微服务架构落地:分库分表实战(下) —— 分布式主键、跨分片查询与分页聚合防坑

大宗商品销采运储系统・六层架构连载(数据持久层)

上一篇我们完成了分库分表的"上篇":讲了切分策略(垂直/水平),用"基因法"解决了路由痛点,并梳理了不停机平滑扩容的步骤。

当数据真正被切分到 16 个库、64 个表之后,你会以为大功告成,但真正的折磨才刚刚开始。

单库时代,MySQL 自带 AUTO_INCREMENT 自增主键;单表时代,JOIN 随便写,LIMIT 100000, 10 随便翻。

分库分表后,这些最基础的 SQL 能力全废了。如果不在代码层和架构层重新兜底,系统不仅跑不起来,还会直接 OOM(内存溢出)。

一句话定调:

分片解决的是存储容量,但打破了数据库的原有生态。分布式主键、跨分片查询、结果归并,是必须补上的三块短板。


一、痛点一:自增主键失效,分布式 ID 怎么生成?

在分库分表环境下,每个表都从 1 开始自增,必然会出现主键冲突。我们需要一个全局唯一的分布式 ID 生成器。

大宗商品场景对 ID 的要求极为严苛:

  1. 全局唯一:绝不能冲突。

  2. 趋势递增:MySQL 的 InnoDB 引擎按主键顺序存储,如果 ID 是乱序的(如 UUID),会导致页分裂,严重影响插入性能。

  3. 高可用、低延迟:每秒钟有大量单据产生,不能因为生成 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)。

落地规范:

  1. 列表页、高级查询、模糊搜索:全部走 ES 检索,拿到单据主键(ID/单号)列表。

  2. 详情页、数据回显:通过拿到的 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 直接报错。

重构原则:

  1. 冗余字段:订单表里冗余客户的名称、编码,避免为了显示个客户名去查客户库。

  2. 代码拼装 :先查出订单列表,提取出 customer_id 集合,再去客户服务内存批量查询(IN 查询),最后在代码里组装成结果。

  3. 大宽表:如果需要很多字段关联,直接做一个大宽表存到数仓或 ES。

    // 代码拼装示例
    List orders = orderMapper.queryListByCustomer(customerId);
    List goodsIds = orders.stream().map(OrderDTO::getGoodsId).collect(Collectors.toList());
    // 批量查询商品信息(商品库)
    Map<Long, GoodsDTO> goodsMap = goodsClient.batchQuery(goodsIds);
    // 内存组装
    orders.forEach(o -> o.setGoodsInfo(goodsMap.get(o.getGoodsId())));


五、本篇小结

分库分表是一项"伤敌一千,自损八百"的架构演进。它换来了容量的扩展,但打破了数据库的诸多便利性。

记住四句话:

  1. 主键用雪花,时钟防回拨。

  2. 查单带基因,复杂查 ES。

  3. 告别深分页,游标是正解;大报表走数仓,跨片聚合是红线。

  4. 抛弃 Join,冗余字段或代码拼装。

下篇预告:分库分表解决了海量数据,但新的问题随之而来:当一个业务动作需要跨越多个微服务、多个数据库时(例如:销售服务扣减库存、财务服务记应收),如何保证数据一致性?分库分表解决了海量数据,但新的问题随之而来:当一个业务动作需要跨越多个微服务、多个数据库时(例如:销售服务扣减库存、财务服务记应收),如何保证数据一致性?

相关推荐
海宇AI1 小时前
零信任架构实战:基于海宇车辆车五项信息核验构建自动化汽配供应链车型分类与发动机型号适配网关
运维·人工智能·架构·自动化
dishugj1 小时前
https://blog.csdn.net/dishugj/article/details/162068579
数据库·系统架构
时间售卖铺1 小时前
MySQL 安装部署
运维·数据库·mysql·云原生
泡海椒1 小时前
JQuick-SQL 子查询:WHERE、SELECT、HAVING 与派生表实践
数据库·sql
德迅云安全-上官1 小时前
新一代智能分布式云接入:应用加速守护 TCP 业务安全与体验
分布式·tcp/ip·安全
方呵呵1 小时前
我做了一个桌面 AI Agent:Hikari 的功能、架构与工程实践
架构
用户0510122572961 小时前
SQLite 学习博客(一):从零建库,理解 SQLite 的角色与第一张表
数据库·sqlite
zzu123zsw1 小时前
面对多 RPC 扇入超时崩溃:深入 asyncTool 核心 DAG 编排与线程池隔离防护
java·网络·spring boot·网络协议·rpc·并发编程
福娃筱欢1 小时前
金仓数据库配置SSL双向认证
数据库·网络协议·ssl