RSS
菜单
全部文章快讯开发科技深度热点

第 62 章 分库分表踩坑

内容摘要

单库单表到了瓶颈,分库分表几乎是必选项,但它带来的复杂度远超"把数据切开"这么简单。本篇基于 MySQL 8.0,梳理分片键选型、跨片查询、分布式 ID、跨库分页、扩容迁移等 11 个高频坑。前置知识是第 17 篇分库分表原理。学完后你应能判断什么场景真的需要分片、如何选分片键、以及分片后哪些 SQL 会失效。

📖 本篇简介:单库单表到了瓶颈,分库分表几乎是必选项,但它带来的复杂度远超"把数据切开"这么简单。本篇基于 MySQL 8.0,梳理分片键选型、跨片查询、分布式 ID、跨库分页、扩容迁移等 11 个高频坑。前置知识是第 17 篇分库分表原理。学完后你应能判断什么场景真的需要分片、如何选分片键、以及分片后哪些 SQL 会失效。

目录

  • 坑 1:非分片键查询触发全库扫描
  • 坑 2:分片键选错导致数据倾斜热点
  • 坑 3:跨库 JOIN 无法直接执行
  • 坑 4:分片后全局唯一 ID 冲突
  • 坑 5:跨库深分页性能雪崩
  • 坑 6:跨库 COUNT DISTINCT 结果不准或极慢
  • 坑 7:分片键一旦选错无法在线变更
  • 坑 8:初期分片数拍脑袋定太少
  • 坑 9:扩容时数据迁移引发双写不一致
  • 坑 10:缺少分片维度的字典表与冗余设计
  • 坑 11:跨片事务一致性难以保证
  • 分库分表常见注意事项
  • 速查表:分片设计关键决策
  • 自测练习

坑 1:非分片键查询触发全库扫描

场景 / 错误示范:

按 user_id 把订单表分到 16 个库。运营后台要按 order_no(订单号)查单条订单,但 order_no 里不含分片键信息,中间件只能把查询广播到全部 16 个库。

-- 错误:查询条件不含分片键 user_id,中间件广播到所有分片
-- 本示例基于 MySQL 8.0
SELECT * FROM t_order WHERE order_no = 'SO202610070001';
-- 实际执行:16 个分片各跑一遍,再聚合,RT 是单分片的十几倍

为什么是坑:分片的价值在于"带分片键的请求能精准路由到一个分片"。一旦查询条件不带分片键,中间件只能全库广播(fan-out),N 个分片就 N 倍压力,并发稍高就把所有分片都打满。这不是个别慢,而是每一次非分片键查询都在做一次"全集群扫描"。

正确做法:

让高频查询条件带上分片键,或为非分片键查询单独建映射表/搜索引擎。

-- 正确 1:高频查询尽量带上分片键,精准路由
SELECT * FROM t_order WHERE user_id = 10086 AND order_no = 'SO202610070001';

-- 正确 2:order_no 单独建一张映射表(order_no -> user_id),先查映射再精准路由
CREATE TABLE t_order_no_map (
  order_no VARCHAR(32) PRIMARY KEY,
  user_id BIGINT NOT NULL,
  KEY idx_user (user_id)
) COMMENT='订单号到分片键的映射表';

加分点:设计阶段就把"核心查询列表"列出来,确保每条高频 SQL 都带分片键,是避免全库扫描的根本。


坑 2:分片键选错导致数据倾斜热点

场景 / 错误示范:

用 user_id 的取模分片,但运营活动产生的"系统账号"(user_id=0)集中了全量营销消息,所有活动消息都落在同一个分片上,该分片磁盘、CPU 都远超其他分片。

-- 错误:直接按 user_id % 16 取模,没有考虑业务上的超级账号
-- user_id=0 的系统账号产生的数据全部落在分片 0
# 错误:分片函数过于简单,无法打散热点
def shard(user_id):
    return user_id % 16   # 热点账号全部集中到同一分片

为什么是坑:分片键选择的核心是"数据均匀分布 + 访问均匀分布"。只看均匀不看热点,业务里总有几个超级账号、默认账号、运营账号,它们的数据量和访问量远超普通用户,取模后集中在一个分片,形成热点。热点分片先到瓶颈,等于分片失效。

正确做法:

分片键选高离散度字段,必要时加盐(salting)或用一致性哈希打散热点。

