新闻详情

新闻详情

首页 / 资讯中心 / 详情

分库分表后全局索引缺失:订单查询从5ms飙到5s的根因与三种解法

发布时间:2026/10/1 3:14:33来源:尧图网络
分库分表后全局索引缺失:订单查询从5ms飙到5s的根因与三种解法
先说结论分库分表不是把分片键一配就万事大吉那些没被分片键覆盖的查询条件随时可能变成一颗定时炸弹。我维护的订单系统前段时间就踩了一次一个按订单号精确查询的接口因为分库分表后全局索引缺失SQL 被中间件广播到全部 32 张分片表TP99 从几毫秒直接飙到五秒开外。这篇就完整复盘这次事故的根因、三种根治方案和相应 Java 代码给同样在分库分表上摸爬滚打的人做个参考。1. 事故现场一条订单查询让数据库差点“罢工”1.1 业务背景为什么要分库分表这套订单系统上线三年多单表订单量接近亿级。每天新增订单几十万索引越建越深写入时的锁竞争越来越明显单表已经明显撑不住了。当时的方案是水平分库分表按用户维度拆分一共 16 个库每个库 32 张表合计 512 张物理分片表。分库分表之后绝大多数查询场景确实解决了用户查询自己的订单列表、订单详情都带上user_id中间件能够准确路由到对应分片单条 SQL 只打一个物理表性能很稳。上线后大半年这套体系都安安静静的直到售后工单系统接入了订单查询。1.2 “完美但致命”的索引设计现在回头复盘问题的根源在前期设计阶段就埋下了。当时梳理查询需求时重点保证了按user_id这个分片键的所有查询路径表上建的索引也都是基于分片键的(user_id, order_no)唯一索引、(user_id, status)普通索引、(user_id, create_time)普通索引。但售后系统有个很常见的场景客服拿到用户提供的订单号直接查订单详情。这个查询条件只有一个order_no不带user_id。一开始数据量小全路由扫描还能接受我们没有把它当核心风险。等到大促结束后售后工单暴增这个接口就彻底露馅了。这里有个非常容易混淆的认知很多人以为分片表上也建立了order_no的唯一索引查询时“肯定会走索引”。这句话没错但它只对了一半。单分片内确实走索引可中间件需要把 SQL 发到全部 512 张物理表上分别执行每张表都走一遍本地的唯一索引。表面看“每条 SQL 都走了索引”实际上整体是全库扫描。1.3 故障表现与定位过程故障当天售后查询订单接口的 TP99 从正常时的 5ms 左右直接飙到 5 秒以上数据库 CPU 持续高位慢 SQL 日志刷屏。最开始排查时我们以为是某个分片的索引失效了EXPLAIN看了一下发现每张分片表的查询都正常命中索引执行计划没有任何问题。真正定位到问题是打开了中间件的 SQL 日志。一条简单的SELECT * FROM t_order WHERE order_no xxx被打印成了几十条“Actual SQL”分别打到不同的物理分片表上。那一刻才确认SQL 本身没问题索引也没问题问题是路由维度缺失导致查询无法收敛到任何单分片。这次事故也让我意识到一个词的分量分库分表后的“全局索引缺失”不只是约束层面的问题更是查询路由层面的问题。2. 根因拆解为什么分库分表后全局索引会“消失”2.1 局部索引与全局索引的本质区别单库单表时代一张表上的索引天然是全局的。order_no唯一索引一建数据库就能保证全表唯一查询的时候索引也能直接把数据定位到具体行。这个体验很容易让人形成惯性思维以为分库分表后只要每张分片表都建相同结构的索引就完全等价。实际上分片后每一张物理表拥有的只是“局部索引”。以(user_id, order_no)唯一索引为例它保证的是“同一个user_id分片内order_no不重复”但跨分片重复。比如两条订单号相同的记录只要user_id不同就会落到不同的分片表两个分片的本地唯一索引各自认为“我是唯一的”谁也不会报错。全局唯一性在数据库层面已经彻底失控。这里可以用图书馆来类比单表单库就像总馆有一套完整的馆藏目录你按书名查书管理员一查目录就知道书在哪个书架的哪个位置。分库分表之后相当于每个分馆只维护自己那几排书架的目录你想查一本不确定在哪个分馆的书只能挨个分馆打电话问一遍——这就是全库扫描。2.2 分片路由原理为什么非分片键必然全路由分库分表中间件的核心逻辑是从 SQL 中提取分片键比如user_id通过分片算法计算出目标分片然后把 SQL 改写成带物理表名的语句下发。能提取到分片键就能精确路由提取不到就只能把 SQL 广播到所有分片。从原理上说不难理解user_id 123可以算出 512 张表里具体是哪一张但order_no HT202506...算不出任何分片信息。中间件不是不想路由而是没有任何依据去路由。所以它只能做一件事——全路由把查询发给所有分片等结果回来再聚合。分片越多这种全路由查询的放大效应越恐怖。2.3 为什么不能靠“每个分片加唯一索引”解决有同事提过既然order_no查询慢那就在每张分片表上都加一个order_no唯一索引查询时每个分片都能快速返回不就行了吗这个思路在“查得到”层面是对的加了索引单分片查询确实快。但有两个问题解决不了全局唯一约束依然缺失。两个分片同时插入相同order_no不会冲突应用层得不到任何报错。对订单这种强唯一性要求的业务这等于数据安全底线被击穿。全路由的放大效应没有消失。就算每张分片表都走索引一次查询仍然要打 512 张表数据库连接、IO、CPU 的消耗被放大几百倍延迟和资源占用完全不可接受。所以“每个分片建索引”只能算止痛药不能算根治。真正要解决的是两个问题非分片键怎么快速路由到分片以及全局唯一性怎么在应用层建立。3. 根治方案一全局索引映射表两阶段路由3.1 设计思路再造一张“路由小表”最容易理解、也是我当时最先落地的方案就是新增一张全局索引映射表。它只存最精简的路由信息order_no、user_id、create_time其中order_no是唯一键。这张表本身可以按order_no哈希分片也可以不分片只做读写分离。查询订单变成两阶段路由第一阶段用order_no查映射表拿到user_id。第二阶段用user_id作为路由条件精确查询订单分片。这个方案的核心逻辑是用一张专门的小表把“非分片键”翻译成“分片键”。因为映射表按order_no分片所以第一阶段查询仍然能精确路由不会全路由。3.2 写入链路如何保证映射表和订单数据一致方案看起来简单真正的难点在写入。订单插入时业务分片表和数据映射表往往不在同一个物理库跨库双写就有了分布式一致性问题。实操中我建议优先考虑下面两种方式本地消息表 异步消费在订单所在分片的同一个事务里先写订单数据再写一条本地消息表记录。事务提交后异步任务扫描消息表把(order_no, user_id)同步到映射表中完成后更新消息状态。消息表和订单数据在同一分片、同一事务能保证主体数据不丢同步映射表即使失败也可以重试。事务消息RocketMQ 等如果团队已经有成熟的消息中间件用事务消息可以省去自研消息表的维护成本思路和本地消息表本质一样。不要踩的一个坑先写订单、后写映射表如果映射表写失败了订单已经可见但查订单号会查不到售后那边直接“查无此单”。所以映射表同步必须要有补偿机制至少要做到“最终一致”并配合定时对账脚本。3.3 代码实现Mapper、路由服务与写入流程先看映射表的 MyBatis Mapper。这里我用注解方式简洁明了Mapper public interface OrderRouteMapper { Select(SELECT user_id FROM order_no_route WHERE order_no #{orderNo}) Long selectUserIdByOrderNo(Param(orderNo) String orderNo); Insert(INSERT INTO order_no_route(order_no, user_id, create_time) VALUES(#{orderNo}, #{userId}, NOW())) int insert(Param(orderNo) String orderNo, Param(userId) Long userId); }对应查询服务Service public class OrderQueryService { Autowired private OrderRouteMapper routeMapper; Autowired private OrderMapper orderMapper; public OrderInfo queryByOrderNo(String orderNo) { // 第一阶段查映射表得到分片键 userId Long userId routeMapper.selectUserIdByOrderNo(orderNo); if (userId null) { // 生产环境这里应该告警并按需降级到全路由兜底 log.warn(order_no_route miss, orderNo{}, orderNo); return fullScanQuery(orderNo); } // 第二阶段按 userId 路由查询订单分片 return orderMapper.selectByUserIdAndOrderNo(userId, orderNo); } }写入端采用本地消息表思路核心代码如下Transactional public void createOrder(OrderCreateCmd cmd) { Long userId cmd.getUserId(); String orderNo cmd.getOrderNo(); // 1. 写入订单分片表 orderMapper.insert(userId, buildOrderEntity(cmd)); // 2. 写入本地消息表与订单数据同分片、同一事务 routeMsgMapper.insert(new OrderRouteMsg(orderNo, userId, RouteMsgStatus.TODO)); } // 异步任务扫描 TODO 消息并同步映射表 public void syncRouteMsg(OrderRouteMsg msg) { try { routeMapper.insert(msg.getOrderNo(), msg.getUserId()); routeMsgMapper.markDone(msg.getId()); } catch (DuplicateKeyException e) { // 映射已存在直接置为完成即可 routeMsgMapper.markDone(msg.getId()); } }3.4 方案优缺点这个方案是最通用的对存量数据和新数据都能覆盖历史订单也能通过离线任务一次性灌入映射表。缺点是查询多一次 IO而且映射表本身也是一份需要运维和保证一致性的数据。不过因为表很“瘦”性能压力不大整体可控。4. 根治方案二分片基因把路由信息“写进”订单号4.1 基因法的核心思想第二种方案更巧妙既然查不到分片键导致全路由那我就把分片路由信息直接编码进订单号里。查询order_no时从订单号中解析出分片信息就能精确定位不需要任何额外查询。这就是所谓的“分片基因”。它利用一个前提分片算法是固定的比如shard hash(user_id) % 32。那么在生成订单号的时候把算出来的shard值作为固定几位嵌入订单号。以后只要看到订单号就等于看到了它在哪个分片。这种方法在电商、支付系统中很常见。订单号看起来是一串数字或字母其实里面藏了生成时间、分片编号、随机序号。既保证了业务可读性又解决了路由问题。4.2 订单号结构设计与分片算法我设计的订单号格式如下HT yyyyMMddHHmmss(14位) gene(2位) 随机序号(4位)其中gene就是分片基因。假设系统共 32 个分片分片键是user_id分片算法应该和订单号解析结果保持完全一致。分片基因生成和解析的核心代码public final class GeneOrderNo { private static final int SHARD_COUNT 32; private GeneOrderNo() {} /** 分片算法对 userId 哈希扰动后取低 5 位32 2^5 */ public static int shardOfUserId(Long userId) { int h userId.hashCode(); return (h ^ (h 16)) (SHARD_COUNT - 1); } /** 生成订单号时间 2位基因 4位随机数 */ public static String generate(Long userId) { String time new SimpleDateFormat(yyyyMMddHHmmss).format(new Date()); String gene String.format(%02d, shardOfUserId(userId)); String random String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); return HT time gene random; } /** 从订单号解析基因即解析出分片号 */ public static int parseGene(String orderNo) { // HT(2) 时间(14) gene(2)基因位于第 16-18 位 return Integer.parseInt(orderNo.substring(16, 18)); } }这里有两个细节值得说。第一分片数选 32 而不是 10 或 100是为了能用位运算替代取模性能更好。第二gene必须用两位十进制表示32 个分片对应 0~31两位足够但注意前导零比如分片 5 要写成05。4.3 查询路由实现强制指定分片有了基因查询时先解析出分片号再强制指定路由。如果用 ShardingSphere可以借助 Hint 机制public OrderInfo queryByOrderNo(String orderNo) { int gene GeneOrderNo.parseGene(orderNo); try (HintManager hintManager HintManager.getInstance()) { hintManager.addDatabaseShardingValue(t_order, gene); hintManager.addTableShardingValue(t_order, gene); return orderMapper.selectByOrderNo(orderNo); } }使用 Hint 有一个前提你的分片中间件必须支持“手动指定分片路由”。如果你们公司自研了分库分表组件但没开放这个能力那就需要在 SQL 里塞一个冗余路由字段。我在另一套系统里用过更朴素的落地方式t_order表增加一个冗余列shard_gene写入时和订单号里的基因保持相同。查询订单号时SQL 条件写成WHERE shard_gene #{gene} AND order_no #{orderNo}。这样中间件能基于shard_gene正常路由不依赖 Hint兼容性最好。代价是多一个冗余列以及所有写入都要算一遍基因。4.4 基因法的改造代价与兼容性基因法最大的问题在于存量数据和订单号规则改造。历史订单没有基因或者订单号规则跟现在不一样就没法解析。我的建议是生产上做双规则兼容新订单用新规则老订单通过离线任务把基因补到冗余列中或者接入映射表方案兜底。整个迁移期间接口层解析基因失败时走一次全路由兜底作为过渡保险。另外要注意隐私和安全性订单号的基因直接暴露了分片数外部用户可以通过订单号反推系统分片规模。对于直接面向 C 端展示的订单号建议做一层混淆比如基因位不用明文分片号而是用一张编码表映射。不过这会增加复杂度是否要做取决于业务对订单号可猜测性的敏感度。5. 根治方案三索引异构用独立索引库加缓存兜底5.1 思路来源在应用层再造“二级索引”第三种方案是三种里面最“工程化”的也是大流量的互联网系统最常用的。它的思路可以理解为在应用层再造一套独立于业务分片表的二级索引。InnoDB 的聚簇索引和二级索引就是这样配合的二级索引单独存储主键值查询时先走二级索引再回表查数据。全局索引缺失的本质是“排序键和路由键分离”那我们在应用层单独维护一张order_no - (user_id, shard_no)的索引表同时用 Redis 加速就相当于把数据库的二级索引机制搬到了架构层。5.2 同步方式一Canal 监听 Binlog业务无侵入维护独立索引库最优雅的是通过 Canal 监听订单分片库的 Binlog把订单插入、更新、删除事件异步同步到索引库。好处很明显业务代码完全不用感知索引的存在写入订单时不需要“双写”。索引同步服务和订单主流程解耦Canal 挂了最多是索引短暂延迟订单主流程不受影响。架构大致如下MySQL 分片库开启 Binlog。Canal 监听 Binlog 变更解析出order_no、user_id等字段。同步服务消费变更写入独立的 OrderIndex 库同时刷新 Redis 缓存。5.3 同步方式二业务双写 Redis读多场景优先Canal 方案适合团队已经有 Canal 基础设施的情况。如果没有业务双写 Redis 是更快的过渡方案写入订单时同时往 Redis 写一条order_no - userId|shardNo的映射不需要额外索引库。查询时先查 Redis命中就直接路由到分片不命中再查全路由或者落库索引。对售后这种“查最近订单”的场景Redis 过期时间可以设置 7 天足够覆盖绝大多数查询。5.4 代码实现缓存加索引库查询链路先定义索引表 MapperMapper public interface OrderIndexMapper { Select(SELECT user_id, shard_no FROM order_idx WHERE order_no #{orderNo}) RouteInfo selectByOrderNo(Param(orderNo) String orderNo); Insert(INSERT INTO order_idx(order_no, user_id, shard_no) VALUES(#{orderNo}, #{userId}, #{shardNo})) int insert(RouteInfo routeInfo); }再写一个路由索引服务Redis 优先索引库兜底Service public class OrderRouteIndexService { private static final String KEY_PREFIX order:route:; Autowired private StringRedisTemplate redisTemplate; Autowired private OrderIndexMapper indexMapper; public RouteInfo getRoute(String orderNo) { String key KEY_PREFIX orderNo; String value redisTemplate.opsForValue().get(key); if (value ! null) { return RouteInfo.parse(value); } // Redis 未命中查独立索引库 RouteInfo route indexMapper.selectByOrderNo(orderNo); if (route ! null) { redisTemplate.opsForValue().set(key, route.toString(), 7, TimeUnit.DAYS); } return route; } }订单查询服务整体链路就完整了Service public class OrderQueryService { Autowired private OrderRouteIndexService routeIndexService; public OrderInfo queryByOrderNo(String orderNo) { RouteInfo route routeIndexService.getRoute(orderNo); if (route null) { // 极端情况下索引缺失降级到全路由但必须告警 log.error(route index missing, orderNo{}, orderNo); return fullScanQuery(orderNo); } return orderMapper.selectByUserIdAndOrderNo(route.getUserId(), orderNo); } }代码里的RouteInfo就是一个简单的值对象包含userId、shardNo两个字段外加toString和parse两个方法做序列化。实际项目中可以替换成 JSON。5.5 一致性风险和兜底策略异构索引方案最大的隐患是“索引数据丢了怎么办”。Redis 会过期、会宕机索引库和业务库之间也有同步延迟。所以它不是一个完美的强一致方案而是一个高性能的最终一致方案。我的兜底组合是缓存优先 索引库兜底 全路由垫底 告警追踪。全路由慢但能保证数据最终查得到属于保命手段。索引库数据和业务数据之间必须有对账任务比如每天扫描增量 Binlog 和索引表差异发现缺失自动补齐。6. 三套方案对比与选型建议三种方案在实践中不是互斥的。我整理了一个对比表方便你根据自己系统的阶段做选择方案额外存储代码改造量一致性复杂度查询延迟影响适用场景全局索引映射表中中高多一次 IO存量系统、多种非分片键查询分片基因无大低无新系统、订单号规则可改造索引异构 缓存中中中极小Redis 命中高并发读、读多写少单看某个维度基因法最“干净”零额外存储、零额外查询。但它对订单号规则的要求很高而且存量数据无法自动获得基因。映射表方案最通用但多一次查询和写入链路维护。异构索引方案性能最好但整体架构复杂度和运维成本最高。我个人的落地倾向是这样的存量系统、急着止血先上全局索引映射表离线任务灌历史数据马上消除全库扫描。新系统、从零设计订单号直接采用基因方案从根上避免非分片键查询。高并发场景在基因法或映射表基础上叠加 Redis 缓存索引查询热点订单号几乎零成本。最终形态业务规模上来后用 Canal 同步独立索引库把路由索引和业务解耦。7. 实战避坑清单分库分表后的索引与查询教训7.1 分片键定了就不要频繁改动这次事故的根因不是代码写错了而是分片键的选择只考虑了用户维度的查询没有覆盖订单号维度。分片键一旦确定所有查询路由都围绕它展开后期想改牵涉存量数据搬迁、中间件配置、全链路接口改造成本巨大。所以分库分表设计阶段第一件事就是把业务所有查询 SQL 全部拉出来按“是否包含分片键”分类。凡是走不到分片键的查询要么改造成带分片键要么提前设计全局索引。这一步省不得后面要还的债往往比省下的时间多得多。7.2 全路由扫描要能“被看见”很多系统直到线上故障才发现自己在全路由扫描就是因为根本没有监控。建议在所有分库分表中间件上开启 SQL 日志或者接入全路由检测工具凡是解析不出分片键的查询统一打点告警。从 ShardingSphere 的日志里能看到一行 SQL 被展开成几十条物理 SQL这是个非常直观的信号。如果你们是自研中间件可以在路由层统计“单条 SQL 实际访问物理表数量”访问表数量超过阈值就触发告警。这样才能在全路由变成事故前拦住它。7.3 全局唯一约束必须由应用层保证每个分片上的唯一索引只能保证局部唯一全局唯一这件事数据库已经管不了了。如果你刚完成分库分表原来单表上的唯一约束用户手机号、订单号、流水号等全部要重新审视。我见过一个极端案例订单号生成器在并发下产生重复单库时代靠唯一索引兜底报错分库分表后重复订单号分别落在不同分片系统毫无感知直到对账才发现大量重复数据。教训就是分库分表后唯一性校验必须前置到应用层生成 ID 的环节数据库兜底能力已经不存在了。7.4 上线前做一次“非分片键查询”全量压测很多人上线前压测都只测核心链路偏偏漏掉售后查询这种低频但致命的接口。分库分表系统的压测一定要包含非分片键查询场景而且要模拟真实分片规模。压测时观察两个指标一是单个全路由查询的耗时二是数据库连接数被放大消耗的情况。512 张分片表全路由一次和单表查询完全不是一个量级。如果有任何一个查询条件不支持分片键路由压测阶段就该把它暴露出来而不是等到大促后让售后同事帮你发现。结尾说点掏心窝的话这次事故给我最大的触动是分库分表的本质是“用路由维度换容量”它从来不是无代价的。你选择了按user_id分片就等于默认放弃了order_no的全局索引能力。很多团队在设计阶段只盯着“怎么把表拆开”却忽略了“拆开后查询怎么进来”这属于典型的准备不足。如果让我重做一遍我会在分库分表设计的第一天把所有非分片键的查询场景列成一张清单逐个标记路由方案和兜底方案然后写进设计文档。这套流程不复杂但能让你避开我踩过的坑。希望这篇分库分表踩坑实录能帮你在设计阶段就想清楚全局索引这件事别等到线上 TP99 飙升再来救火。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

从零手搓AI工程:手写神经网络与工程化实践指南 2026/10/1 5:17:35

从零手搓AI工程:手写神经网络与工程化实践指南

1. 从零手搓AI工程:为什么我不建议你直接调包第一次看到ai-engineering-from-scratch这个项目名的时候,我脑子里蹦出来的画面是:一个人坐在终端前,从矩阵乘法开始,一行一行把神经网络敲出来,中间还要自己写…

阅读更多 →
AgentScope 2.0实战:多智能体框架、RAG服务化与Java集成指南 2026/10/1 5:17:29

AgentScope 2.0实战:多智能体框架、RAG服务化与Java集成指南

如果你最近在关注多智能体(Multi-Agent)开发,肯定绕不开AgentScope这个名字。我第一次在社区刷到这个项目的时候,心里想的是:哦,又一个包装大模型的框架,跟那几十个套壳开源项目估计没啥区别。直…

阅读更多 →
.NET 10 + WinForm 实战:从零构建LOL助手核心骨架与关键模块 2026/10/1 5:17:29

.NET 10 + WinForm 实战:从零构建LOL助手核心骨架与关键模块

1. 从零拆解一个桌面端游戏辅助工具的核心骨架1.1 这个项目到底在做什么“.net10winform制作LOL助手二”这个标题,核心信息量其实很密集。拆开来看,技术栈锁定在.NET 10加WinForm,目标产物是一个LOL助手,而那个“二”字说明这是系…

阅读更多 →
基于Python机器学习的网络入侵检测:NSL-KDD预处理与实时部署 2026/10/1 5:17:29

基于Python机器学习的网络入侵检测:NSL-KDD预处理与实时部署

简介:这是一套基于 Python 机器学习的网络入侵检测系统源码,适用于高校信息安全、网络工程、计算机等相关专业的课程设计与期末大作业。项目在导师指导下完成并获得97分,完整包含数据加载、特征处理、模型构建、训练评估等环节,基…

阅读更多 →
AI Agent技能管理器:统一托管与可视化实践 2026/10/1 5:17:29

AI Agent技能管理器:统一托管与可视化实践

1. 为什么我们需要一个给 AI Agent 用的技能管理器1.1 从一个真实的混乱现场说起如果你手头同时跑着三五个 AI Agent,大概率经历过这种场面:一个 Agent 负责整理会议纪要,一个负责盯数据看板,还有一个在后台默默处理工单。每个 Ag…

阅读更多 →
基于.NET 10与WinForms的桌面游戏助手架构设计与性能优化实战 2026/10/1 5:17:29

基于.NET 10与WinForms的桌面游戏助手架构设计与性能优化实战

1. 从零拆解一个桌面端游戏助手的整体设计思路1.1 为什么选 .NET 10 WinForms 这套组合做游戏辅助类桌面工具,技术选型其实就那么几条路:Electron、WPF、WinForms、MAUI,再或者直接上 C 原生。我前后试过 Electron 和 WPF,最后回…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