系统设计:数据存储
系统设计:数据存储
一、高效读写
作为后端工程师,我们每天都在跟各种存储系统打交道:MySQL 存核心业务数据,Redis 扛高并发访问,RocketMQ 或 Kafka 处理消息洪流,还有各式各样的 NoSQL 数据库处理海量异构数据。
前面我们分别对这些存储系统做了简单介绍。从这篇文章开始,我们讲解这些存储系统背后的一些通用技术方案,比如,如何实现高效读写、索引优化、主从复制、自动故障转移、数据分片等。
今天这篇文章我们讲高效读写,具体讲解存储系统实现高效读写的底层通用方案,包括WAL存储、刷盘策略、读写缓存、零拷贝、避免锁竞争、批处理、数据压缩等。
1. WAL存储
对于大部分存储系统来说,数据都需要持久化,也就是最终落盘(写入磁盘)。磁盘是一个慢速IO设备,随机读写,比顺序读写性能要差一大截,前面的文章有数据对比,你可以翻翻看!
为了提到数据写入磁盘的效率,其中一个有效的办法就是把随机写变成顺序写,怎么做到呢?WAL(Write-Ahead Logging,预写日志)存储方式就可以实现这样的效果。WAL的核心思想是:在真正修改数据之前,先把操作本身(或者操作产生的数据变更)顺序地、只追加地写入一个日志文件。 数据本身的更新(比如修改 B+ 树结构)可能很慢且复杂。WAL 先把变更“存个档”,后续可以异步、批量地应用这些变更到数据本身的更新上,以此大幅提高响应速度。

我们来看这一技术在存储系统中的应用:
- MySQL (InnoDB): redo log 是典型的 WAL。事务提交时,修改先写入 redo log(顺序写),后台线程再将 redo log 中的变更应用到数据页(Buffer Pool),再刷盘。
- Kafka / RocketMQ: 它们的核心存储机制本身就是一种 WAL。所有消息,无论属于哪个 Topic 或 Queue,都严格顺序地追加写入到巨大的 Commit Log 文件中。
- HBase / Cassandra: 它们将写入操作先快速写入一个内存结构(MemTable),同时也会写入一个持久化的 WAL(HBase 叫 HLog,Cassandra 叫 CommitLog),这是为了防止内存数据丢失。当 MemTable 满了刷盘成 SSTable 后,对应的 WAL 段才能被清理。
2. 刷盘策略
数据写到内存缓冲区是很快的,但内存是易失的。最终,数据需要落到非易失的磁盘上才算真正安全。刷盘策略决定了我们何时、如何将内存中的数据同步到磁盘。这本质上是在性能(低延迟、高吞吐) 和 持久性(数据不丢失) 之间做权衡。
数据在最终写入磁盘之前,会经过系统开辟的内存缓冲区(比如,InnoDB的Buffer Pool),操作系统内核管理的Page Cache(由操作系统决定何时刷到物理磁盘),磁盘控制器缓存(现在磁盘都有自己的缓存,通常有掉电保护,这个我们在计算机组成原理中讲到过)。
针对写入操作写入哪个缓存之后就返回,由此就产生了各种不同的刷盘策略。
- 同步刷盘: 必须等待数据成功写入物理磁盘(或至少是磁盘的缓存)后,写操作才返回成功。这是最安全的策略,保证数据绝不丢失(在硬件不能损坏的前提下),但这也是最慢的,因为要等待缓慢的磁盘 I/O 完成。fsync系统调用可以实现这一强制刷盘效果。
- 异步刷盘: 数据写入到操作系统的 Page Cache 就认为写操作成功返回了。操作系统会在后台某个时间点(例如脏页达到一定比例、超时、或调用fsync系统调用 )将 Page Cache 的数据批量刷到磁盘。性能极高,因为避免了等待磁盘 I/O,但如果在刷盘前系统崩溃(如断电),Page Cache 中的数据就会丢失。write 系统调用通常就是异步刷盘(除非设置特殊标志)。
- 折中策略: 间隔固定时间(比如每秒)或者累积 N 条操作之后刷一次盘(后台线程调用fsync系统调用)。在安全性和性能之间取得平衡。
我们来看这一技术在存储系统中的应用:
- MySQL (InnoDB): innodb_flush_log_at_trx_commit 控制 redo log 的刷盘策略:
- 0(异步,性能最高):每次提交事务,会将redo log 写入InnoDB的log buffer,不会写入主动触发写入操作系统的Page Cache,InnoDB 后台线程每秒执行一次将 log buffer 写入 Page Cache,然后调用fsync 刷盘。
- 1(同步,默认,最安全):每次事务提交,都将 redo log 写入 Page Cache,并立即调用 fsync() ,强制将数据从 Page Cache 刷到物理磁盘。
- 2(折中方案):每次事务提交,都将 redo log 写入 Page Cache,但并不立即调用 fsync(),而是每秒执行一次 fsync() 将 Page Cache 中的数据刷盘(可能丢 1 秒数据)。
- Redis (AOF): appendfsync 参数控制刷盘策略:
- always:每条命令都 fsync(同步,最安全)。
- everysec (默认):每秒 fsync 一次(折中)。
- no:由操作系统决定(异步)。
- RocketMQ: flushDiskType 参数控制刷盘策略:
- SYNC_FLUSH:消息写入 PageCache 后,立即调用 fsync 强制刷盘,等待数据落盘成功后才向生产者返回成功 ACK。这种方式保证消息绝不丢失,但会降低写入性能。
- ASYNC_FLUSH (默认):消息写入 PageCache 后立即返回成功 ACK,由后台线程定期(如每500ms)批量执行刷盘(执行fsync系统调用)。这种方式性能高、延迟低,但可能在系统崩溃时丢失少量(约1秒内)未刷盘的消息。
3. 读写缓存
磁盘再快也快不过内存。缓存是提升读写效率最立竿见影的手段。
- 读缓存:将磁盘中的数据缓存在内存中,后续读取相同数据时直接从内存返回,避免磁盘 I/O。这对随机读场景尤其有效,随机读是磁盘最不擅长的操作,而内存的随机访问速度比磁盘快几个数量级。此外,现代存储系统还普遍采用预读机制:当检测到顺序读取模式时,会提前将后续数据块加载到缓存中。
- 写缓存的核心:写入操作先进入内存缓冲区即返回成功,后台异步将数据刷入磁盘。这样可以:将多次小写入合并成一次大写入,减少磁盘 I/O 次数;将随机写转换为顺序写(如 WAL);让写入操作快速返回,降低延迟。
在存储系统中,主要可以利用以下两个层级的缓存:
- 应用层缓存: 存储系统自身管理的内存区域,比如InnoDB的Buffer Pool。
- Page Cache:操作系统内核管理的通用文件缓存。
应用层缓存更适合存储结构化数据(如 B+ 树页、Redis 对象),而 Page Cache 对应用透明。大多数系统会利用其中的一个作为缓存,比如MySQL主要使用应用层缓存Buffer Pool(MySQL 的 InnoDB 默认使用 直接 I/O 绕过内核 Page Cache),Kafka/RocketMQ主要使用Page Cache,这样目的是避免“双缓存”,比如Buffer Pool 和 Page Cache 同时缓存同一份数据,浪费内存。
我们来看这一技术在存储系统中的应用:
- MySQL (InnoDB): Buffer Pool 是其核心组件。它缓存的是数据页和索引页。写入时先修改 Buffer Pool 中的页,读取数据时,先查 Buffer Pool,命中则直接返回;。这是 InnoDB 应对随机读的关键。
- Redis: 整个数据库的核心就是内存。所有数据的读写都直接在内存中进行,这是它极致速度的根本来源。持久化(RDB/AOF)是异步或定时的。
- Kafka / RocketMQ: 当Broker写入消息时,数据会先进入PageCache,然后后台线程异步刷盘。这样消费者读取时如果数据还在PageCache中,就能直接从内存读取,速度很快。
4. 零拷贝
其实,在《Java编程之美》中,我们在讲解I/O的时候,已经详细讲解了零拷贝技术,这里简单再介绍一下。
传统的文件传输(如从磁盘读文件发网络)过程繁琐:
- 磁盘文件读到内核缓冲区 (Page Cache)。
- 内核缓冲区数据拷贝到用户缓冲区(应用进程内存)。
- 用户缓冲区数据拷贝到内核的 Socket 缓冲区。
- Socket 缓冲区数据通过网卡发出去。
这个过程涉及 4 次上下文切换 (用户态/内核态切换) 和 2 次 CPU 数据拷贝。零拷贝技术的目标就是消灭不必要的 CPU 数据拷贝和上下文切换。注意,零拷贝并不是指完全不拷贝,是尽量减少拷贝,这里解释一下避免误解!