# 正确:在分片键后拼接随机后缀打散热点账号,或用一致性哈希
import hashlib
def shard(user_id):
    # 对超大号账号做二次打散,避免集中到同一分片
    key = f"{user_id}_{user_id % 7}"   # 附加轻量哈希打散
    return int(hashlib.md5(key.encode()).hexdigest(), 16) % 16

加分点:上线前用真实数据分布做一次"分片后各分片数据量统计",发现倾斜立即调整分片函数,比上线后再迁数据省事得多。


坑 3:跨库 JOIN 无法直接执行

场景 / 错误示范:

订单表按 user_id 分片,用户表是单库。需求要"查某个用户的订单并带出用户昵称",DBA 写了一个标准 JOIN。

-- 错误:分片表与全局表直接 JOIN,且关联键不是分片键
SELECT o.order_no, u.nickname
FROM t_order o JOIN t_user u ON o.user_id = u.id
WHERE o.user_id = 10086;
-- t_order 在分片库,t_user 在全局库,无法用一条 SQL 完成

为什么是坑:分库后,数据分散在多个物理库,跨库 JOIN 涉及不同数据库实例,MySQL 本身不支持跨实例 JOIN。中间件即使支持"绑定表"或广播 JOIN,也只覆盖很有限的场景。把单机时代的 JOIN 习惯直接搬到分片架构,大量 SQL 会跑不起来或性能极差。

正确做法:

冗余字段到业务表、用应用层组装、或把强关联小表做广播表。

-- 正确 1:把 nickname 冗余进订单表,避免 JOIN
ALTER TABLE t_order ADD COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '';

-- 正确 2:把极小且几乎不变的字典/维度表做成广播表(每个分片全量一份)
-- t_dict_region 在每个分片库都有一份,可与分片表本地 JOIN
SELECT o.order_no, r.region_name
FROM t_order o JOIN t_dict_region r ON o.region_code = r.code
WHERE o.user_id = 10086;

加分点:分片架构下"能冗余就别 JOIN"是基本原则,用空间换一致性,应用层承担组装职责。


坑 4:分片后全局唯一 ID 冲突

场景 / 错误示范:

各分片库都用 AUTO_INCREMENT,步长默认 1。分片 1 插入 id=100,分片 2 也插入 id=100,全局主键冲突,业务逻辑依赖 ID 排序也全乱。

-- 错误:每个分片各自自增,步长都是 1
CREATE TABLE t_order (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,   -- 各分片都会从 1 开始涨
  user_id BIGINT
);
-- 分片1 和分片2 都可能生成 id=1,2,3... 全局重复

为什么是坑:单机自增 ID 在单库内唯一,分片后每个库独立自增,必然撞号。全局唯一 ID 是分片的基础设施,靠数据库自增根本不够。更隐蔽的是,业务如果还依赖"ID 递增 = 时间序",撞号后排序逻辑也错。

正确做法:

用全局 ID 生成方案(雪花算法 / 号段模式),应用层生成 ID 再写入。

# 正确:雪花算法生成全局唯一且趋势递增的 ID
import time
def snowflake_id(worker_id):
    timestamp = int(time.time() * 1000)
    # 高位时间戳 + 机器位 + 序列号,保证全局唯一、趋势递增
    return (timestamp << 22) | (worker_id << 12) | (timestamp % 4096)
# 写入时把 ID 显式带上,不再依赖 AUTO_INCREMENT
-- 建表时 id 由应用指定,不自增
CREATE TABLE t_order (
  id BIGINT PRIMARY KEY,   -- 应用层雪花算法生成
  user_id BIGINT,
  ...
);

加分点:雪花算法要处理时钟回拨问题,号段模式则更简单可控(每次从号段表取一段),按业务量级选型。


坑 5:跨库深分页性能雪崩

场景 / 错误示范:

后台要翻到第 1000 页,每页 20 条,SQL 写成 LIMIT 20000, 20。中间件把它广播到 16 个分片,每个分片都要 LIMIT 20020 再取前 20。

-- 错误:跨分片深分页,每个分片都扫描并排序大量数据
SELECT * FROM t_order
WHERE user_id = 10086
ORDER BY create_time DESC
LIMIT 20000, 20;
-- 16 个分片各扫描 20020 行排序,再归并排序,RT 爆炸

为什么是坑:深分页 LIMIT offset, size 在单库就慢,跨库更严重——每个分片都要返回前 offset+size 条给中间件做全局排序,offset 越大,每个分片传输和排序的数据越多。分片越多,这种放大效应越明显。后台翻页场景尤其容易触发。

