ByFomo

架构实战|MySQL 分库分表 & 全局 ID:别急着上 ShardingSphere,先把“账本”想明白(连载第 5 篇)

2026/02/04
1
0

- 要么:回拨时打特殊 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,以及“你以为你在用缓存,其实缓存正在用你”。