大宗商品销采运储系统・六层架构连载(数据持久层)
上一篇我们完成了分库分表的"上篇":讲了切分策略(垂直/水平),用"基因法"解决了路由痛点,并梳理了不停机平滑扩容的步骤。
当数据真正被切分到 16 个库、64 个表之后,你会以为大功告成,但真正的折磨才刚刚开始。
单库时代,MySQL 自带 AUTO_INCREMENT 自增主键;单表时代,JOIN 随便写,LIMIT 100000, 10 随便翻。
分库分表后,这些最基础的 SQL 能力全废了。如果不在代码层和架构层重新兜底,系统不仅跑不起来,还会直接 OOM(内存溢出)。
一句话定调:
分片解决的是存储容量,但打破了数据库的原有生态。分布式主键、跨分片查询、结果归并,是必须补上的三块短板。
一、痛点一:自增主键失效,分布式 ID 怎么生成?
在分库分表环境下,每个表都从 1 开始自增,必然会出现主键冲突。我们需要一个全局唯一的分布式 ID 生成器。
大宗商品场景对 ID 的要求极为严苛:
-
全局唯一:绝不能冲突。
-
趋势递增:MySQL 的 InnoDB 引擎按主键顺序存储,如果 ID 是乱序的(如 UUID),会导致页分裂,严重影响插入性能。
-
高可用、低延迟:每秒钟有大量单据产生,不能因为生成 ID 拖慢交易。
主流方案选型
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| UUID | 本地生成,包含时间、MAC 等 | 简单、无网络开销 | 无序、太长(占空间),会导致 B+ 树页分裂 | ❌ 禁止作为业务主键 |
| 数据库自增 | 独立一张表,步长递增 | 绝对有序 | 数据库单点故障,并发性能差 | ❌ 不适用高并发 |
| 雪花算法(Snowflake) | 64 位整数,包含时间戳、机器码、序列号 | 本地生成、趋势递增、性能极高 | 强依赖服务器时钟 | ✅ 项目首选(配合 NTP) |
| 号段模式(Leaf) | 数据库批量下发号段,本地分配 | 不依赖时钟、吞吐高 | 需要维护下发服务 | ✅ 对时钟敏感的金融场景 |
本项目选型:雪花算法(Snowflake)改造版
针对雪花算法可能遇到的"时钟回拨"问题(服务器时间校准导致时间倒退,生成重复 ID),在生成器中加入本地时钟校验。
public class SnowflakeIdGenerator {
// 起始时间戳 (2020-01-01)
private final long twepoch = 1577808000000L;
// 机器ID所占位数
private final long workerIdBits = 5L;
// 序列号所占位数
private final long sequenceBits = 12L;
private long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = timeGen();
// 时钟回拨处理:如果当前时间小于上次时间,说明发生了回拨
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 回拨在 5ms 内,等待两倍时间
try { wait(offset << 1); timestamp = timeGen(); }
catch (InterruptedException e) { throw new RuntimeException(e); }
} else {
// 回拨严重,直接抛出异常,拒绝服务,交给监控告警
throw new RuntimeException("时钟回拨严重,拒绝生成ID");
}
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << 22) | (workerId << 12) | sequence;
}
// ... 省略 tilNextMillis 和 timeGen 实现
}
二、痛点二:无分片键查询,怎么查?
上一篇提到了"基因法",解决了带分片键(如客户ID、订单号)的查询。但现实中,业务人员的查询维度千奇百怪:
-
按供应商查询采购单(没带客户ID,也没带订单号)。
-
按仓库名称查询历史入库单。
-
按商品编码查询这周所有的出库单。
如果不带分片键,ShardingSphere 只能把 SQL 广播到所有分片执行,这叫"全路由",性能极差。
解决方案:异构存储(ES 作为查询入口)
在大宗商品架构中,对于复杂多条件查询,不要用 MySQL 去抗 。
我们在数据同步层将 MySQL 的数据通过 Canal 或 RocketMQ 异构到 Elasticsearch(ES)。
落地规范:
-
列表页、高级查询、模糊搜索:全部走 ES 检索,拿到单据主键(ID/单号)列表。
-
详情页、数据回显:通过拿到的 ID,携带分片键(如订单号)精准路由回 MySQL 获取完整数据。
这样不仅解决了全路由带来的数据库压力,还将文本检索的性能提升了百倍。

三、痛点三:深分页与跨分片聚合,内存直接 OOM
分库分表后,最怕写 LIMIT 和聚合函数。
1. 深分页灾难
你要查第 10000 页的数据(LIMIT 100000, 10)。
在分库分表环境下,每个分片都要查出 100000 + 10 条数据,然后全部加载到 ShardingSphere 的内存中,进行全局排序,再取出最后 10 条。
数据量一大,直接 OutOfMemoryError。
终极解决方案:禁止浅表深分页,全面推行"游标翻页"或"限制最大页数"。
-
业务妥协:前端限制最多只能翻 200 页,超过请加时间范围过滤。
-
游标分页 :使用
WHERE id > last_id LIMIT 10代替OFFSET。大宗商品的单据由于趋势递增,天然适合这种方式。
2. 跨分片聚合
比如 SELECT status, COUNT(*) FROM t_order GROUP BY status。
ShardingSphere 会将其改写为 SELECT status, COUNT(*) FROM t_order_0 UNION ALL SELECT ... t_order_63,然后内存汇总。
如果有几千万单子,内存分分钟被打满。
解决方案:大统计(报表)禁止直连业务 MySQL。
-
所有汇总数据(如"今日成交量"、"本月出库总重"),通过 canal 同步到数仓,或者在报表服务中由调度任务定时预计算存入 Redis。
-
业务系统里,严禁写跨分片的大聚合 SQL。

四、痛点四:跨库 Join 彻底报废
分库分表后,t_order 在库 A,t_customer 在库 B,原来的 LEFT JOIN 直接报错。
重构原则:
-
冗余字段:订单表里冗余客户的名称、编码,避免为了显示个客户名去查客户库。
-
代码拼装 :先查出订单列表,提取出
customer_id集合,再去客户服务内存批量查询(IN查询),最后在代码里组装成结果。 -
大宽表:如果需要很多字段关联,直接做一个大宽表存到数仓或 ES。
// 代码拼装示例
Listorders = orderMapper.queryListByCustomer(customerId);
ListgoodsIds = orders.stream().map(OrderDTO::getGoodsId).collect(Collectors.toList());
// 批量查询商品信息(商品库)
Map<Long, GoodsDTO> goodsMap = goodsClient.batchQuery(goodsIds);
// 内存组装
orders.forEach(o -> o.setGoodsInfo(goodsMap.get(o.getGoodsId())));
五、本篇小结
分库分表是一项"伤敌一千,自损八百"的架构演进。它换来了容量的扩展,但打破了数据库的诸多便利性。
记住四句话:
-
主键用雪花,时钟防回拨。
-
查单带基因,复杂查 ES。
-
告别深分页,游标是正解;大报表走数仓,跨片聚合是红线。
-
抛弃 Join,冗余字段或代码拼装。
下篇预告:分库分表解决了海量数据,但新的问题随之而来:当一个业务动作需要跨越多个微服务、多个数据库时(例如:销售服务扣减库存、财务服务记应收),如何保证数据一致性?分库分表解决了海量数据,但新的问题随之而来:当一个业务动作需要跨越多个微服务、多个数据库时(例如:销售服务扣减库存、财务服务记应收),如何保证数据一致性?