正确做法:

用"游标/seek"方式代替 offset,或限制后台最大翻页深度。

-- 正确:用上一页最后一条的 create_time + id 作为游标,避免大 offset
SELECT * FROM t_order
WHERE user_id = 10086
  AND (create_time, id) < ('2026-10-01 10:00:00', 987654)  -- 上一页末值
ORDER BY create_time DESC, id DESC
LIMIT 20;
-- 每次只扫 20 行,不再翻前面的 offset

加分点:后台导出/翻页类需求,产品上应限制最大翻页数(如只允许看前 100 页),从需求侧规避深分页。


坑 6:跨库 COUNT DISTINCT 结果不准或极慢

场景 / 错误示范:

要统计"下单用户数",SQL 写 COUNT(DISTINCT user_id),跨 16 个分片执行。

-- 错误:跨分片 COUNT(DISTINCT user_id),中间件要把所有 user_id 拉到一起去重
SELECT COUNT(DISTINCT user_id) FROM t_order;
-- 每个分片返回本分片 distinct user_id,中间件再全局去重,数据量大时内存与网络双爆

为什么是坑:分片下,聚合函数语义会变。COUNT(DISTINCT) 要求全局去重,中间件必须把各分片的 distinct 值汇总后再去重,数据量大时结果集会撑爆中间件内存。普通 COUNT(*) 也一样——各分片 count 相加看似正确,但带条件时容易算错。

正确做法:

预聚合(离线统计表)替代实时跨片聚合,或接受近似值。

-- 正确:用离线预聚合表,定时把各分片结果汇总
CREATE TABLE t_order_user_daily (
  stat_date DATE PRIMARY KEY,
  uv BIGINT   -- 每日去重下单用户数,由离线任务计算
);
-- 实时查直接读这张小表,不再跨大表 distinct
SELECT uv FROM t_order_user_daily WHERE stat_date = '2026-10-07';

加分点:实时大聚合类需求(PV/UV)本就不该在线查大表,实时大屏用 Redis 计数 + 离线校正才是常规方案。


坑 7:分片键一旦选错无法在线变更

场景 / 错误示范:

初期按 tenant_id 分片,业务发展后发现绝大多数查询按 user_id,但 tenant_id 已经写进每一行且作为路由依据,改分片键等于全量数据重新分布。

# 错误:分片键与核心查询维度不匹配,后期想改代价极大
shard_key = "tenant_id"   # 当初图省事选了租户,但高频查询按 user_id

为什么是坑:分片键决定数据落在哪个分片,一旦上线并积累海量数据,更换分片键意味着所有数据要按新规则重新分布、重新迁移,风险和成本接近"重做一次分片"。分片键是"一次性决策",选错了几乎没有无痛改的可能,必须在设计阶段就论证清楚。

正确做法:

设计前列出全部核心查询维度,选"绝大多数高频查询都带"的字段做分片键;预留改造方案。

# 正确:设计阶段先列查询清单,确认分片键覆盖绝大多数高频 SQL
query_list = [
    "按 user_id 查订单",     # 占 80%
    "按 order_no 查订单",    # 占 15%(用映射表解决)
    "按 tenant_id 统计",     # 占 5%(离线报表)
]
shard_key = "user_id"   # 覆盖最高频维度,其余走映射/离线

加分点:分片键最好同时具备"高离散度 + 高频查询必带 + 不更新"三个特征,选完就当它不可变来对待。


坑 8:初期分片数拍脑袋定太少

场景 / 错误示范:

初期数据量小,只分了 4 个库。业务爆发一年后,单库数据量逼近上限,想扩容到 16 库,结果要做大规模数据迁移,期间业务抖动严重。

# 错误:分片数只按当前数据量拍脑袋,没留扩容余量
SHARD_COUNT = 4   # 一年后单库 500G,必须扩容到 16,迁移代价巨大

为什么是坑:分片数扩容是分片架构里最贵的操作——数据要按新规则重新分布。初期定太少,后期扩容要迁移几乎全量数据;定太多,小数据量下又浪费资源、管理复杂度高。分片数本质是"现在管理成本"和"未来扩容代价"的权衡,拍脑袋很容易两边不讨好。

正确做法:

用一致性哈希或提前规划扩容路径,分片数按"3 年后预期容量"倒推,并预留虚拟节点。

