2021 年我做一个订单系统。orders 表一开始好好的,几百万行,查询飞快。业务涨了两年多,orders 涨到了三亿多行,单表彻底慢了下来——加索引也救不动了,深分页(翻到很后面)一次查询要几十秒,大促那几天直接把数据库 CPU 打满、整个下单链路被拖垮。我决定分库分表,把 orders 这一张大表,水平拆成 16 张小表。第一版我做得很直接:按订单的自增主键 id 取模——id % 16,把数据分到 orders_0 到 orders_15。本地一测——很顺:三亿行数据均匀地散进 16 张表,每张表两千万行,按 id 查单个订单飞快,大促压测也扛住了。我心里很踏实:"分库分表嘛,不就是把一张大表拆均匀。"可上线之后,问题一个接一个冒出来。第一个,也是最致命的:用户打开"我的订单"页面——这是整个系统最高频的查询——要查"这个 user_id 的全部订单"。可订单是按 id 分的,一个用户的几十笔订单,被 id%16 均匀地打散在了 16 张表里。我根本不知道它们在哪几张表,只能把 16 张表全查一遍、再在内存里合并。我亲手把系统里最高频的查询,做成了一个 16 倍的全表扫描。第二个:自增 id 没了——16 张表各自维护自增主键会冲突,我必须找一个新办法生成全局唯一的订单号。第三个:跨表的分页、排序、count 全乱套了。第四个:半年后数据又涨,要从 16 张表扩到 32 张,id%16 变成 id%32,几乎每一行数据的归属表都变了,要做一次惊心动魄的全量迁移。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"分库分表,就是把一张大表拆成几张结构一样的小表,拆得均匀就大功告成"。可这个想法,漏掉了分库分表里最核心的一个决策——分片键(sharding key)。按哪个字段去拆,根本不是"随便选一个能拆均匀的",它直接决定了:之后哪些查询飞快、哪些查询会变成扫遍所有表的噩梦。这篇文章就把分库分表梳理一遍:为什么单表到亿级必须拆、分片键怎么选、分布式 ID 怎么生成、跨分片查询和分页怎么办、扩容怎么不搬全量数据,以及分布式事务、热点分片、不要过早分表这些把分库分表真正做对要避开的坑。
问题背景
先把那次拆表的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一张三亿行的 orders 单表慢到拖垮数据库,我按 id % 16 把它拆成 16 张表。拆完单表压力是下去了,但接连出事:最高频的"查我的订单"(按 user_id)变成了扫遍 16 张表;自增主键失效;跨表分页、排序、count 全乱;后来要扩容,几乎全部数据都得搬。
我当时的错误认知:"分库分表就是把一张大表拆成几张结构一样的小表,只要拆得均匀就大功告成了。"
真相:分库分表的核心决策不是"拆均匀",而是选对分片键。按哪个字段拆,决定了哪些查询一次命中一张表、哪些查询退化成全分片扫描。分片键要按业务最高频的查询来选;非分片键查询要靠基因法或全局索引救;主键要换成分布式 ID;扩容要靠预分片避免搬全量数据。
要把分库分表做对,需要几块认知:
- 为什么单表到亿级必须拆,以及第一版拆错在哪——分片键选错了;
- 分片键怎么选——它决定哪些查询飞快、哪些变噩梦;
- 分布式 ID——自增主键失效后,怎么生成全局唯一订单号;
- 跨分片查询——非分片键查询、分页、聚合怎么办;
- 扩容、分布式事务、热点分片这些工程坑怎么处理。
一、为什么单表到亿级必须拆,以及第一版拆错在哪
先把这件最根本的事钉死:单表到了亿级行,索引也救不动它;但拆表的关键决策不是"拆得均不均匀",而是"按哪个字段拆"——这个字段叫分片键,它一旦选错,系统里最高频的查询就会从"一次命中一张表"退化成"扫遍所有表"。
下面这段代码,就是我那个"把最高频查询做成全表扫描"的第一版——它按 id 取模分表:
def shard_table_naive(order_id: int) -> str:
# 反面教材:按订单的自增 id 取模,把 orders 拆成 16 张表。
# 数据确实拆均匀了,但分片键选的是 id 而不是 user_id。
return f"orders_{order_id % 16}"
按 id 分,数据是拆均匀了——三亿行平摊进 16 张表,每张两千万行,看起来无可挑剔。可问题立刻就在最高频的查询上爆发了。"我的订单"页面要查"某个 user_id 的全部订单",而一个用户的订单 id 各不相同、被 id%16 均匀打散到了 16 张表里——你根本无法从 user_id 推算出它们在哪几张表:
def query_my_orders_naive(user_id: int) -> list:
# 灾难:订单按 id 分散在 16 张表,要查"某用户的全部订单",
# 根本不知道它们在哪几张表 —— 只能 16 张表【全部扫一遍】。
results = []
for i in range(16):
rows = db.query(
f"SELECT * FROM orders_{i} WHERE user_id = %s", (user_id,))
results.extend(rows)
return results
# 系统里最高频的查询,被我亲手做成了 16 张表的全表扫描 ——
# 单表的瓶颈是解决了,却制造了一个更难缠的新瓶颈。
这两段代码没有任何语法错误,拆出来的数据也确实均匀。它们的问题不在代码本身,而在一个错误的决策标准:我选分片键时,唯一的考量是"哪个字段能把数据拆均匀"。id 是自增的,拿它取模当然均匀——可"均匀"只解决了"每张表多大",它完全没回答另一个更要命的问题:"查询进来时,我能不能算出它该去哪张表"。分库分表之后,一次查询只有两种命运:要么你能从查询条件直接算出分片,一次命中一张表(这叫分片键查询);要么你算不出来,只能把查询广播到所有表、再合并结果(这叫全分片扫描)。这两种命运的性能差距是数量级的。而决定一个查询走哪条路的,正是分片键——只有分片键本身参与了查询条件,才能算出分片。我按 id 分,所以"按 id 查"很快;可业务里压倒性最高频的查询是"按 user_id 查",这个查询里没有 id,于是每一次都退化成全分片扫描。问题的根子清楚了:分片键不能按"哪个字段拆得均匀"来选,要按"哪个字段是最高频查询的条件"来选。
二、分片键:它决定哪些查询飞快、哪些变噩梦
选分片键的正确标准,是先问自己一个问题:这个表上,压倒性最高频的查询,是按哪个字段查的? 对订单系统,答案毫无疑问是 user_id("我的订单")。所以分片键就该选 user_id——让同一个用户的所有订单,永远落在同一张表。
SHARD_COUNT = 16
def route_by_user(user_id: int) -> str:
"""按 user_id 分片:同一个用户的订单,永远落在同一张表。"""
# 关键:分片键选 user_id,而不是 order_id ——
# 因为业务里压倒性最高频的查询是"查我的所有订单"。
return f"orders_{user_id % SHARD_COUNT}"
分片键这么一换,那个曾经要扫遍 16 张表的"我的订单"查询,就彻底变了——因为 user_id 本身就在查询条件里,我能一步算出它的订单全在哪一张表,然后只打那一张:
def query_my_orders(user_id: int) -> list:
"""查某用户的全部订单:一次路由,只打一张表。"""
table = route_by_user(user_id) # 一步算出在哪张表
return db.query(
f"SELECT * FROM {table} WHERE user_id = %s", (user_id,))
# 和第一版的 16 张表全扫相比,这里只查 1 张 ——
# 同一个查询,性能差了整整一个数量级。
这就是分片键的全部分量:它不是一个"怎么拆都行"的技术细节,它是分库分表里第一位、也最难改的决策。选对了,最高频的查询一次命中;选错了,最高频的查询次次扫全表。而且要清醒:分片键一旦选定、数据按它拆完,再想改就意味着整个库的数据重新洗牌,代价极高。所以它必须在动手拆之前就想透。但选了 user_id 做分片键,新问题立刻来了:订单的主键怎么办?16 张表各自用自增 id,必然撞号。
三、分布式 ID:自增主键失效后,怎么生成全局唯一订单号
单表时代,主键靠数据库的 AUTO_INCREMENT 自动生成,天然唯一。可一旦拆成 16 张表,每张表各自从 1 开始自增,就会生成出一大堆重复的 id。你需要一个不依赖单库、能全局生成唯一 ID 的办法。业界最经典的方案是 雪花算法(Snowflake):用一个 64 位整数,由时间戳 + 机器号 + 毫秒内序列号三段拼成。
import time
import threading
class SnowflakeIdGenerator:
"""雪花算法:64 位全局唯一 ID = 时间戳 + 机器号 + 毫秒内序列号。"""
EPOCH = 1609459200000 # 起始基准时间戳(2021-01-01)
def __init__(self, machine_id: int):
self.machine_id = machine_id & 0x3FF # 机器号占 10 位
self.seq = 0
self.last_ts = -1
self.lock = threading.Lock()
def next_id(self) -> int:
with self.lock:
ts = int(time.time() * 1000)
if ts == self.last_ts:
# 同一毫秒内,序列号自增(占 12 位,每毫秒最多 4096 个)
self.seq = (self.seq + 1) & 0xFFF
if self.seq == 0: # 这一毫秒序列号耗尽
while ts <= self.last_ts: # 自旋等到下一毫秒
ts = int(time.time() * 1000)
else:
self.seq = 0 # 新的毫秒,序列号归零
self.last_ts = ts
# 拼装:时间戳左移 22 位,机器号左移 12 位,低位放序列号
return (((ts - self.EPOCH) << 22)
| (self.machine_id << 12) | self.seq)
雪花算法的精妙之处有两点。一是全局唯一:时间戳保证不同毫秒生成的 ID 不同,机器号保证同一毫秒不同机器不撞,序列号保证同一机器同一毫秒内不撞——三段一拼,不依赖任何中心数据库就能唯一。二是趋势递增:高位是时间戳,所以越晚生成的 ID 越大——这对数据库索引非常友好(新数据总是追加在 B+ 树尾部,不会引起页分裂)。它也有代价:强依赖机器时钟,一旦服务器时钟回拨,就可能生成重复 ID,生产上要专门处理时钟回拨。主键的问题解决了。可还有一类查询没解决:用户拿着订单号(order_id)来查一个订单——order_id 不是分片键,这个查询又要扫全表了。
四、跨分片查询:非分片键查询、分页与聚合怎么办
分片键选了 user_id,那么所有不带 user_id 的查询,都会退化成全分片扫描。最典型的就是"按 order_id 查单个订单"。对这个问题,有一个很巧妙的解法叫基因法:既然 order_id 是我们自己生成的,那就在生成它时,把 user_id 的分片信息(基因)悄悄嵌进 order_id 的低位。
def make_order_id(user_id: int, raw_id: int) -> int:
"""基因法:把 user_id 的分片基因,嵌进 order_id 的低位。"""
gene = user_id % SHARD_COUNT # 这个用户落在哪个分片
# 关键:让 order_id 的低 4 位 == 分片基因。这样无论手里
# 拿到的是 user_id 还是 order_id,都能算出【同一个】分片号。
return (raw_id << 4) | gene
def route_by_order_id(order_id: int) -> str:
"""从 order_id 自带的基因里,直接取出分片号 —— 无需全表扫。"""
return f"orders_{order_id & (SHARD_COUNT - 1)}"
基因法的精髓,是让 order_id 和 user_id 这两个不同的键,路由到同一个分片——于是按 user_id 查和按 order_id 查,都能一次命中。但基因法只能救"能提前埋进基因"的 ID。还有一类查询怎么都躲不掉全分片扫描:比如运营后台要查"所有状态为待发货的订单,按时间倒序,翻到第 3 页"——这个查询既没有 user_id、也没有 order_id。它只能scatter-gather:把查询广播到每个分片,再把结果归并。
def query_orders_paged(status: str, page_size: int) -> list:
"""跨分片查询 + 分页:非分片键的查询,只能 scatter-gather。"""
merged = []
# scatter:把同一个查询,广播到每一个分片上
for i in range(SHARD_COUNT):
rows = db.query(
f"SELECT * FROM orders_{i} WHERE status = %s "
f"ORDER BY created_at DESC LIMIT %s",
(status, page_size))
merged.extend(rows)
# gather:在内存里把各分片的结果归并、重新排序、再截断
merged.sort(key=lambda r: r["created_at"], reverse=True)
return merged[:page_size]
# 注意:翻到第 1000 页时,每个分片都得取出前 1000*page_size 条
# 才能保证归并正确 —— 深翻页在分片后的代价高得惊人。
这里藏着分库分表最痛的一个真相:跨分片的分页,代价随页码飙升。要拿到全局的"第 N 页",每个分片都得各自取出"前 N 页"的量,才能保证归并后排序正确——翻得越深,每个分片要吐的数据越多,内存归并的开销越大。所以分片之后,能不做深翻页就别做:要么改成游标分页(用上一页最后一条的时间戳当锚点,而不是 OFFSET),要么把这类后台聚合查询,干脆引到一个专门的查询库(如 ES)上去做。分片之后的查询千头万绪,但要把分库分表真正用在生产上,还有几个绕不开的坑。
五、扩容:别用直接取模,用预分片把"搬数据"变成"挪映射"
第一版还埋了一个大雷:扩容。当 16 张表又装满,要扩到 32 张时,分片函数从 user_id % 16 变成 user_id % 32——一算就知道,绝大多数 user_id 算出的分片号都变了,意味着几乎全库的数据都要搬家。这种扩容风险极高。正确的做法是预分片:一开始就切出远多于物理库的"逻辑桶"(比如 1024 个),user_id 先映射到逻辑桶,逻辑桶再映射到物理库。
# 预分片:一开始就切成 1024 个【逻辑桶】,桶再映射到物理库。
BUCKET_COUNT = 1024
# bucket_map[逻辑桶号] = 物理库名;扩容时,只改这张映射表
bucket_map = {b: f"db_{b % 4}" for b in range(BUCKET_COUNT)}
def route_with_buckets(user_id: int) -> str:
"""两级路由:user_id 先定位逻辑桶,再由映射表定位物理库。"""
bucket = user_id % BUCKET_COUNT
# 关键:user_id 到逻辑桶的映射【永远不变】(桶数固定为 1024)。
# 扩容时只需把一部分桶改指到新库,user_id 的算法毫发无伤,
# 要搬的只是那几个桶的数据,而不是全库重算。
return bucket_map[bucket]
预分片的精髓,是在 user_id 和物理库之间,加了一层稳定不变的逻辑桶。user_id % 1024 这个算式永远不变——变的只是"哪个桶放在哪个物理库"这张小小的映射表。扩容时,你只需把一部分桶重新指向新库,然后只搬这部分桶的数据。扩容从一次"全库数据重算"的惊险手术,变成了一次"挪几个桶"的常规操作。这也是一致性哈希解决的同一类问题——核心都是让节点增减时,需要迁移的数据尽可能少。预分片要在第一天就规划好,事后很难补。下面看最后几个坑。
六、工程坑:分布式事务、热点分片与不要过早分表
四块设计之外,还有几个工程坑,不处理就会在生产上出事。坑 1:跨分片的事务,不再有数据库的 ACID 保证。单表时代,"扣库存"和"建订单"可以放在一个本地事务里,要么全成、要么全败。可一旦它们落在不同的库,数据库的事务就管不到了。不要轻易上重量级的分布式事务,优先用最终一致性方案(如可靠消息),或者——更好的办法——在设计分片键时就尽量让一个事务涉及的数据落在同一个分片,从根上避免跨分片事务。坑 2:热点分片。分片键选 user_id 通常很均匀,但如果有少数超级大客户(比如一个企业账号下挂了上千万订单),它一个人就能把所在的那个分片撑爆。所以必须监控各分片的数据量,及时发现热点:
def shard_skew_report() -> dict:
"""统计各分片的数据量,监控是否出现热点分片。"""
counts = {}
for i in range(SHARD_COUNT):
counts[i] = db.query_one(
f"SELECT COUNT(*) AS c FROM orders_{i}")["c"]
avg = sum(counts.values()) / len(counts)
# 关键:某个分片的数据量远超均值,就是热点分片 ——
# 多半是分片键不够均匀(如某个大客户的订单全压一个分片)。
hot = {i: c for i, c in counts.items() if c > avg * 2}
return {"counts": counts, "avg": round(avg), "hot_shards": hot}
坑 3:不要过早分库分表。分库分表会让系统复杂度陡增——跨分片查询、分布式 ID、分布式事务、扩容,每一样都是负担。在数据量没到(单表通常千万级、乃至数千万级才需考虑)之前,优先用更轻的手段:加索引、优化 SQL、上读写分离、归档冷数据。分库分表是"不得不"时才用的重武器,不是"看起来高级"就该上的。 坑 4:分片数量一开始就要留足。分片数(尤其是逻辑桶数)事后极难改,第一天就该按未来几年的数据量规划,宁多勿少。坑 5:分片键最好不可变。如果分片键的值会变(比如允许订单更换所属用户),那这条数据就要从一个分片删掉、再插到另一个分片,极易出错——所以分片键要选业务上稳定不变的字段。下面这张图,把一次查询如何被路由串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 单表瓶颈 | 单表到亿级行索引也救不动,深分页和大促查询会把数据库 CPU 打满 |
| 分库分表 | 把一张大表水平拆成多张结构相同的小表,分散到多个库以分摊压力 |
| 分片键 | 按哪个字段拆,决定哪些查询一次命中、哪些退化成全分片扫描 |
| 分片键选错 | 按 order_id 分却高频按 user_id 查,会把最高频查询做成全表扫描 |
| 分布式 ID | 分表后自增主键会撞号,要用雪花算法等生成不依赖单库的全局唯一 ID |
| 雪花算法 | 64 位 ID 由时间戳加机器号加毫秒内序列号拼成,全局唯一且趋势递增 |
| 基因法 | 把分片键的基因嵌进 order_id 低位,让两种 ID 都能算出同一分片 |
| scatter-gather | 非分片键查询广播到所有分片再内存归并,深翻页代价随页码飙升 |
| 预分片 | 先切大量逻辑桶再映射物理库,扩容只挪桶映射不必全库重算搬迁 |
| 热点分片 | 某分片数据量远超均值,多因大客户数据全压一个分片,需监控发现 |
避坑清单
- 单表到亿级才需考虑分表,千万级以下优先加索引优化 SQL 读写分离,别过早分表。
- 分片键不能按哪个字段拆得均匀来选,要按业务压倒性最高频查询的条件字段来选。
- 分片键选错会把最高频查询做成全分片扫描,它是第一位且最难改的决策要先想透。
- 分表后自增主键会撞号,必须换成雪花算法等不依赖单库的全局唯一分布式 ID。
- 雪花算法强依赖机器时钟,生产上必须专门处理时钟回拨,否则会生成重复 ID。
- 非分片键查询用基因法,把分片基因嵌进 order_id,让两种 ID 都能一次命中。
- 跨分片分页代价随页码飙升,别做深翻页,改用游标分页或引到专门的查询库。
- 扩容别用直接取模,要用预分片,user_id 先映射逻辑桶,扩容只挪桶映射。
- 跨分片事务没有数据库 ACID 保证,优先用最终一致性,或让一事务数据落同分片。
- 分片数和逻辑桶数事后极难改,第一天就按未来几年数据量规划,宁多勿少。
总结
回头看那次"订单表分了 16 张、查我的订单却要扫遍全部"的事故,以及我后来在分库分表上接连踩的坑,最该记住的不是某一段路由代码,而是我动手前那个想当然的判断——"分库分表,就是把一张大表拆均匀"。这句话错在它只看见了"拆"这个动作,却没看见"拆完之后,查询怎么找回数据"这个真正的难题。单表的时候,数据都在一个地方,你怎么查都行,大不了慢一点。可一旦拆成 16 张表,数据就散了——这时候,"一次查询能不能算出该去哪张表",就成了性能的生死线。而握着这条生死线的,就是分片键。分库分表这件事想清楚的,正是这个:它表面上是一个"拆"的动作,本质上却是一次"以某个字段为中心、重新组织全部数据的物理布局"的决策。你选谁做分片键,就等于宣布:今后带着这个字段来的查询,是一等公民,一次命中;不带它的查询,是二等公民,要么靠基因法补救,要么扫遍全表。
所以做分库分表,真正的工程量不在"id % 16"那一行取模上。那一行,任何教程的第一页就教完了。真正的工程量,在于你要在动手拆之前,就把所有问题想穿:你要盘清这张表上每一类查询的频率,据此选定那个能让最高频查询一次命中的分片键;你要为失效的自增主键,准备好一套分布式 ID;你要为那些注定带不上分片键的查询,想好基因法或查询库的退路;你还要为三年后的扩容,提前铺好预分片这条不必搬全量数据的路。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚分片键为什么是第一位的决策,再看它怎么让查询一次命中,接着是分布式 ID、跨分片查询、扩容,最后是事务、热点这几个把分库分表真正做扎实的工程细节。
你会发现,分库分表的思路,和现实里怎么规划一个大型仓库的货架完全相通。一个塞爆了的小仓库,你决定扩成一排大库房。一个没经验的人会想:"把货按入库流水号,一号库一件、二号库一件,平均分进去就行了"——货是分匀了,可顾客来取货时傻眼了:顾客从来不会报"流水号",他报的是"我的会员号,把我寄存的东西全给我"。而这个会员的十几件东西,被流水号均匀地撒在了每一个库房里——管理员只能抱着清单,跑遍所有库房去翻。一个有经验的仓库主管会怎么做?他先去观察:顾客来取货,99% 是按会员号取的。于是他按会员号分库——同一个会员的所有东西,永远堆在同一个库房;顾客一报会员号,管理员直奔那一个库房,一次取齐。他还会给每件货编一个新货号,并在货号里悄悄写上会员的库房编号(这是基因法);他知道有的大客户东西特别多,会盯着各库房的满载情况(这是热点监控);他甚至在第一天就把库房在图纸上划成了上千个小隔间,日后真要加库房,只需把一批隔间的牌子换个指向,而不必把货全部重搬(这是预分片)。仓库怎么分,从来不取决于"货怎么摆才整齐",而取决于"取货的人,最常按什么来找"。
最后想说,分库分表做没做对,差距永远不会在刚拆完时暴露——刚上线那几天,你测一测"按 id 查单个订单",飞快;单表的瓶颈也确实消失了,你会觉得大功告成。它只在真实的、用户日复一日地点开"我的订单"、运营天天翻后台报表、数据又涨到要扩容的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,看着系统里最高频的那个查询,每一次都在扫遍 16 张表;看着扩容变成一场要停机、要搬几亿行数据的惊险战役——你拆掉了一个瓶颈,却亲手造出了好几个更难缠的。而做对了,无论用户多少、数据多大,每一次"我的订单",都一次命中一张表,快得和单表时代没有分别;要扩容时,你从容地挪几个逻辑桶,业务几乎无感。所以别等最高频的查询慢成一片、等扩容变成一场战役,在你写下第一行"把表拆开"的代码之前就该想清楚:这张表上,最高频的查询是按什么查的?我该用哪个字段做分片键?带不上这个键的查询,我怎么救?三年后扩容,我搬不搬得动?这几个问题都有了答案,你的分库分表才不只是把一张大表"拆开"了,而是给海量数据,重新搭好了一座查得快、扩得动的房子。
—— 别看了 · 2026