- 要么:回拨时打特殊 bit + 记录告警(仍需保证不重复)
别让它变成“静悄悄的重复主键”,那种事故最阴间。
### 4.2 让 ID 可路由:把分片信息编码进去?
有两条路:
- **显式编码分片位**:ID 中包含 shardId,路由 O(1)
- **隐式计算**:路由依赖 `user_id % N`,但你需要先拿到 user_id
交易系统里通常:
- 用户请求“订单详情”只带 `orderId`
- 服务端必须靠 `orderId` 直接路由,不然你得全库扫一遍(那是灾难)
所以常用做法是:
- orderId 生成时已知 `userId`
- 计算 `shard = hash(userId) % shardCount`
- 把 `shard` 编进 `orderId` 的某些 bit
示意:
```text
| time(41) | shard(6) | node(4) | seq(12) |
```
- shard 6 bit → 64 个分片
- node 4 bit → 16 台节点
这会牺牲一点 node/seq 的空间,但换来**路由确定性**。
你也可以不把 shard 编进 ID,而是在 Redis/Meta 表里做“orderId → shard”映射,但那相当于引入一个额外的强依赖点。
我更偏好“把路由信息放进 ID”,简单粗暴,出了问题也好查。
---
## 5. 分库分表的演进路径:别一步到位,先走“可回滚”的三段式
现实里很少有团队能一口气把所有表都分掉,还不影响业务。
我建议三段式:
### 5.1 第一段:先做“逻辑分片”,不立刻分库
做法:
- 先引入全局 ID
- 表结构里加 `shard_key`(比如 user_id)
- 写入时强制带上 `shard_key`,查询时强制走路由(哪怕暂时还是同库同表)
这样你的代码层面已经“分片化”,但 DB 仍是单体。
好处:
- 让路由逻辑先稳定
- 让查询形态先收敛
- 让团队先习惯“不要随便 JOIN”
你会惊讶地发现:很多问题在这一段就暴露出来了——
- 运营想要的查询其实是报表
- 客服要的筛选其实是搜索
- 业务方以为随手一查是“应该的”
这段做完,你的系统会更像一个成年人。
### 5.2 第二段:先分表(同库多表),再分库
- `order_00 ~ order_63`
- `ledger_entry_00 ~ ledger_entry_63`
分表能解决:
- 单表索引膨胀
- 部分锁竞争
- 部分 DDL 影响面
同时 DBA/运维成本相对可控。
### 5.3 第三段:再分库(真正的写扩展)
- `db0: order_00~07`
- `db1: order_08~15`
- ...
这段才是“真正的扩展性”,但也是“真正的复杂性”。
---
## 6. 迁移策略:双写不是银弹,先想清楚“真相源”是谁
分库分表最痛的是迁移。
常见三种策略:
### 6.1 方案 1:停机迁移(最靠谱但最难被允许)
- 停机窗口
- 全量导出 → 导入
- 切流
如果你能争取到停机窗口,恭喜你,你的老板可能是天使。
### 6.2 方案 2:双写 + 回放(常用但容易写成灾难)
典型做法:
- 新旧库同时写(或通过 binlog 同步)
- 读流量逐步切换
- 校验一致性
坑点:
- **双写失败处理**:一边成功一边失败怎么办?
- **幂等**:第 2 篇的幂等思路要延续到迁移工具
- **顺序性**:状态机更新乱序会把订单写回“过去”
我建议把“真相源”明确为:
- 旧库是真相源(新库是影子)
- 或新库是真相源(旧库只兜底)
不要“两个都当真”,那叫精神分裂。
### 6.3 方案 3:按用户/商户分批搬家(最符合分片思想)
- 按 `shard`/`merchant`/`user` 维度搬迁
- 每批搬迁后冻结该分片的写入几分钟(或通过队列排空)
- 搬完切路由
这方案的关键是:
- 你得有“分片级开关”
- 你得有“分片级一致性校验”
否则搬一次出一次事故。
---
## 7. 订单与账本:跨分片一致性怎么做?(别做 2PC,你会后悔)
交易系统最常见的跨表一致性:
- 订单状态更新
- 账本流水追加
如果这两者落在不同分片,你会本能地想:
- “要不要上 2PC?”
我的建议:**不要。**
你想要的不是“教科书级强一致”,你想要的是:
- 用户体验一致
- 资金安全可对账
- 故障可恢复
做法可以沿用前几篇的组合拳:
1) 订单状态更新(主链路)
2) 账本流水写入(同事务或同分片优先)
3) 若跨分片:通过 Outbox 发事件 → 账本服务异步落库
4) 对账/修复任务兜底
也就是说:
- **主链路保证“可感知一致”**(用户看到的状态正确)
- **账本保证“可审计一致”**(最终以账本为准)
在金融/支付更严格的场景,会以账本为真相源;在电商类场景通常订单是主视图,但账本必须能对齐。
---
## 8. SQL 与索引:分片后你该怎么写查询(否则你会写出“跨库全表扫”)
### 8.1 订单详情:必须单分片
```sql
SELECT *
FROM order_17
WHERE order_id = ?;
```
这里 `order_17` 是路由结果,不是你手写的。
### 8.2 用户订单列表:单分片分页(尽量用“游标”)
不要用 `offset` 大分页:
```sql
-- 不推荐
SELECT *
FROM order_17
WHERE user_id = ?
ORDER BY order_id DESC
LIMIT 10000, 20;
```
推荐游标:
```sql
SELECT *
FROM order_17
WHERE user_id = ?
AND order_id < ?
ORDER BY order_id DESC
LIMIT 20;
```
前提:你的 orderId 大体有序(类 snowflake)。
### 8.3 运营全站订单按时间范围?去 ES/离线
别在分片库里做:
```sql
-- 这玩意儿会把你从架构师打回实习生
SELECT * FROM order_* WHERE create_time BETWEEN ? AND ?;
```
如果你一定要做,就得用专用归档表/时间分区库,别把 OLTP 当 OLAP。
---
## 9. 实战落地:我们团队当时怎么做(以及我们踩过的一个“幽灵 bug”)
我们最终的决策是:
- `order` 按 userId 分片(64 分片)
- `ledger_entry` 也按 userId 分片(保持同分片落点,减少跨分片)
- `payment` 跟随 orderId(由 orderId 解出 shard)
- 引入 orderId 编码 shardId 的类 Snowflake
- 运营/搜索全部走 ES
迁移策略:
- 先逻辑分片 → 再分表 → 再分库
- 数据迁移按 shard 批次执行
- 每批迁移:先停该 shard 的写(限流 + 排队),再搬迁,再切路由
“幽灵 bug”是什么?
某次迁移后,少量订单出现:
- 用户侧显示“已支付”
- 账本里却没有对应流水
排查后发现:
- 我们对 outbox 消费做了幂等(第 2 篇)
- 但幂等 key 用了 `orderId + eventType`
- 迁移过程中,同一个 orderId 在新旧库各回放了一次(因为影子同步重复投递)
- 幂等挡住了第二次消费
- 而第一次消费落在了旧库账本分片(迁移后被切走),导致新库账本缺失
修复办法:
- 幂等 key 改成 `eventId`(事件天然唯一)
- 并且事件携带 `targetShard`,消费者强制落到目标分片
- 迁移期间对 outbox 事件做“分片冻结”,避免跨边界写入
那天我学到的道理是:
> 迁移不是“拷贝数据”,是“拷贝真相”。
你以为你在搬家,其实你在搬**因果关系**。
---
## 10. 小结:什么时候该分库分表?我给一个不那么鸡汤的答案
你可以考虑分库分表,当且仅当:
- 单库写入已接近上限(CPU/IO/锁等待持续高),并且优化空间不大
- 查询形态已收敛(你能说清楚 80% 的查询长什么样)
- 你已经有全局 ID 方案,并且服务端能路由
- 你愿意为复杂性付钱(运维、监控、排障、数据修复、工具链)
否则,先做这些通常更划算:
- 把跨维度查询挪到 ES/数仓
- 把热写外移(库存、计数、风控)
- 让账本追加化(减少更新)
- 收敛状态机、减少“随便查”
---
## 11. 彩蛋:如果你只记住一句话
分库分表像搬家:
- **你搬的不只是箱子(数据),你搬的是地址簿(路由)、户口(主键)、收据(账本)、以及未来的快递路线(查询形态)。**
搬得好,系统扩展;搬不好,你的夜晚扩展。
下一篇我们聊:**Redis 坑位图**——缓存击穿、雪崩、大 Key、热 Key,以及“你以为你在用缓存,其实缓存正在用你”。