# 正确:一致性哈希 + 虚拟节点,扩容时只需迁移相邻分片的一小部分
import hashlib
class HashRing:
    def __init__(self, nodes, vnodes=160):
        self.ring = {}
        for n in nodes:
            for i in range(vnodes):
                h = hashlib.md5(f"{n}#{i}".encode()).hexdigest()
                self.ring[int(h, 16)] = n
        self.sorted_keys = sorted(self.ring)
    def get_node(self, key):
        h = int(hashlib.md5(key.encode()).hexdigest(), 16)
        # 顺时针找第一个节点,扩容时只迁移落在新旧节点之间的数据
        ...

加分点:分片数建议按"当前数据量 × 3~5 年增长预期"倒推,宁可初期空着,也别事后大规模迁移。


坑 9:扩容时数据迁移引发双写不一致

场景 / 错误示范:

扩容到 16 库时,直接把老数据迁移到新分片,迁移期间业务还在写老库。迁移完发现新老分片都有数据,且部分新数据没迁移过去。

# 错误:一次性离线导出导入,迁移期间业务照常写老库,新库永远追不上
mysqldump old_db t_order > order.sql
mysql new_db < order.sql   # 导入期间老库又写入了新数据

为什么是坑:数据迁移不是"导一次就完",迁移是有时间窗口的,这期间业务还在持续写入。一次性 dump 导入必然丢掉窗口期内的新数据。在线扩容必须解决"迁移历史数据 + 同步增量变更"两件事,否则新旧数据对不上。

正确做法:

双写或增量同步(binlog)方式迁移,迁移完对账再切流。

-- 正确思路:先全量迁移,再用 binlog 订阅把增量变更回放新分片
-- 1) 全量迁移历史数据(此时业务仍写老分片)
-- 2) 订阅老分片 binlog,把迁移期间的增量变更同步到新分片
-- 3) 追平后,把写流量双写切到新分片,对账一致后停老分片
# 增量同步:订阅 binlog,把变更按新分片规则路由到新库
# 伪代码示意(本示例基于 MySQL 8.0 binlog)
for event in binlog_stream:
    new_shard = shard_by_new_rule(event.row["user_id"])
    apply_to(new_shard, event)   # 回放增量,保证新旧一致

加分点:迁移后必须做"全量行数 + 抽样 checksum"对账,一致后才切流,这是零丢数据的最后保险。


坑 10:缺少分片维度的字典表与冗余设计

场景 / 错误示范:

订单表分片后,要关联商品信息展示。商品表也是大表,没法做广播表,每次查订单详情都要跨库查商品,RT 居高不下。

-- 错误:订单详情实时跨分片查商品库
SELECT o.*, p.name, p.price
FROM t_order o JOIN t_product p ON o.product_id = p.id
WHERE o.order_no = 'SO001';
-- t_product 在另一套分片库,跨库 JOIN 走不通

为什么是坑:分片时只考虑了"大表怎么切",没考虑"大表之间怎么关联"。商品这种被订单高频引用的表,如果也分片,订单详情就要跨库聚合,延迟和复杂度都上去了。缺乏冗余/字典设计,分片后读路径会非常碎。

正确做法:

把详情页需要的字段冗余进订单,或把商品基础信息放缓存。

-- 正确:下单时把商品快照冗余进订单,详情页不再回查商品库
ALTER TABLE t_order
  ADD COLUMN product_name VARCHAR(128),
  ADD COLUMN product_price DECIMAL(10,2);
-- 下单时写入快照,后续展示读订单本地数据即可

加分点:电商订单里存"商品快照"不仅是性能考虑,也是业务正确性要求——商品改名/降价后,历史订单应保留当时的价格。


坑 11:跨片事务一致性难以保证

场景 / 错误示范:

一个业务操作要同时写订单分片和库存分片,开发者用本地事务包住两个分片的写操作,以为和单库一样原子。

-- 错误:一个事务里跨两个分片写,本地事务根本不跨库
BEGIN;
INSERT INTO t_order (...) VALUES (...);        -- 落在分片 A
UPDATE t_stock SET num = num - 1 WHERE id=...; -- 落在分片 B
COMMIT;
-- 分片 B 失败回滚时,分片 A 的插入并不会回滚,数据不一致

为什么是坑:MySQL 的本地事务只保证单个数据库实例的原子性。跨分片写涉及多个实例,本地事务管不到。靠"先写 A 再写 B、失败就删 A"这种补偿,在并发和异常下必然出现中间态。跨片一致性必须靠分布式事务或最终一致性方案,不能假装它是单库事务。