实现零拷贝的技术主要有:
- mmap + write: 使用内存映射 (mmap),让应用程序的用户空间地址直接指向内核的 Page Cache,这样应用程序读取文件数据就像访问内存一样,省去了“内核缓冲区->用户缓冲区”的拷贝。当调用 write时,数据会从 Page Cache 直接拷贝到 Socket 缓冲区。
- sendfile: 系统调用直接在内核中完成数据从文件描述符(对应 Page Cache)到 Socket 描述符的传输。完全避免了数据进入用户空间。
我们来看这一技术在存储系统中的应用:
- Kafka: 零拷贝是其高吞吐的关键。 Broker 向 Consumer 发送消息时,大量使用 sendfile。消息直接从 Page Cache 经由网卡发送出去,避免了在内核空间和用户空间之间来回复制巨大的消息体。
- RocketMQ: 同样广泛使用零拷贝。消息消费时,Broker 通过 mmap 将 CommitLog 文件映射到内存,省去消息从“内核缓冲区→用户缓冲区”的复制。
5. 锁竞争
高并发下,锁是保证数据一致性的必要手段,但锁竞争会严重降低系统吞吐量,增加延迟。追求高性能的系统都在努力减少锁的范围、降低锁的粒度、缩短锁的持有时间,甚至尽量避免锁。
常用减少锁竞争的策略:
- 无锁数据结构: CAS (Compare-And-Swap) 等原子操作实现的无锁队列、计数器等。
- 读写分离: 读操作通常不修改数据,可以并发执行。使用读写锁 (ReadWriteLock) 允许多个读并发,只在写时互斥。
- 分区加锁: 将数据分成多个独立区间(Shard/Partition/Queue),每个区间有自己的锁。这样并发请求只要落在不同区间,就能并行处理,互不干扰。ConcurrnetHashMap就是这一个思想的经典体现。
- 单线程模型: 一个线程处理所有任务(包括网络 I/O 和命令执行),通过非阻塞 I/O 多路复用 (如 epoll, kqueue) 处理海量连接,彻底避免了线程上下文切换和锁竞争,适用于I/O密集的场景。Redis就是这么干的!
我们来看这一技术在存储系统中的应用:
- Redis: 单线程模型是其经典设计。它使用 I/O 多路复用处理海量连接,所有命令在一个线程中串行执行,天然避免了锁竞争。
- Kafka / RocketMQ:分区是并行和避免锁的关键。 一个 Topic 分成多个 Partition/Queue。Producer 可以并发地向不同 Partition/Queue 发送消息。Consumer Group 内多个 Consumer 可以并行消费不同 Partition/Queue 的消息。
- MySQL:InnoDB 使用行级锁,相对于 MyISAM 的表锁,锁粒度更细,并发能力更强。
- NoSQL: 分片几乎是所有分布式 NoSQL 的标配(如 MongoDB Sharding, Cassandra Partitioning, HBase Region),核心目的之一就是实现并行处理和避免全局锁竞争。
6. 批处理
网络 I/O、磁盘 I/O、系统调用都是有开销的。批处理的核心思想就是将多个小的操作请求打包成一个大的请求进行处理。这样做的好处有:
- 大幅减少网络往返次数: 对于网络通信,一次发送 N 条消息比发送 N 次单条消息快得多。
- 减少磁盘 I/O 次数: 磁盘对顺序的大块写入更友好。批量写入可以将多次小 I/O 合并成少量大 I/O。
- 摊薄固定开销: 每次操作都有固定开销(如建立网络连接、上下文切换、磁盘寻道等),批量处理可以摊薄这些开销。
我们来看这一技术在存储系统中的应用:
- Kafka Producer: linger.ms 和 batch.size 参数控制 Producer 端消息的批处理。Producer 会积累一批消息再发送给 Broker,而不是一条一送。
- Kafka Consumer: fetch.min.bytes 和 max.poll.records 控制 Consumer 一次拉取的消息量,支持批量拉取,批量处理。
- RocketMQ Producer/Consumer: 同样支持批量发送消息和批量拉取消息。
- Redis Pipeline: 这是 Redis 应对高并发请求的利器。客户端可以将多个命令一次性发送给 Redis 服务器,服务器依次执行所有命令后,将结果一次性返回。避免了传统“发送命令->等待响应->发送下一条命令”模式中的多次网络通信。
- MySQL:批量插入 (INSERT ... VALUES (...), (...), ...) 比单条插入 (INSERT ... VALUES (...)) 快很多倍。
- NoSQL: 大多数 NoSQL 都提供批量写入和批量读取的 API(如 MongoDB 的 bulkWrite(), Cassandra 的 BatchStatement)。
7. 数据压缩
压缩就是用 CPU 时间换取网络带宽和磁盘 I/O 带宽,也就是我们常说的时间换空间,一般来讲,压缩耗费的CPU时间,要少于压缩后传输或者存储所节省的时间,因此,收益是正的。
- 减少网络传输量: 对于分布式系统,尤其是跨机房传输,压缩能显著降低网络带宽消耗和传输延迟。
- 减少磁盘 I/O 量: 压缩后的数据写入磁盘更快,读取时需要传输的数据量也更少(虽然需要解压)。
- 节省存储空间: 这是显而易见的。
我们来看这一技术在存储系统中的应用:
- Kafka: Producer 端可以配置 compression.type (gzip, snappy, lz4, zstd)。Producer 对消息进行压缩后再发送给 Broker。Broker 存储压缩后的消息。Consumer 收到消息后自动解压。
- RocketMQ: 支持 Producer 端对消息进行压缩(需要用户自己实现),Broker 存储压缩后的消息,Consumer 解压。相比 Kafka,其原生支持稍弱一些。
- NoSQL: 大多数现代 NoSQL 都支持数据压缩,比如,HBase / Cassandra 支持多种压缩算法(Snappy, LZ4, GZ, Zstandard)压缩磁盘上的数据块。MongoDB支持 Snappy, Zlib, Zstandard 压缩存储引擎的数据和索引。
8. 最后总结
综合MySQL、Redis、Kafka、RocketMQ这些存储系统,它们提升读写效率的手段虽然多样,但核心思想是相通的,无外乎就是我们这篇文章讲到的这些:WAL存储(顺序写磁盘)、灵活的刷盘策略、读写缓存、零拷贝、避免锁竞争、批处理、数据压缩。掌握这些经典而普适的技术方案,就如同掌握了内功心法,能让我们在面对层出不穷的新存储引擎时,也能快速洞察其性能奥秘。
二、索引优化
上一节课我们讲了存储系统加速读写的通用技术,其中有一个非常重要的点我们没有讲,那就是索引优化。为了实现高效查询,基本上每个存储系统都构建自己的索引结构,当然,查询的方式不同,对应的索引结构也有所不同。常见的索引结构有:B+树、哈希表、顺序表、LSM树等。
这节课我们就结合前面讲到的存储系统(MySQL、Redis、Kafka / RocketMQ、 NoSQL DB),讲一讲这些索引的基本原理、特性和应用。
1. B+树
在《数据结构与算法之美》中,我们已经详细讲解过B+树了,这里我们简单介绍一下它的几个关键特性。
- 多叉层级少:它不像二叉树那样“瘦高”,B+树每个节点能存很多“键”和指向下一级节点的指针,这使得树的高度通常很低(3-4层就能存海量数据),查询数据时磁盘 I/O 次数少。
- 性能稳定: 所有数据记录都存储在最底层的叶子节点里。非叶子节点只存“键”和指针。这保证了查询任何一条记录,路径长度都一样(性能稳定)。
- 叶子节点有序:所有叶子节点用指针按顺序连成一个有序双向链表,对于范围查询(WHERE age BETWEEN 20 AND 30)和排序查询(ORDER BY)非常友好,顺着链表读取就行,效率极高。
- 平衡性:插入删除数据时,树会通过分裂、合并节点自动保持平衡,保证查询效率稳定。
我们知道,MySQL需要支持点查(按照键精确查找某个记录)、范围查和排序,并且,支持高性能写入、修改操作,B+树非常完美的支持了这些功能需求,因此,MySQL的索引结构使用B+树来实现。当然,B+树也有问题,就是数据是随机写磁盘,相对于顺序写,磁盘写入性能不高,这也是为什么会先记录redo log(顺序写,上一节讲到了)再异步构建索引和存储数据的原因。
MySQL的索引类型有很多,比如主键、唯一键、联合键、前缀键等,它们本质上都是 B+ 树,只是在数据存储和使用方式上有所不同。我们简单介绍下它们对应的索引结构,当然,这里只是简单介绍,如果感兴趣的话,可以进一步查阅资料。
- 聚簇索引 每个 InnoDB 表有且只有一个聚簇索引,叶子节点包含完整的行数据。如果表定义了主键(PRIMARY KEY),那么主键就是聚簇索引的键。如果没有显式定义主键,InnoDB 会选择一个唯一的非空索引(UNIQUE NOT NULL)作为聚簇索引。如果也没有这样的索引,InnoDB 会隐式创建一个隐藏的列(DB_ROW_ID)作为聚簇索引的键。
- 二级索引 我们手动创建的索引(CREATE INDEX ...)或唯一约束(UNIQUE KEY)都属于二级索引(主键索引除外)。一个InnoDB表可以有多个二级索引。叶子节点存储的是该索引键的值 + 对应行的聚簇索引键(主键值)。因此通过二级索引查询数据,需要两次查询,先通过二级索引查找到聚簇索引键,然后通过聚簇索引键在聚簇索引中查找数据行。
- 联合索引 在多个列上建立的索引(如 INDEX idx_name_age (name, age))。其本质上也是一个 B+树。键值由多个列的值按定义顺序组合而成(如 (Alice, 30), (Bob, 25), (Bob, 30))。联合索引使用最左前缀匹配原则,也就是说,查询条件必须包含联合索引定义中最左边的一个或多个列,才能有效利用该索引。比如,联合索引 (a, b, c),WHERE b = 2 索引无效,因为缺少最左列a, WHERE a = 1 AND c = 3 部分索引有效,只用到 a 列索引,c 列无法利用索引过滤。
- 前缀索引 对于很长的字符串列(如 CHAR(255), TEXT),可以只对列值的前面一部分字符建立索引(如 INDEX idx_email_prefix (email(10)))。
2. 哈希表
哈希表虽然不支持B+树那么多丰富的查询方式(点查、范围查、排序查),但是,它结构简单且点查效率非常高(时间复杂度是O(1) ),特别适合只支持简单查询的Key-Value数据库,比如Redis。
Redis使用一个全局的大的哈希表来给所有的数据构建索引。当然,为了满足更多的查询功能,Redis给Value建立了二级索引,不同类型的Value(Set、SortedSet、HSet等)使用不同的索引结构。比如,SortedSet使用ziplist 或 skiplist + dict来存储Value数据,支持按照value中某个属性的值范围来查询。
# 添加玩家初始积分(支持批量操作)
ZADD leaderboard 1500 "player:A" 1200 "player:B" 800 "player:C"
# 查询积分在 [1000, 2000] 的玩家(升序排列)
ZRANGEBYSCORE leaderboard 1000 2000 WITHSCORES实际上,MySQL也用到了哈希表。当MySQL InnoDB发现某些索引值被非常频繁地访问(热点数据)时,它会在内存的 Buffer Pool 中为这些值建立一个哈希索引,极大地加速了等值查询(WHERE key = ?)的速度。注意,哈希表索引由 InnoDB 根据负载自动创建和删除,无需 DBA 干预。
3. 顺序表
我们知道,对于一个有序的数组,我们可以使用二分查找加速查询(查询时间复杂度是O(logn),对数级是非常高效的,具体可以看《数据结构与算法之美》)。实际上,Kafka和RocketMQ就是使用这种顺序表结构来存储索引的,支持消费者从任意位置(偏移量 Offset)读取消息。
前面介绍消息中间件的时候,我们提到RocketMQ的存储结构,主要包含:CommitLog、ConsumeQueue、ConsumeOffset、IndexFile。其中IndexFile是用于运维,非主消费链路,使用的是哈希结构。前三个组成了RocketMQ的主存储结构。我们再简单重述一下它的存储逻辑。
CommitLog存储所有的消息,支持磁盘顺序写,ConsumeQueue相当于索引结构,每个Queue都对应有一个ConsumeQueue。ConsumeQueue存储固定长度的记录(20个字节),每个记录包含消息在CommitLog中的物理偏移量、消息长度等,基于此可以在CommitLog中读取此消息。ConsumeOffset记录的是每个consumerGroup对每个Queue中消息的消费记录,也就是在此Queue中的第几个消息。
因为ConsumeQueue中存储的是定长数据(每条记录20个字节),因此,ConsumeQueue就相当于一个数组,而ConsumeOffset中存储的就相当于是数组下标,因为通过数组下标可以快速计算出数组中对应元素的位置(请参看《数据结构与算法之美》),因此,我们可以快速定位ConsumeOffset中的数组下标对应在ConsumeQueue中的记录,然后解析出消息在CommitLog中的偏移位置,最后读取消息。
Kafka的索引结构有所不同。消息并非全部存储在一个文件中,而是每个Partition(也就是RocketMQ中的Queue)存储一个单独的文件(.log文件),每个文.log文件对应构建一个索引(.index文件),不过,不同的是,索引文件中存储的数据稍有不同。RocketMQ的ConsumeQueue为Queue中的每条记录构建索引项,但是,Kafka中的.index索引文件并不为Partition中的每条消息都构建索引项,而是每隔一定的字节数(由 log.index.interval.bytes 配置,默认 4KB)的消息数据,在索引文件中添加一条索引项。这意味着索引文件通常很小(默认每个 .index 文件最大 10MB)。
简单来讲,可以这么理解,RocketMQ ConsumeQueue中存储的是Queue中第1、2、3、4...个消息在CommitLog中的偏移位置,而Kafka .index文件中存储的是Partition中第1、6、14、35...个消息在.log文件中的偏移位置(当然这里的数据是举例用的,并非真实数据)。
那么,如果我们要查找第9个消息在.log中的存储位置怎么办呢?因为.index实际上是一个顺序表,我们可以基于二分查找快速的找到比9小的最后一个索引项(也就是第6个消息对应的索引项),然后在.log中找到第6个消息,顺序往下遍历读取,直到找到第9个消息。
4. LSM树
LSM树(Log-Structured Merge-Tree)专为极致「写入」吞吐量而生,是 NoSQL 数据库(如 LevelDB, RocksDB, Cassandra, HBase)的核心存储引擎,在写入密集型场景(如时序数据、日志、物联网)中应用极为广泛。
传统 B+ 树等结构在更新数据时,需要先找到数据所在的磁盘位置(随机读),然后原地修改(随机写)。而磁盘的随机 I/O 性能(尤其是机械硬盘)远低于顺序 I/O 性能(几个数量级的差距)。既然磁盘顺序写这么快,那为什么不让所有的写操作都变成顺序追加呢?
当然可以,我们可以将存储数据改为存储操作(有点类似WAL),记录一个数据的所有操作:写入、更新、删除。比如写入a的值为5,我们把这个操作(a=5)在文件中顺序记录下来,后面又更新了a的值为7,我们把新版本的数据(a=7)也记录下来,而不是去修改老的记录。最后我们又执行了删除操作,我们把删除操作(DEL a)也记录下来。这样所有的操作都是顺序写入文件的,写入性能非常高!当然,查询性能就降低了,当我们要查询a的值时,我们需要将所有的对a的操作都查询出来,然后综合出一个最新值。
以上只是基本处理思想,实际上还有很多细节需要考虑。接下来,我们具体来讲讲LSM树的实现原理。这里需要声明一下,虽然它叫LSM树,实际上跟树关系不大,不要被名字带偏了。
LSM树有四个核心概念:MemTable、Immutable MemTable、SSTable、Compaction。
- MemTable 新写入的数据首先被放入一个驻留在内存中的、有序数据结构MemTable(通常是跳表 Skip List 或 B树变种)。除此之外,写操作还会顺序追加到WAL(上一节课讲到)用于持久化(保证崩溃恢复),当MemTable中的数据写入磁盘之后,WAL就会删除。
- Immutable MemTable 当 MemTable 满了,它就变成只读(不可变)。后台线程开始将它刷到磁盘上,生成一个 SSTable。同时,一个新的空 MemTable 顶上接收新写入操作。也就是说,内存中一般同时会有一个可写的MemTable和一个只读的Immutable MemTable。
- SSTable SSTable是 MemTable 刷盘的产物,这些文件是不可变的。SSTable包含数据块和索引块。数据块内部按键有序存储。索引块类似前面讲到的Kafka的索引结构,存储部分键在数据块中的偏移量位置。这允许快速查询键对应的数据,避免扫描整个文件。
- Compaction 当我们要查询数据的时候,先在内存中的MemTable中和Immutable MemTable中查询,如果查找就返回了,如果查找不到就在所有的SSTable中查找。
随着SSTable的增多,尽管SSTable有索引,但是仍然需要逐个都查询一下,性能也会随着降低。除此之外,一个数据可能经历多次更新操作,随着操作的增多,SSTable越来越多,对存储也是压力。实际上,我们只关心对这个数据的最新值,因此我们可以后台对SSTable进行合并处理,同一个数据只保留最新值,将小的SSTable合并成大的SSTable,以减少SSTable的个数,这样既减少了存储压力,也提高了查询速度。
5. 最后总结
回顾这一课,我们探讨了存储系统常用的几种索引结构:B+树、哈希表、顺序表以及LSM树,它们是在应对多样化查询需求(点查、范围、排序)、硬件瓶颈(尤其是磁盘I/O)、海量数据写入时,做出的精妙设计与权衡。
B+树各方面的表现都非常稳定全面,是MySQL的索引结构。哈希表适合点查,是Key-Value数据库Redis的索引结构。顺序表结合二分查找特别适合消息队列这种按照偏移消费消息的场景,是Kafka/RocketMQ这些消息中间的索引结构。LSM树是写入密集型场景的终极大招,特别适合海量存储,是众多NoSQL数据库的索引结构。
三、主从复制
在讲解MySQL、Redis、RocketMQ的时候,我们讲到为了提到可用性、性能、可靠性的扩展架构,包括:主从复制(包含读写分离)、自动故障转移、数据分片,接下来三节课我们就依次讲下它们。这节课我们讲主从复制。
1. 基本原理
主从复制有两个核心概念,一个是主节点,一个是从节点。主节点,也称为Master或者Leader,处理所有的写操作。从节点,也成为Slave、Follower、Replica,主节的写操作会复制到从节点进行重放,最终从节点跟主节点具有一致的数据。从节点可以只负责数据冗余,也可以负责读操作,视具体需求来定。
主从架构可以是主节点负责读写,从节点只负责备份,也可以是主节点负责写,从节点负责读。除此之外,主从架构可以是一主一从,也可以是一主多从。主从的复制模式有多种:异步、半同步、同步。它们最核心的区别就在于主节点在响应客户端写请求时,需要等待多少个从节点确认收到数据,这直接决定了数据一致性的强度、系统的性能和可用性。
(1)异步复制
这是最常见、性能最好的复制模式,但也是数据可靠性要求最“宽松”的一种。主节点处理完客户端的写操作(比如,MySQL在本地提交事务并写入Binlog/WAL),立刻就响应客户端“写入成功”。它不会等待任何从节点确认是否已经接收并应用了这个变更。复制数据是在后台异步进行的。
- Binlog 是主从复制的核心。主库将事务的变更写入 Binlog,从库的 I/O 线程拉取 Binlog 并写入本地的 Relay Log,再由 SQL 线程重放执行。
- WAL(redo log) 在主从复制中不直接参与。它的作用是在主库本地保证事务持久性:即使主库宕机,已提交的事务也能通过 redo log 恢复。
异步复制优点非常明显:写操作的响应延迟最低,主节点的吞吐量最高,因为写操作不会被慢速的从节点拖累。缺点也很突出:如果主节点在变更成功发送到从节点之前就突然宕机了,即使客户端已经收到了成功响应,这个已“提交”的变更也可能会永久丢失(因为还没来得及复制到从节点上,主节点磁盘损坏恢复不了了),导致数据不一致。
(2)半同步复制
这种模式是异步和同步之间的一种折中,试图在性能和一定的数据安全性之间取得平衡。主节点处理完写操作并写入Binlog/WAL后,不会立刻响应客户端。它会等待至少一个从节点(可以配置个数)确认已经成功接收到了这个变更事件(注意:通常只是确认接收并写入从节点的Relay Log,不一定是完全应用成功),然后主节点才会给客户端返回“写入成功”的响应。
相比异步复制,它显著降低了主节点故障时数据丢失的风险(只要有一个从节点确认收到了变更,这个数据大概率就能保住)。缺点是,写操作的延迟会明显增加,因为它需要等待从节点的ACK确认。
(3)同步复制
这是数据可靠性要求最高的模式。主节点处理完写操作后,必须等待所有配置的从节点(或者一个法定数量的从节点,通常要半数以上)都确认不仅收到了变更,而且已经成功在本地应用(提交)了这个变更,之后才会给客户端返回“写入成功”的响应。
优点是提供了最强的一致性保证。只要主节点返回成功,数据肯定在所有参与的节点上都持久化了,主节点宕机不会导致已确认的数据丢失。缺点是,写操作的延迟和性能代价是三种模式中最高的,因为要等待所有配置的从节点完成操作;任何一个从节点响应慢或故障,都会导致整个写操作被阻塞,严重影响主节点的可用性和吞吐量。
2. 应用场景
搞清楚了主从复制的工作原理,我们再来看看它能解决哪些问题,其实也就是它的应用场景。
(1)高可用与灾难恢复
这是主从复制的首要任务。当主节点不幸宕机时,我们可以(通常是手动或者借助工具自动)将一个从节点提升为新的主节点。其他从节点可以指向这个新主节点继续复制数据。这样服务就能比较快地恢复,大大缩短了不可用时间。这比从零恢复快多了,是灾难恢复的重要手段。
(2)读写分离与读扩展
大部分系统都是读多写少的,读请求可能是写请求的几倍、甚至十几倍。主从复制实现读写分离,主节点负责写操作,从节点负责读操作。对于读多写好的场景,尤其是那些不要求绝对实时最新数据的场景(比如用户浏览商品、查看朋友圈),通过部署多个从节点,我们就能把大量的读请求分摊开,显著减轻主节点的负担,提升整个系统的读能力。想想看,一个主节点带十个从节点,理论上读能力能提升十倍(当然还会受限于复制延迟等因素)。
(3)降低主节点负载
承接上一点,把读请求导到从节点,主节点访问压力减少,就能更专注、更高效地处理写请求。
(4)数据备份
在主从复制架构中,从节点本身就是一个准实时的热备份(主节点不停机备份)。虽然它通常不能完全替代定期的全量备份,但它提供了一个非常近的恢复点,用于恢复误操作或者快速重建节点非常方便。而且,我们可以直接在从节点上做备份操作,完全不影响主节点的性能。
(5)地理分布与就近访问
我们可以把从节点部署在离用户更近的地方(比如不同地域的机房)。用户查询数据时,可以直接访问当地的从节点,大大降低了网络延迟,提升了用户体验。这在全球化服务中尤其重要,但也需要注意延迟导致的数据一致性问题。
3. 技术挑战
当然,主从复制也不是万能的银弹,了解它存在的问题,才能更好的应用在适合的场景中。
(1)复制延迟
这是最核心的问题。数据从主节点写到从节点,必然需要时间。网络传输、从节点执行的速度、主节点写入压力大等等都会导致延迟。这就意味着,用户在从节点上读到的数据,可能不是主节点上最新的版本。这个延迟可能是毫秒级,也可能是秒级甚至分钟级(在极端情况下)。
这会导致“写后读不一致”的问题:用户刚在主节点写完数据,马上到从节点去读,发现没读到刚写的内容。所以,在设计业务逻辑时,必须考虑这种延迟的存在。对实时性要求极高的读操作,可能还得去主节点读。或者通过监控告警系统,时刻关注主从数据复制的延迟时间,正常为毫秒级别,一旦达到秒级别就立刻告警,并介入处理。
(2)主节点单点写瓶颈
虽然读可以扩展了,但所有的写操作还是压在单一主节点上。如果写请求量巨大,主节点依然可能成为性能瓶颈。这时就需要更高级的技术,比如分库分表,把数据分散到多个独立的主节点上去,每个主节点负责自己那一部分数据的读写。这个我们后面的课程会详细讲解。
(3)故障切换的复杂性
如果主节点挂了,我们可以手动选择一个从节点为主节点,并重新配置主从复制关系,快速恢复服务,但是,这需要监控系统敏锐的发现主节点宕机,并且运维人员随时待命及时处理,否则,服务的不可用时间就会加长。其实,为了更快速地响应故障,现在多数数据存储系统,比如MySQL、Redis、RocketMQ,都支持自动故障转移。自动故障转移底层依赖共识算法,需要多机部署(至少3台),部署更加复杂。
(4)数据一致性问题
在主从架构下,尤其是在有复制延迟的情况下,我们通常只能保证数据的最终一致性。即如果没有新的写操作发生,经过一段时间后,所有从节点最终都会和主节点数据一致。强一致性(所有节点瞬间一致)在主从模式下很难实现,且代价高昂。
4. 具体实现
现在,我们对主从复制有了深刻的理解,接下来,我们来看下MySQL、Redis、RocketMQ具体如何实现主从复制的。
(1)MySQL实现
MySQL是基于Binlog进行异步或半同步的复制。主节点执行事务并写入 Binlog。Binlog Dump Thread 在主节点运行,监控 Binlog 变化。从节点的 I/O Thread 连接到主节点的 Binlog Dump Thread,请求并接收 Binlog 事件,写入本地的 Relay Log。从节点的 SQL Thread 读取 Relay Log 中的事件,并在本地数据库重放这些 SQL 语句,使数据与主节点最终一致。
前面我们提到过MySQL的redo log,这里又来了一个binlog,我们稍微解释一下它们的区别。
MySQL 的核心优势之一是其 可插拔的存储引擎架构。Server 层负责 SQL 解析、优化、执行等通用逻辑,而存储引擎(如 InnoDB, MyISAM 等)负责底层数据的存储、索引和事务实现。redo log 是 InnoDB 存储引擎特有的物理日志。其他存储引擎(如 MyISAM)没有 redo log。binlog 是由 MySQL Server 层实现和维护,与底层的存储引擎无关。无论使用的是 InnoDB、MyISAM 还是其他引擎,只要 Server 层执行了修改数据的操作,都会记录到 binlog。
redo log 记录的是对某个表中某个数据页(Page,通常是 16KB)的某个偏移量(Offset)所做的具体修改(例如:将 Page 1234 上偏移量 567 处的 4 个字节从 0x00 改为 0xFF)。它不关心具体的 SQL 是什么,只关心物理数据页的变化,主要用于崩溃恢复,这种设计使得恢复非常高效。binlog记录的是改变数据的逻辑操作(SQL语句或者修改前后的行数据),主要用于主从复制 。
主从复制的核心目标是复制数据库的逻辑变更(执行了哪些 SQL 语句或修改了哪些行数据)。基于 binlog 可以实现跨存储引擎的复制。一个使用 InnoDB 的主节点,其从节点可以(理论上,虽然不推荐)使用 MyISAM,只要 SQL 兼容即可。如果依赖 redo log,复制就完全绑定在 InnoDB 引擎上了。
(2)Redis实现
Redis基于 RDB 快照和增量命令进行异步复制。
从节点连接主节点后,主节点执行 BGSAVE 生成 RDB 快照文件,同时把生成期间的写命令缓存到复制缓冲区。快照文件发送给从节点后,从节点清空旧数据,加载 RDB 恢复到快照点状态。
RDB 加载完成后,主节点把缓存在复制缓冲区中的写命令(即 RDB 生成期间积压的命令)发送给从节点执行,让从节点追上主节点的进度。之后,主节点每收到一个写命令,就异步地实时发送给所有连接的从节点执行。
如果网络短暂中断,从节点重连后,会向主节点请求断开期间缺失的命令。主节点根据复制缓冲区中保留的命令直接补发,避免重新生成 RDB。
(3)RocketMQ实现
RocketMQ基于 CommitLog 的消息拉取复制,支持异步复制和同步双写。
- 异步复制 (默认): 主 Broker 写入本地 CommitLog 后即返回成功给 Producer,然后异步将消息复制给从 Broker。主Broker宕机(特指磁盘损坏之类的无法恢复的故障)可能丢失未复制的消息。
- 同步双写 (需配置): 主 Broker 在写入本地 CommitLog 的同时,同步等待将消息写入至少一个从 Broker 成功后才返回成功给 Producer。保证主从数据强一致,主Broker宕机无数据丢失,但写延迟增加。
需要注意的是,主Broker处理所有 Producer 的写入和 Consumer 的读/拉请求。从Broker只负责备份。为什么 RocketMQ 不支持读写分离呢?最主要的是消费进度的管理问题。如果让消费者随机选择主从节点消费,会导致消费进度在多个节点上不一致,难以保证消息的顺序消费和精确一次处理。RocketMQ的设计更注重消息的顺序性和一致性,它的主从复制主要是为了高可用和灾难恢复,而不是负载均衡。如果需要水平扩展读能力,可以通过增加Queue的个数来实现。
5. 最后总结
主从复制本质上是一种通过数据冗余来提升系统可用性、数据可靠性和读扩展能力的架构模式,解决了单点故障这个核心痛点,并顺带提供了读写分离等功能。理解它的工作原理、应用场景、技术挑战(复制延迟、数据不一致等)是后端架构师的必备知识。在技术选型和架构设计中,我们要根据业务的容忍度(能接受多大延迟?对一致性要求多高?)来权衡是否采用主从复制,以及如何配置(异步?半同步?同步?部署多少个从节点?)。
四、故障转移
上一节课我们讲到主从复制,它是保证系统可用性、数据可靠性、以及扩展读性能的有效手段。如果主节点挂了,我们可以手动选择一个从节点为主节点,并重新配置主从复制关系,快速恢复服务,但是,这需要监控系统及时发现主节点宕机,并且运维人员随时待命及时处理,否则,服务的不可用时间就会加长。
想象这样一个场景:深夜两点,数据库突然宕机。告警短信炸响手机,运维团队从被窝爬起来紧急处理。这种画面在以高科技著称的互联网行业本不该出现。其实,为了更快速地响应故障,现在多数数据存储系统,比如MySQL、Redis、RocketMQ,都支持自动故障转移。自动故障转移的价值在于:它让系统在硬件故障、网络抖动、软件异常时,能像人体免疫系统一样自我修复,保障业务持续运行。
1. 技术挑战
实际上,真正落地自动故障转移,对于有状态的存储系统来说(相对于无状态的服务),并不是一件容易的事情。这其中的技术挑战有这样一些:
- 数据不一致性风险 在多数主从架构中,为了保证性能,一般都会采用异步或者半同步的复制方式。如果主节点写入成功即返回,从节点还未复制数据或者还未完全落盘。若此时主节点宕机且数据丢失,即使切换到从节点,这部分未同步的数据也会永久丢失。
- 脑裂问题风险 如何精准地判断节点故障?如果从节点因为网络分区误认为主节点宕机,然后推举自己成为新的主节点,或者多个从节点之间存在网络分区,两个分区选举出各自的主节点,这样就出现了两个“大脑”,这种脑裂问题该如何解决。
- 平衡不可用和频繁选主 如果把故障检测时间设得太短(比如1秒),网络稍微抖动就可能触发选主。想象一下,系统在1分钟内反复切换主节点,客户端不断收到读写错误(Leader选举的过程写会失败或者阻塞,读有可能也不可用),这种“癫痫式切换” 更致命。但如果把检测时间设得太长(比如30秒),主节点真宕机时,业务要忍受半分钟不可用。
- 客户端无缝重连 当有新的leader产生的时候,客户端如何快速的感应到?除此之外,因为网络分区问题,旧主节点可能还活着(错误的被其他从节点认为宕机了),客户端仍向其发请求。这些写入怎么处理?怎么判断哪个Leader才是真正的新的Leader?
从上面的这些挑战,你应该也已经发现,自动故障转移不仅仅涉及Leader的选举问题,还有一个更重要更复杂的问题那就是数据的一致性保证。其实,这些问题在共识算法中统统都有解决。
2. Raft算法
前面在讲分布式协调的时候,我们讲到,Paxos共识算法晦涩难懂难以实现,Raft共识算法降低了实现难度,应用更多。因此,这里我们拿更广泛使用的Raft算法来举例讲解,让你对共识算法有个深刻的了解。
在Raft共识算法中,传统主从复制架构中的主节点、从节点被以下概念所替代:
- Leader:唯一接受客户端写请求的节点。负责处理日志(这里的日志是什么意思,我们稍后解释)写入,并将日志复制给 Follower。
- Follower:复制 Leader 日志,并将日志持久化到本地磁盘,负责读操作或者单纯数据备份。除此之外的另一个重要作用是:参与 Leader 选举投票。
- Candidate:Leader候选人。在 Leader 选举期间出现的临时角色。Follower 在认为 Leader 失联时会转变为 Candidate 并发起选举。
这里我们稍微解释一下什么是日志。前面我们提到,共识算法不仅仅解决Leader的选举问题,还要解决数据的一致性问题,实际上,这里的日志就是数据的抽象概念。其实,在上一节课中我们已经提到了,对于MySQL来说,他就是binlog日志,对于Redis来说,他就是RDB快照+增量指令,对于RocketMQ来说,它就是CommitLog。
当然,为了解决前面提到的技术挑战,日志不单单存储数据,还会存储用于解决数据一致性、Leader选举的其他辅助数据。Raft算法中的日志会包含以下三个关键信息,这三个信息在后面详细讲解算法的时候会频繁用到。
- 指令(Command):客户端的具体操作;
- 索引(Index):日志条目的唯一递增序号;
- 任期号(Term):创建该条目的Leader任期编号。
其实,共识算法主要分为两个部分:Leader选举和日志复制。接下来,我们详细介绍一下。
(1)日志复制
客户端会保存Leader节点和Follower节点的信息或者通过服务发现(注册中心)来获取。客户端将数据发送给Leader节点,Leader节点会将写入操作作为一条新的日志条目追加到本地的WAL中。
Leader节点随即将日志发送给所有的Follower节点。Follower节点收到日志后,会写入自己的WAL,之后返回成功确认消息(ACK)给Leader节点。
Leader节点在收到超过半数节点(包括自己)的ACK之后,认为日志条目已经达到多数派持久化(Quorum机制,多数写入就算成功),即消息被安全可靠的存储下来了。之后,Leader节点才会返回客户端写入成功。
当客户端读取数据时,读取的方式有多种,看具体情况去实现。可以是只有Leader才能响应读写,Follower只负责备份和故障转移;也可以是基于Quorum机制,读必须同时从过半数的节点中读取,然后选择一个索引Index(也就是前面提到的日志编号)最新的返回给客户端,这两种方法一定能保证读取的是最新的。当然,如果对数据的读取的一致性要求没那么高,也可以直接Follower中读取。
(2)Leader选举
Leader会周期性地向所有的Follower发送心跳。每个Follower都维护一个选举超时计时器。如果在该时间内没有收到 Leader 的有效心跳或日志复制请求,Follower 会认为 Leader 已经失效。
认为 Leader 失效的 Follower 会自增任期号 (Term),将自身状态转变为 Candidate,并向集群中所有其他节点发起 RequestVote请求投票。
节点(包括其他 Follower 和可能的 Candidate)收到投票请求后:只会在一个任期内投一次票(先到先得),并检查 Candidate 的日志是否比自己更新(Term 更大,或者 Term 相同但 Index 更大或相等),如果满足条件,则投票给该 Candidate。
如果一个 Candidate 收到了超过半数节点的投票(包括自己的一票),它就赢得了选举,成为新的 Leader。新 Leader 立即向所有其他节点发送心跳,宣告自己成为 Leader 并阻止新的选举,并启动同步日志,其他Follower同步这个新的Leader的日志。
如果旧的 Leader 恢复后(可能因网络分区暂时失联),它会发现自己任期号较低,会自动降级为 Follower。
从上述原理可以看出,分布式系统在面临网络分区时仍能保障数据一致性与决策有效性的核心机制是多数派原则(Quorum):任何关键操作(如选主或数据提交)必须获得超过半数节点的同意。这意味着即使部分节点故障,只要存活节点数大于 ⌊N/2⌋(N为总节点数),系统就能持续服务。因此,基于共识算法(如Raft)的高可用架构必须部署至少3个节点,且推荐奇数节点部署。
3. 工程实践
以前我们需要自己去搭建故障自动转移架构(比如使用Zookeeper),现在各个存储系统都随着迭代开发逐渐完善,很多都已经内置了故障自动转移功能,比如MySQL的MGR、Redis的Sentinel、RocketMQ的DLeger。它们三者都采用了类似Raft的共识算法(根据场景和需求做了调整)。
其中,MySQL MGR和RocketMQ DLedger是主从复制的替代架构,通过故障自动转移进一步提高了系统的可用性,不过,Redis Sentinel是主从复制架构的增强,原有的存储数据的主从节点仍然存在,Redis Sentinel需要额外部署(至少部署3个Sentinel节点)。Redis Sentinel只充当了Leader选举的功能,当主节点故障时,多个Sentinel节点通过Raft协议选举一个领导者(Leader Sentinel)。该领导者负责执行故障转移流程。Leader Sentinel基于优先级、数据新旧程度等指标选择新主节点,并发送 SLAVEOF NO ONE 命令提升其为主节点,最后,更新其他从节点复制的目标节点,并通过发布订阅通知客户端新主节点信息。
4. 最后总结
MySQL MGR、Redis Sentinel、RocketMQ DLedger等存储系统通过集成 Raft 共识算法,将自身从传统的“主从复制+手动切换”架构升级为“自动故障转移”的高可靠、高可用架构。它牺牲了部分写入性能(需要等待多数节点写入成功)换取了数据的可靠性和服务的自动恢复能力。理解 Raft 日志复制原理、选举过程,对于架构选型、部署、运维和故障排查都很重要。
五、数据分片
前面反复提到存储系统的几种扩展架构:主从复制、读写分离、故障自动转移、数据分片,前三个我们已经讲完了,这节课我们讲第四个:数据分片(Sharding)。
系统刚上线时,一台 MySQL 服务器,配个 Redis 单实例,再加个 RocketMQ 单节点,可能轻轻松松就跑起来了。用户量小、数据少,单机撑得住,再不行顶多就再加个主从复制、读写分离,把读的压力分担给从库,主库专心写,对于大多数读多写少的场景,基本上也就够用了。
但是,业务一旦跑起来,性能压力随之而来,用户表过了亿,缓存飙到几百 GB,消息队列每天堆积上亿条消息。这时候你会发现,单台机器再强,也有它的物理极限:CPU 会满、内存会爆、磁盘 IO 会堵死。靠升级硬件(垂直扩展)不仅成本飙升,效果也越来越差。
这种情况下,“水平扩展”就成了必然选择。无状态的计算服务水平扩展相对容易,但对存储系统来说,则要复杂得多。 存储系统水平扩展的核心技术就是数据分片(Sharding),即把数据拆开,分散放到多台普通的、相对廉价的服务器上。
1. 分片策略
数据分片首先需要制定分配策略。所谓分片策略就是按照什么规则将数据分到不同的Shard(Shard可以是不同的机器,也可以是同一机器的不同实例)中。常见的策略有这样几种:
(1)按范围分片
这个比较直观,比如你有一个用户表,可以按用户 ID 的范围来分:ID 在 1-1000 万的放到 Shard1,1000 万零1到2000万的放到 Shard2,以此类推。按时间分片也常用,比如把上个月的数据放一个分片,这个月的放另一个。好处是简单明了,特别是范围查询效率高(比如查某个时间段的数据,可能只涉及少数分片)。但最大的风险就是数据分布不均和热点问题。如果新用户注册量激增,ID 集中在最新那个范围段,那么承载最新分片的服务器压力就会特别大,成为热点,而老的分片可能很空闲。
(2)按哈希分片
为了解决分布不均的问题,于是就有了哈希分片策略。选定分片键(比如用户ID)之后,通过一个哈希函数(比如 hash(user_id) % N,N 是分片总数),计算出这个数据应该落在哪个分片上。这种方式最大的优点就是数据分布相对均匀,只要哈希函数选得好,不同分片键算出来的结果比较随机,不太容易出现某个分片数据量或访问量特别集中的情况。缺点就是范围查询变得困难,比如你想查 ID 在某个区间的用户,因为 ID 被哈希打散了,可能每个分片上都有,你就得去查所有的分片,然后再把结果合并,效率肯定比不上按范围分片。另外,一旦确定了分片总数 N,后续想增加分片(扩容)就比较麻烦,因为 N变了随之hash(key) % N 的结果变了,需要大规模迁移数据。
(3)按业务分片
这种分配策略使用分片映射表,记录着每个分片键(或者分片键的某个范围 / 哈希值)对应到哪个具体的Shard。应用在读写数据前,先查一下这个映射表:“哦,用户ID=123456 的数据在 Shard5 上”,然后再去操作 Shard5。这种方式非常灵活,分片规则可以很复杂(比如结合业务属性),扩容时调整映射关系相对容易(修改映射表就行)。但问题也很明显,这个分片映射表本身成了单点和性能瓶颈。它必须非常可靠(不能挂),而且每次操作都要查它,在高并发下压力巨大。
2. 技术挑战
选定了分片策略,数据分散存储了,压力也分摊了,但这仅仅是开始,分片带来的复杂性,才是我们真正要面对的技术挑战。
(1)跨分片查询
上面提到了,像范围分片还好,哈希分片做范围查询就得查所有分片再聚合,这非常耗时耗资源。对于需要排序、分页、聚合统计(SUM, COUNT, AVG)的操作,难度和开销都成倍增加。很多时候,我们需要在应用层做大量额外的合并工作。那么,这个问题的解决方案一般是适当冗余数据,建立宽表(把原本需要通过 JOIN 关联查询才能获取到的、业务上经常一起访问的、分散在多张表里的字段,预先合并(冗余)到一张表里),或使用离线查询引擎(比如ElasticSearch)来应对这些复杂查询需求。
(2)跨分片事务
在单库上轻易实现的事务,到了分片环境就变得极其复杂。一个事务可能涉及更新多个分片上的数据。要保证所有分片要么都成功,要么都失败(原子性),需要引入像两阶段提交(2PC)这样的协议。而 2PC 性能差、容易阻塞,还可能存在协调者单点故障问题。针对性的解决方案一般是尽量避免跨分片事务,或采用最终一致性方案。分布式事务问题在后面会有详细的讲解。
(3)分片键选择
分片键选得不好,前面说的热点问题、查询效率低、事务复杂等问题会放大。理想的分片键应该能让数据均匀分布(避免热点),同时又能满足核心业务的高频查询需求(比如经常按用户ID查,那用户ID做分片键,点查就很快)。
(4)数据库扩容
业务在增长,数据在增加,最初的分片数量有可能就不够了,需要加机器、加分片。在哈希分片下,增加分片意味着哈希取模的分母 N 变了,大部分数据的归属分片都会变,这就需要进行大规模的数据迁移。这个过程要保证线上服务不停机、数据不丢失,是个技术活。这个问题的一般解决方案是:提前分配足够的分片或者使用一致性哈希算法,可以避免或减少扩容带来的大量数据迁移。
(5)全局唯一ID
在单库里,用自增主键很简单。分片后,多个分片都在生成ID,如何保证全局唯一且趋势递增(或者至少不冲突)?这需要专门的分布式 ID 生成方案(雪花算法 Snowflake、Leaf 等)。
(6)运维复杂度
单机或主从架构,需要管理的机器是有限的,当数据分片之后,我们可能需要管理几十、甚至上百个分片,每个分片可能还有自己的副本。监控、备份、恢复、升级、故障排查的难度和成本都大大增加。
3. MySQL
单机 MySQL 扛不住时,读写分离(主从复制)是第一道防线。但这只解决了“读”的压力,“写”的瓶颈和单机存储上限还在。真到了数据量千万级、亿级,或者并发写入高得吓人的时候,就得做水平分片了,即把一张大表的行,按某种规则分散存到多个数据库实例里。
MySQL 本身没自带分布式基因,并且支持JOIN等复杂的SQL查询,相对于天然支持分片的Redis(Redis Cluster)和RocketMQ(RocketMQ Queue),分片带来的技术挑战更大,因此,我们MySQL的数据分片放到单独的下一篇文章中详细讲解。
4. Redis
Redis 作为内存数据库,单实例容量和性能瓶颈来得更快更猛。按照时间线,它的分片之路经历了从“手动挡”到“自动挡”的升级。
早期是客户端分片(手动挡)。开发者得自己在客户端编写代码逻辑,比如用个哈希函数算 key 的哈希值,再对 Redis 节点数取模 (hash(key) % node_count),决定这个 key 该存到哪台机器。这样做简单直接,但缺点是增减节点时,取模的分母一变,大部分 key 映射关系全变,缓存雪崩风险高,数据迁移工作量巨大。当然,我们也可以按 key 的范围分片,但极易导致数据倾斜(某些节点数据特别多)。
Redis 3.0 推出了 Redis Cluster,它是Redis 官方内置的分布式解决方案,相对于早期客户端手动挡分片,这算是自动挡分片。
Redis Cluster使用基于哈希槽(Hash Slot)的分片机制。它预分配 16384 个哈希槽,每个 key 进来,先用 CRC16 算法算个值,再对 16384 取模,确定它落在哪个槽里,然后再根据「槽位映射表」(槽跟节点的对应关系)定位节点。
每个节点都会有一份全局的槽位映射表。 客户端连上集群任一节点后,如果要操作的 key 就在该节点负责的槽里,直接处理。如果不在,节点会返回一个 MOVED 错误,并根据槽位映射表告诉客户端正确的节点地址,客户端自动重定向过去。当然,客户端也会缓存槽位映射表,以提高操作效率。
# 获取槽位映射表
127.0.0.1:6379> CLUSTER SLOTS
1) 1) (integer) 0 # 槽位起始编号
2) (integer) 5460 # 槽位结束编号
3) 1) "192.168.1.101" # 节点 IP
2) (integer) 6379 # 节点端口
3) "a1b2c3..." # 节点 ID
2) 1) (integer) 5461
2) (integer) 10922
3) 1) "192.168.1.102"
2) (integer) 6379
3) "d4e5f6..."
3) 1) (integer) 10923
2) (integer) 16383
3) 1) "192.168.1.103"
2) (integer) 6379
3) "g7h8i9..."Hash Slot 的精妙之处在于解耦: key 只认槽,槽再绑定节点。当需要扩容(加节点)时,只需要在集群内移动一部分槽到新节点即可。这比客户端取模方案的数据迁移量小得多。同时,Redis Cluster 中每个分片都集成了主从复制和故障自动转移,Master 挂了,Slave 能顶上,保证了高可用。
# 添加新的节点
# redis-cli --cluster add-node <新节点IP:端口> <现有集群任意节点IP:端口>
redis-cli --cluster add-node 192.168.1.104:6379 192.168.1.101:6379
# 工具自动从所有现有节点抽取部分槽位给新节点
# 默认迁移量≈16384/(N+1)(如3节点→4节点,迁移 4096 槽)
# redis-cli --cluster rebalance <现有集群任意节点IP:端口>
redis-cli --cluster rebalance 192.168.1.101:63795. RocketMQ
消息队列要应对海量消息堆积和高并发写入,同样离不开分片。在 RocketMQ 和 Kafka 这类分布式消息系统中,“分片”的概念通常被称为 分区(Partition) 或 队列(Queue)。我们拿 RocketMQ 举例讲解。
在 RocketMQ 里,分片的单位就是队列(Queue)。一个 Topic(代表一类消息)的消息,被分片存储到多个不同的队列中。
Producer 发消息时,默认按照轮询策略(Round-Robin)依次将消息发到该 Topic 下的队列,尽可能均匀分布。当然,你也可以在Producer Client中自定义MessageQueueSelector,比如根据消息的某个业务属性(如订单ID)哈希取模,让同一订单的消息严格按顺序进入同一个队列。
我们在创建 Topic 时,指定它在哪些 Broker上创建队列,以及每个 Broker 上创建几个队列。这些队列就是该 Topic 的分片,分布在不同的 Broker 上以分摊写入和存储压力。
# 创建 Topic-A,精确分配 Broker 队列数
./mqadmin updateTopic -n <NameServer地址> \
-t Topic-A \
-c DefaultCluster \ # 集群名(需与 Broker 所属集群一致)
-b "Broker-1:3;Broker-2:2" \ # 指定 Broker 名称及队列数
-w 5 \ # 写队列总数 = 3+2=5(必须匹配)
-r 5 # 读队列总数 = 5(必须匹配)
# 队列分配结果:
Broker-1:3 个队列(ID 0,1,2)
Broker-2:2 个队列(ID 3,4)RocketMQ 分片的扩容主要是手动操作。新增 Broker 节点后,Topic 可以在新 Broker 上分配新的队列,当然,也可以通过 mqadmin updateTopic 命令手动调整,将已有Broker上部分队列迁移到新 Broker 上。这比 Redis Cluster 的自动槽迁移稍显笨重,但也提供了更多控制权。
6. 最后总结
数据分片是实现数据存储水平扩展的核心技术。当单机数据库扛不住海量数据和访问压力时,分片让我们能够通过增加普通机器来线性扩展存储能力和处理能力。分片的核心工作在于如何合理(按范围、哈希、目录等)地切割数据并分配到不同节点,以及应对分片之后的技术挑战:跨片查询、分布式事务、扩容迁移、运维成本等。
六、分库分表
上一节课我们讲了数据分片,并且重点讲解了Redis和RocketMQ的分片架构,因为它们自带分布式的基因,内置了分片的功能,并且读写操作都比较简单,所以,相对来说,分片并不是太难!恰恰相反,MySQL原生不支持分片,并且需要JOIN、聚合(SUM、AVG)、分页等复杂的查询操作,因此,分片面临更多技术挑战。这节课我们就重点讲下MySQL的数据分片,也就是我们常说的分库分表!
1. 为什么要分库分表
分库分表的核心目标就是解决单一数据库实例在数据量、并发访问、读写性能上的瓶颈。
假设你运营着一个电商平台,初期业务量不大,一台 MySQL 服务器就撑起了所有订单、用户、商品数据。那时候,所有查询都飞快,写操作也毫无压力。可是随着业务增长,你发现几个问题开始浮现:
- 数据量太大:订单表已经积累了上亿行记录,一张表的文件大小超过几百 GB。即使建了索引,SELECT * FROM orders WHERE user_id = ? 这样的查询也要几十毫秒甚至几百毫秒,用户体验明显下降。
- 写入压力太高:大促期间,每秒有几千个订单创建,单台 MySQL 的 写入都成了瓶颈。
- 索引维护成本高:一张大表上建了五六个索引,每次插入都要更新所有索引,写性能被严重拖累。而且索引文件本身也巨大,内存根本放不下。
- 备份恢复极慢:每天全量备份需要几个小时,恢复一次数据更是噩梦。
分库分表的核心目标,就是通过水平拆分的方式,把一个巨大的数据库(库)拆成多个小库,把一张巨大的表拆成多张小表,让每个分片只承担原来的一部分数据和请求,从而突破单机的 IO、CPU、内存、连接数等限制。
2. 分库分表工作原理
分库分表的核心思想是“分而治之”。假设我们有一个订单表 orders,数据量巨大。我们决定将它拆成 4 个表,分别放在 2 个数据库实例上。那么,当应用程序需要写入一条订单时,它需要根据某个规则(比如订单号或用户 ID)决定这条数据应该去哪个库的哪个表。这个过程叫做“路由”。
下图展示了分库分表的基本架构:

工作流程拆解:
- SQL 解析:中间件收到 SQL 后,解析出分表键的值。例如 SELECT * FROM orders WHERE user_id = 123,解析得到分表键 user_id=123。
- 路由计算:根据预先配置的路由算法(如 hash(user_id) % 4),计算出目标分片编号,比如结果为 2。然后映射到具体的数据库和表名,比如 ds0.orders_2。
- SQL 改写:将原 SQL 中的逻辑表名 orders 替换为真实的物理表名 orders_2
- 执行:将改写后的 SQL 发送到对应的数据库实例上执行。
- 返回结果:数据库执行后将结果集返回给中间件,中间件直接返回给应用程序,无需额外处理。
以上讲解的是 SQL 中包含分表键的情况,可以精确路由到单一分片,效率最高。但如果 SQL 中不包含分表键,比如订单表按 user_id 分片,但查询条件是 order_no。
SELECT * FROM orders WHERE order_no = 'ORD123456';中间件无法知道 order_no='ORD123456' 这条数据属于哪个分片(因为分片键是 user_id,而 SQL 里没有 user_id),于是只能将这条 SQL 发送到所有分片去执行。这种 SQL 就叫做广播 SQL。
广播 SQL 的处理流程如下:
- SQL 解析:中间件发现 SQL 中不包含分表键 user_id,只有 order_no。
- 路由计算:因为无法确定目标分片,中间件将逻辑表 orders 映射到所有物理分片(例如 ds0.orders_0、ds0.orders_1、ds1.orders_0、ds1.orders_1)。
- SQL 改写:为每个物理表生成对应的 SQL(表名替换为 orders_0、orders_1 等)。
- 并行执行:中间件向所有分片并行发送改写后的 SQL。
- 结果归并:收集所有分片返回的结果集,然后根据业务语义进行归并:如果查询期望单条记录,则从多个结果中取出第一条非空记录即可。如果查询期望多条记录(如 ORDER BY、LIMIT、COUNT),则需要进行更复杂的归并:排序、聚合、分页等。例如 SELECT * FROM orders WHERE create_time > '2024-01-01' ORDER BY create_time LIMIT 10,需要每个分片先取出满足条件的记录,中间件再全局排序,最后截取前 10 条。
广播 SQL 的性能代价:分片数越多,广播的开销越大。假设有 16 个分片,一次广播 SQL 相当于执行了 16 次查询,且中间件需要消耗内存和 CPU 来归并结果。如何避免广播SQL?
3. 分表键选择和路由
避免广播SQL,其中最重要的一点是:选择合适的分片键。
(1)选择分表键的基本原则
选分表键,本质上是回答一个问题:“业务上最常用的查询条件是什么?”
例如,在订单表里,最常见的查询是:
- 用户查询“我的订单”:SELECT * FROM orders WHERE user_id = ?
- 运营后台按订单号查询:SELECT * FROM orders WHERE order_no = ?
- 商家查询自己店铺的订单:SELECT * FROM orders WHERE merchant_id = ?
如果你的系统里 80% 的查询都带 user_id,那么用 user_id 作为分表键就是合理的。因为查询时,应用程序可以根据 user_id 的值直接计算出目标分片,无需查询所有分片再合并数据。
(2)具体如何选择分表键
第一步:列出所有查询模式,统计频率和重要性
把系统里所有对这张表的 SQL 列出来,按调用量排序。那些高频、延迟敏感的操作应该优先考虑。
第二步:选择覆盖率最高的列作为候选分表键
理想情况下,这个列的值是均匀分布的(比如用户 ID、订单号),不会产生热点。避免选择性别、月份等低基数列,否则数据会严重倾斜。
第三步:对无法覆盖的查询设计辅助方案
比如选择了 user_id 作为分表键,那么根据 order_no 的查询就无法直接路由。这时可以采用基因法(在订单号中嵌入分片信息),或者建立一张“索引表”来映射 order_no -> user_id,或者将数据冗余一份,还有使用 ES 供搜索。这个我们待会详细来讲。
分表键的选择,体现了你对业务查询模式的深度理解。选对了,事半功倍;选错了,后患无穷。
(3)常用的分片路由算法
选定分表键后,需要一个路由函数将键值映射到具体的分片。常用的路由算法有三种:
- 哈希取模:shard_id = hash(sharding_key) % N。简单、数据分布均匀,但扩容时需要大量数据迁移。
- 范围划分:比如按 user_id 的范围,0 ~ 1000 万分片1,1000万 ~ 2000万分片2。优点是扩容简单(加新范围即可),缺点是容易产生热点(新用户集中在最新分片)。
- 一致性哈希:将键值映射到环上,分摊到物理节点。扩容时只需要迁移少量数据,但实现复杂,且不能很好支持范围查询。
绝大多数业务场景下,哈希取模是最简单可靠的选择。为了应对未来扩容,可以预留两倍的分片数,采用“双倍扩容”法,以减少数据迁移量。待会会详细介绍。
4. 如何避免广播SQL
即便选择了合适的分片键,大多数情况下,也无法完全避免广播SQL,因为并不是所有的查询都是走分片键的。因此,针对非分片键的查询,如何避免广播SQL,我们有几种常用的方案。
(1)基因法
我们举个例子。订单表是典型的写多读多、数据量巨大的表。大部分查询都是“查询我的订单”。因此,我们选择用户 ID 作为分表键进行分库分表,可以避免单个用户的所有订单散落到不同分片。但是,如果客服或运营想根据 order_no 查询订单详情时,就不知道这个订单属于哪个分片,只能广播到所有分片去查,效率极低。
--直接计算 123 % 16 = 11,去第 11 分片查询,非常快。
SELECT * FROM orders WHERE user_id = 123;
-- 不知道该去哪个分片,只能并发查 16 个分片,再合并结果。
SELECT * FROM orders WHERE order_no = 'ORD20260409001';那么,有没有一种方法,既能按用户分片,又能根据订单号直接定位到分片?答案是有的,这就是“基因法”。在生成订单号时,把 user_id 的分片结果(比如 user_id % 16 的值,0~15)作为一个固定长度的“基因片段”,嵌入到订单号中。这样,当收到一个订单号时,我们可以先从中提取出基因,直接得到出目标分片,无需广播。
当然,基因法也有局限性,就是分表扩容时,会导致基因法失效。假设扩容前分片数 N=16,基因 = user_id % 16。扩容后分片数 M=32,新路由规则变为 user_id % 32。旧订单号中的基因(015)无法直接映射到新的分片编号(031),因为同一个 user_id 在旧规则下可能落在分片0,在新规则下可能落在分片16。如果你直接从订单号提取基因0,然后去分片0查询,可能会漏掉实际在新分片16的数据。
因此,基因法更适合分片数长期稳定或预先规划大分片基数的场景。如果业务增长迅猛、需要频繁扩容,建议配合索引表或ES等其他方案。
(2)索引法(映射表)
建立一张小而独立的“索引表”,存储非分片键到分片键的映射关系。查询时先查索引表得到分片键,再用分片键去目标分片查询。
比如,订单表按 user_id 分片,我们需要支持按 order_no 查询。创建一张索引表 order_no_to_user_id,只有两列:order_no(主键)和 user_id。这张表数据量小,查询简单,可以不分片单库存储,也可以直接放到Redis中。
索引法的优点是,不侵入订单号生成逻辑,并且数据库扩容时不受影响。缺点是,多一次网络开销(两步查询);写入订单时需要同时或者异步(利用消息中间件)写入索引表,需要保证最终一致。
(3)数据冗余(双写)
将同一份数据按照不同的分片键分别存储多份。例如,订单表按 user_id 作为分片键存储一份,再按照 order_no 作为分片键存储一份。应用写入时,同时写入两张表(或通过消息队列异步写入)。查询时根据查询条件选择对应的分片表。
它的优点很明显,每种查询都能单分片命中,性能最佳。缺点也很明显,存储成本翻倍。
(4)搜索引擎(ES)
如果查询条件极其复杂,比如运营后台需要同时按时间范围、金额区间、商品类别、订单状态等多个维度组合搜索,还支持模糊匹配和排序分页——这种场景下,无论是基因法、索引表还是数据冗余都难以应对。因为组合条件太多,无法提前预知所有查询模式,也不可能为每一种组合都建一套分片索引。
这时候,最成熟的方案是把数据同步到 Elasticsearch(ES)中。ES 天生为全文检索和多维分析而生,支持海量数据的近实时查询,并且本身是分布式架构,可以水平扩展。
这种方案的优点是:彻底解放 MySQL,让分库分表的设计只需关心高频、简单、点查场景(如按订单号、用户ID查详情),可以做到强一致、毫秒级响应的要求;ES 负责分析类查询,只要允许秒级延迟,ES 方案在大中型系统中几乎是标配。
(5)业务规避
有些时候,与其费尽心思用技术解决广播 SQL,不如从业务层面绕过去。下面举几个常见例子:
- 强制带分片键:例如用户查询订单详情时,前端 URL 中不仅要有订单号,还要带上用户 ID(可从会话中获取)。这样后台接口就能拿到 user_id,直接路由。对于运营后台,如果无法获取用户 ID,可以限制每次查询必须先选择一个用户(通过用户名搜索),得到用户 ID 后再查订单。
- 深度分页问题:分库分表后,LIMIT 100000, 10 这种大偏移量分页几乎不可用。业务上可以禁止直接跳转到后面的页,只允许“上一页/下一页”,或者使用游标分页(WHERE id > last_id LIMIT 10)。
- JOIN 操作问题:分库分表后,尽量避免跨分片 JOIN。业务上可以通过多次查询 + 应用层组装来实现。例如先查出订单列表,再根据订单中的商品 ID 列表,批量去商品服务查询商品详情。虽然增加了代码复杂度,但性能可控。或者,在业务允许的情况下,适当冗余数据,建立宽表。所谓宽表就是把原本需要通过 JOIN 关联查询才能获取到的、业务上经常一起访问的、分散在多张表里的字段,预先合并(冗余)到一张表里,来避免跨分片JOIN。
- 聚合操作(SUM、AVG、COUNT):实时聚合尽量预计算结果,例如每天的成交总额可以在每次支付成功后原子累加。离线聚合则交给数仓(Hive、Spark)或 ES,不要在在线查询中做跨分片聚合。
总结一句话:技术做不到的,就通过产品规则或业务流程来规避。这并非妥协,而是架构设计的成熟表现。
5. 分表的容量预估和扩容
在 MySQL 中,并没有一个绝对的“单表超过多少行就必须分表”的硬性规定。是否分表,取决于业务访问模式、硬件性能、可维护性等多方面因素。不过,业界有一些公认的经验阈值和判断标准,可以帮助你做出决策,比如单表数据量超过千万、单表文件超过50GB、索引大小超过内存(Buffer Pool)等。不过,这些阈值不是硬性指标,而是经验参考。很多业务中,单表几千万甚至上亿行,只要查询模式合理、索引设计得当,依然可以跑得很好。
前面提到,分表导致的数据迁移会比较麻烦,因此,如果一个表的行数在未来3年(或者更长,比如5年,看自己项目的需求)内有可能达到分表的需求,就可以考虑提前分表。具体每张表最大允许存储多少行的数据,这个最终还要具体看在当前业务数据操作的情况下的性能表现。
怎么预估未来N年的某个业务表的数据总量呢? 我们给出了一个计算公式。
未来N年总数据量 = 当前行数 + (日均增量 × 365 × N)根据预估的未来N年的总数据量,通过以下公式计算分表数,单表容量上限:建议控制在1000万行内或大小50GB以内。
分表数 = 未来N年总数据量 / 单表容量上限我们举个例子:
- 当前订单表:1000万行
- 日均增量:5万行
- 5年总数据量 ≈ 1000万 + 5万 × 365 × 5 ≈ 1亿行
- 按单表1000万行上限:分表数 = 1亿 / 1000万 = 10张表
一般来说,我们会选择大于分表需求值的最小2的N次方值为最终的分表数,因此,大于10的最小2的N次方是16,最终,订单表分16张表。为什么要这么做呢?因为这样做路由计算效率最高。
基于哈希的路由算法,有取模操作,而CPU执行取模指令需要 20-40个时钟周期,远高于加法运算和位运算。
// Java取模运算
int tableIndex = userId.hashCode() % tableCount;如果表数tableCount是2的N次方,那么,我们就可以把取模操作转换为位运算,如下所示,仅需1个时钟周期,性能提升 20倍以上。其实,这个计算技巧在《Java编程之美》中讲HashMap的哈希算法的时候已经提到过。
// 位运算替代取模 (tableCount=16)
int tableIndex = userId.hashCode() & (tableCount-1);当数据量增加,确实需要扩容,增加更多的表时,我们一般会将表数量扩容为原来的两倍。为什么这么做?其实在《Java编程之美》的HashMap动态扩容中也讲到了。
这么做的好处是:最小化迁移数据量。具体来讲,tableCount增加之后,只需要迁移一半的数据。我们举个例子,假设原分表数 N=8(二进制 1000),扩容后 M=16(二进制 10000)
原分片号 = hash(user_id) & 0x00000007 // 取低3位 (0111)
新分片号 = hash(user_id) & 0x0000000F // 取低4位 (1111)如果原数据从右往左数的第4位是0,则保持在原表不动,如果是1,则迁移到新表中。而原数据中第4位是0的,跟第4位是1的,按照概率应该是各占一半。因此,只有一半的数据需要迁移。
当然,为了避免数据大量迁移,我们还可以使用一致性哈希算法,或者在分表初期直接创建1024张表,一次性给够,这样就不会有后续的扩容需求了。当然,这也会增加表管理的成本。
6. 最后总结
对于MySQL来说,单机数据库在数据量、读写性能和并发连接方面存在天然的不足,面对互联网海量场景极易触及性能极限。此时,我们应优先采用读写分离、Redis缓存等更轻量级的优化方案,而分库分表应该是作为应对海量数据的最终手段。毕竟,分库分表不是免费的午餐。它会带来一系列复杂问题:跨分片查询、分布式事务、分布式ID(全局唯一ID)、分片扩容等。其中,本节课讲到了跨分配查询和分片扩容的解决方案,分布式事务、分布式ID我们留在后面的课程中讲解。