正确做法:

用最终一致性(本地消息表 / 事务消息)替代强一致跨片事务。

-- 正确:本地消息表保证"业务写"与"待发事件"在同一本地事务
CREATE TABLE t_local_message (
  msg_id BIGINT PRIMARY KEY,
  topic VARCHAR(64),
  payload TEXT,
  status TINYINT DEFAULT 0   -- 0 待发送,1 已发送
) COMMENT='本地消息表';

-- 分片 A 内:订单插入 + 写消息 在同一本地事务
BEGIN;
INSERT INTO t_order (...) VALUES (...);
INSERT INTO t_local_message VALUES (..., 'stock-deduct', '{"id":1}', 0);
COMMIT;   -- 同分片,原子性有保证
-- 后台任务把消息投到 MQ,库存分片消费后扣减,实现最终一致

加分点:能不跨片就不跨片——通过分片键设计让同一业务实体落在同一分片,是规避分布式事务的最优解。


分库分表常见注意事项

分片不是银弹,单库能扛住就别分片。以下注意事项帮你判断"到底要不要分、分了之后怎么收"。

| 决策点 | 建议 | 理由 |

|---|---|---|

| 什么时候才分片 | 单库数据量超千万、写入 QPS 打满单库 | 分片带来的复杂度远大于收益,能晚则晚 |

| 分片键选择 | 高频查询必带 + 高离散 + 不更新 | 选错无法在线改,是一次性决策 |

| 全局 ID | 雪花算法 / 号段模式,应用层生成 | 数据库自增跨库必然撞号 |

| 跨片查询 | 冗余字段 + 映射表 + 离线聚合 | 跨库 JOIN/聚合性能极差 |

| 一致性 | 本地消息表 + 最终一致 | 强一致跨片事务代价高 |

口诀:能单库不分片,能不分片不分表;分片先定分片键,跨片查询靠冗余,一致性走最终方案。


速查表:分片设计关键决策

| 维度 | 单机时代做法 | 分片后做法 | 坑点 |

|---|---|---|---|

| 主键 | AUTO_INCREMENT | 雪花/号段全局 ID | 跨库自增撞号 |

| 查询路由 | 直接查 | 带分片键精准路由 | 非分片键全库扫描 |

| JOIN | 直接 JOIN | 冗余字段/广播表 | 跨库 JOIN 走不通 |

| 分页 | LIMIT offset | 游标 seek | 深分页性能雪崩 |

| 聚合 | 实时 COUNT DISTINCT | 离线预聚合表 | 跨片去重撑爆内存 |

| 扩容 | 垂直升级 | 一致性哈希在线迁移 | 一次性迁移丢窗口期数据 |

| 事务 | 本地事务 | 本地消息表最终一致 | 本地事务不跨片 |


自测练习

1. 为什么非分片键查询在分片架构下特别危险?

<details>

<summary>参考答案</summary>

中间件无法精准路由,只能广播到所有分片做 fan-out,N 个分片就是 N 倍压力,并发时会把全集群打满。应让高频查询带分片键,或为非分片键建映射表。

</details>

2. 分片键应该具备哪三个特征?

<details>

<summary>参考答案</summary>

① 高频查询几乎必带(覆盖核心 SQL);② 高离散度(避免数据倾斜热点);③ 业务上不更新(分片键变了要重分布)。且选错无法在线变更,需在设计阶段充分论证。

</details>

3. 分片后为什么不能再用 AUTO_INCREMENT 做主键?

<details>

<summary>参考答案</summary>

每个分片库独立自增会产生重复 ID。应改用雪花算法或号段模式在应用层生成全局唯一、趋势递增的 ID。

</details>

4. 跨片深分页为什么慢?如何优化?

<details>

<summary>参考答案</summary>

LIMIT offset,size 在分片下每个分片都要返回 offset+size 条再全局归并,offset 越大放大越严重。改用游标 seek(以上一页末值做条件)替代大 offset,并在产品上限制最大翻页深度。

</details>

5. 一个业务操作要写两个不同分片,为什么不能用本地事务?

<details>

<summary>参考答案</summary>

MySQL 本地事务只保证单个实例原子性,跨分片写不在同一事务里。应改用本地消息表 + MQ 实现最终一致性,或通过分片键设计让同一业务实体落同一分片来规避。

</details>

下一篇预告:第 63 章 中间件使用踩坑

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 62 章 分库分表踩坑》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。