分库分表Bug修复实录:UPDATE ORDER BY LIMIT语义被改写,中间件如何兜底

发布时间:2026/10/7 3:52:34
分库分表Bug修复实录:UPDATE ORDER BY LIMIT语义被改写,中间件如何兜底 前阵子给 ShardingSphere 提了个 PR修的是分库分表场景下一个UPDATE语句带了ORDER BY和LIMIT、结果“改多了”的 Bug。本来以为这种边角料问题大概率会被 committer 直接 close结果补丁被认真 review 了一圈居然真的合进去了。复盘整个提 PR 的过程发现这个 case 特别值得拿出来聊聊它表面上是 SQL 语法兼容问题实际上牵扯到分库分表中间件的解析、路由、改写、执行全套链路还顺带验证了一件事——很多在单库单表下“数据库自己会处理”的语义一旦数据被拆到多个分片上就得靠中间件自己扛。这篇文章既是把这 Bug 的来龙去脉、根因定位、修复方案讲透也是给准备给开源项目提 PR 的朋友一份完整的实战记录。无论你是正在用 ShardingSphere、踩过类似分库分表的坑还是单纯想了解一个开源 Bug 从发现到合入的全过程都能从中找到点东西。1. 先说结论这个Bug是什么为什么值得修1.1 一个SQL引发的“数据事故”先还原一下现场。假设有个订单表t_order做了分库分表分片键是order_id一共 4 个库每个库里若干张表。业务里有个批处理任务要把“最近创建的 10 条订单”状态改成已关闭SQL 写得很自然UPDATE t_order SET status 1 ORDER BY create_time DESC LIMIT 10;在单库单表下这条 SQL 的语义非常明确全表按create_time倒序排取前 10 条改状态。MySQL 会老老实实把全表扫一遍、排序、取 10 行、更新返回结果 “10 rows affected”。但是分库分表之后呢这条 SQL 没有带分片键order_id中间件只能走全路由把 SQL 下发给 4 个分片。每个分片都执行了一遍“本分片内按 create_time 倒序取 10 条更新”最终返回结果可能是 “37 rows affected”。更麻烦的是更新的 37 条数据并不保证是全局排序下“最新”的 10 条——每个分片只取了自己分片内的前 10 条合起来根本不是业务想要的那批数据。这就是我提交的 PR 要修的问题分库分表场景下带ORDER BY ... LIMIT的 UPDATE 语句被中间件原样下推到每个分片执行导致数据变更范围超过预期且变更对象不符合全局排序意图。1.2 这个场景在真实业务里有多常见可能有人觉得这种 SQL 写得不规范但在真实生产环境里“只改最近 N 条”的需求非常普遍。我整理了几个典型的业务场景风控/营销批处理给最近 30 天有登录行为的用户发券先UPDATE user SET flag1 WHERE ... ORDER BY last_login DESC LIMIT 1000。日志/流水清理每个用户只保留最近 100 条操作记录清理任务常写成“删除除了最近 100 条之外的数据”或者反过来“只更新最近 100 条”。状态流转批任务把最近创建的待处理订单批量推进到下一状态类似上面那个场景。排行榜/置顶逻辑把一个分类下最新的 N 条记录置为推荐位。这些场景在单库时代很容易跑通一旦分库分表SQL 原样下发就会踩雷。为什么很多人没发现原因也很现实测试环境很多还是单库或者只有 2 个分片执行完看着“行数差不多”没细究。这类批任务大多在凌晨跑行数超了也不影响线上功能日志没有告警。有些中间件会直接对这种 SQL 抛“不支持”异常反倒拦住了问题但 ShardingSphere 当时是“允许但语义错误”这种“能跑但结果不对”的状态最危险。这已经不只是语法层面的问题而是数据一致性问题。轻则改了不该改的数据重则批处理任务算错数、影响资金类数据。所以这个 Bug 值得修而且值得认真修。2. 问题定位从SQL执行结果反常到源码层排查2.1 最小化复现先声明我提交的 Bug 是在特定版本下复现的不同版本的 ShardingSphere 行为可能有差异但问题的本质是通用的。复现步骤我尽量说得具体一点。建表语句大致是这样简化版CREATE TABLE t_order ( order_id BIGINT PRIMARY KEY, user_id BIGINT, amount DECIMAL(10,2), status INT, create_time DATETIME );分片规则order_id % 4分库也就是 4 个分片。执行下面的 SQLUPDATE t_order SET status 1 ORDER BY create_time DESC LIMIT 10;期望全局 4 个分片里按create_time倒序取 10 条更新。实际每个分片都更新了自己分片内倒序前 10 条总计 40 条假设每个分片都有超过 10 条数据。这个 SQL 没有WHERE条件所以全路由是正常的。问题出在后面的ORDER BY LIMIT被原样拼到了下发到每个分片的 SQL 里。我先把执行链路拉出来看一遍再顺着链路去找代码层面的原因。2.2 分库分表的执行链路解析、路由、改写、执行、归并分库分表中间件的核心执行流程可以分成五步我会用一个生活化的类比来解释。解析SQL Parsing把 SQL 字符串变成一棵语法树。有点像把一句话拆成主谓宾定状补中间件得先知道你这条 SQL 要干什么。ShardingSphere 用的是自研的解析引擎支持 MySQL、PostgreSQL、openGauss 等多种方言。路由Routing根据分片键的值比如order_id 100决定这条 SQL 要去哪些分片执行。没有分片键条件时就是全路由所有分片都得去。改写SQL Rewriting把逻辑表名改成物理表名、加上分片条件、调整 SQL 结构。逻辑上的t_order要变成t_order_0、t_order_1之类的物理表如果是分库还要换数据源。执行Execution把改写后的 SQL 发到各个分片去执行ShardingSphere 这里有多线程并发执行的能力。归并Result Merging分片执行的返回结果汇总。对于 SELECT 查询要做结果集的合并、排序、分页、聚合但对于 UPDATE数据库驱动返回的只是“影响行数”归并阶段简单加总就完事了中间件没有机会再干预。类比一下单库单表时你请了一个管家数据库帮你干完所有活它知道“先排序再取 10 条”是什么意思。分库分表后你相当于把活儿外包给了 4 个管家分片每个管家只知道自己负责的那一亩三分地。如果中间这个“包工头”中间件不额外嘱咐4 个管家都会各自按自己的理解干活最后汇总出来的结果自然不是你要的。2.3 顺着链路揪出根因定位的过程其实不复杂核心是“二分法”锁定问题环节。第一步确认路由。通过 ShardingSphere 的 SQL 日志或者HintManager查看路由结果这条 SQL 正确走了全路由4 个分片都分到了。没问题。第二步确认改写。打开 SQL 日志的“改写后 SQL”输出发现下发给每个分片的语句几乎原封不动ORDER BY create_time DESC LIMIT 10还在。到这里基本可以断定问题出在“改写阶段没有处理 UPDATE 语句的 ORDER BY 和 LIMIT”。第三步看源码。我去 ShardingSphere 的仓库里翻UpdateStatement相关的类和改写模块。UpdateStatement里确实有setAssignment、where这些信息但对于orderBy、limit这两个在 SELECT 语句里会被重点关注的结构UPDATE 语句的改写逻辑里基本没有判断。我当时搜索了几个关键类比如SQLRewriteContext、ShardingSQLRewriteContext还有和 DML 改写相关的 encoder/decoder发现 SELECT 语句有专门处理ORDER BY/LIMIT/GROUP BY的逻辑派生列、分页参数都处理得很细但 UPDATE 语句的改写走的是另一条相对简单的路径压根没管ORDER BY和LIMIT的存在。这就解释了为什么“居然改了”——因为在很多数据库中间件看来UPDATE加ORDER BY本来就不是主流写法能跑通就不错了。但 ShardingSphere 既然承诺兼容 MySQL 的 SQL 方言这个语义就该被正确实现而不是让用户自己踩坑。第四步确认修复点。既然路由阶段无法判断因为不带分片键必然全路由执行阶段无法干预UPDATE 没有结果集归并那修复点只能放在改写阶段要么改写 UPDATE 语句本身要么改变执行计划让中间件在 UPDATE 执行前先拿到一份“全局正确的目标主键列表”。3. 修复方案设计不能只改一行SQL得先想清楚语义3.1 为什么不能简单禁止刚开始我想的方案很简单粗暴检测到 UPDATE 语句同时带ORDER BY和LIMIT且路由到多个分片时直接抛异常告诉用户“不支持请改写”。这个方案实现起来最省事但也最不可取。原因有三单分片命中时是合法的。如果WHERE条件里带上了分片键比如WHERE order_id IN (1, 2, 3)恰好只路由到一个分片那这个分片内部的ORDER BY ... LIMIT语义是完全正确的没有任何理由禁止。存量业务迁移会直接崩。很多团队是分库分表做完了业务 SQL 还没来得及全部改造这种 SQL 虽然语义错了但至少“能跑”。你一禁线上凌晨的批处理直接报错影响面更大。中间件的职责不是限制 SQL而是尽最大可能保持 SQL 语义。用户的习惯是“我在单库上这么写是对的换了分库分表也应该对”中间件应该尽可能抹平差异而不是把差异甩给用户处理。所以修复方向应该是能准确执行的场景要保证准确不能准确执行的场景再考虑报错或降级。我的方案走的是“能准确执行”的路线。3.2 可行的修法把“先查后改”做成执行计划修复的核心思路是把一条语义不确定的 UPDATE改写成“先全局查再精确改”的两阶段执行。具体步骤如下第一步识别场景。当UPDATE 语句包含 ORDER BY LIMIT并且路由结果覆盖多个分片时触发改写流程。第二步生成查询语句。把 UPDATE 改写为一个查询目标主键的 SELECT-- 原 UPDATE UPDATE t_order SET status 1 ORDER BY create_time DESC LIMIT 10; -- 改写出的第一步 SELECT SELECT order_id FROM t_order ORDER BY create_time DESC LIMIT 10;这步 SELECT 走的是 ShardingSphere 成熟的 SELECT 执行链路会做全局归并排序拿到真正符合业务语义的“全局前 10 条”的order_id集合。第三步把主键集合带回到 UPDATE。用WHERE order_id IN (...)的形式把更新语句变成精确的定点更新UPDATE t_order SET status 1 WHERE order_id IN (10001, 10002, ..., 10010);这条 UPDATE 带上了明确的主键条件ShardingSphere 可以精确路由到对应的分片去执行每一条更新都落在正确的位置上。第四步放到同一个事务和连接里。两阶段执行要保证原子性不能让第一步查到了主键、第二步执行前数据又变了也不能出现第一步成功、第二步失败的中间态。ShardingSphere 本身支持事务同一逻辑库下可以保证两个操作在同一个本地事务里执行。这里有个细节要特别注意如果 UPDATE 原本还带了其他WHERE条件第一步的 SELECT 必须把这个条件一并带上否则查出来的主键范围就扩大了。例如UPDATE t_order SET status 1 WHERE user_id 10086 ORDER BY create_time DESC LIMIT 10;改写后应该是SELECT order_id FROM t_order WHERE user_id 10086 ORDER BY create_time DESC LIMIT 10;不能把user_id 10086丢掉。还有排序字段选择和主键选择的问题优先用表的主键主键必须能唯一标识一行。如果表没有主键这个方案就要降级或者报错否则第二步的IN更新可能会重复更新到多行。这些细节都是我写补丁时踩过的坑。3.3 补丁实现要点解析、改写、路由三个环节配合说起来简单落到代码里要动的地方不少。我提交的补丁大致涉及以下几个模块解析层判断。在UpdateStatement的处理逻辑里补上对ORDER BY和LIMIT的识别。解析器本身已经把这两个语法元素解析出来了问题只是后续没人消费。改写层生成子查询。在 SQL 改写阶段如果检测到 UPDATE 有ORDER BY LIMIT并且路由分片数大于 1就生成对应的 SELECT 语句作为“预查询”同时把原 UPDATE 的ORDER BY和LIMIT从下推 SQL 中剔除避免二次误改。执行层串联两阶段。在真正执行前先执行预查询拿到主键列表再拼接WHERE IN (?)更新条件然后继续走正常执行流程。我当时写的核心逻辑差不多是这样示意代码不是实际补丁if (updateStatement.getOrderBy() ! null updateStatement.getLimit() ! null) { RouteResult routeResult route(updateStatement); if (routeResult.getRouteUnits().size() 1) { // 生成预查询 SQL SelectStatement selectStatement buildSelectByOrderByLimit(updateStatement); CollectionObject primaryKeys executeSelectAndGetPrimaryKeys(selectStatement); // 把 WHERE 条件替换为主键 IN 条件 replaceAssignmentWhere(updateStatement, primaryKeys); } }当然实际补丁的代码要复杂得多要处理主键元数据获取、参数化 SQL、PreparedStatement 占位符重排、路由结果缓存等一堆细节。但核心思想就是这个“先查后改”。我在提交 PR 之前其实还在两个修复方案之间犹豫过一个是上面的“先查后改”另一个是“在改写阶段给 UPDATE 加子查询”。UPDATE t_order SET status 1 WHERE order_id IN ( SELECT order_id FROM t_order ORDER BY create_time DESC LIMIT 10 );但 MySQL 对“UPDATE 子查询指向同一张表”有限制会报You cant specify target table for update in FROM clause这个方案直接出局。所以最终还是走两阶段执行的方案这算是踩坑之后换来的经验。4. 提PR的完整流程与踩坑实录4.1 从Issue到PR社区沟通的正确姿势这个 Bug 不是我拍脑袋发现的是测试环境跑批处理任务时行数不对先怀疑自己 SQL 写错了然后查资料、排查、最终确认是中间件的问题。发现之后我并没有一上来就写代码而是先去 ShardingSphere 的 GitHub 仓库搜了一圈 issue确认没有人报过同样的问题然后提交了一个 issue内容包括问题描述分库分表下 UPDATE ORDER BY LIMIT 影响行数超预期最小复现步骤表结构、分片规则、SQL、期望结果、实际结果环境信息ShardingSphere 版本、数据库类型、分片算法初步定位怀疑是改写阶段没处理 ORDER BY / LIMIT。这里有个很重要的经验提 issue 时把环境信息和复现步骤写清楚维护者才愿意认真看。我见过太多 issue 只写“分库分表后 update 结果不对”没表结构、没版本、没复现 SQL这种基本会被直接打回。在 issue 里和 committer 来回讨论了一两轮确认“这是一个值得修的 Bug”之后我才开始动手。按一般开源项目的规范我 fork 了仓库切了一个分支命名为类似fix-update-order-by-limit这种一眼能看出意图的名字。然后开始写代码和测试用例。4.2 提交PR时CI挂了问题出在测试用例的设计第一版补丁写完我本地跑了一遍自己的测试直接mvn test通过感觉稳了就推上去提了 PR。结果 GitHub Actions 的 CI 跑完红了一片。第一个问题是checkstyle 没过。ShardingSphere 的代码风格要求很严import 顺序、变量命名、注释格式都有规范。我本地没有跑完整的 checkstyle 校验直接 push 上去才发现。解决办法是补跑mvn checkstyle:check把报的问题挨个改掉。这一步不难但特别容易劝退第一次提 PR 的人——看着满屏的红色报错会有点崩溃其实耐心改一下就过去了。第二个问题暴露得更有价值我的测试用例没覆盖“无分片键”的场景。我第一版写的单元测试只验证了“带分片键命中单分片时UPDATE 正常下推”这个 happy path没有专门验证“全路由多分片时走两阶段改写”。CI 里跑集成测试时有一个场景直接暴露了改写后 SQL 的主键条件没带上排序字段导致更新结果和预期不一致。这逼着我补了更完整的测试矩阵包括单分片路由时不加两阶段改写多分片路由时正确改写为先查后改带额外 WHERE 条件时预查询要带上条件分页参数占位符LIMIT ?的预处理语句场景。这里我要强调一个经验给开源项目提 PR测试用例的重要性不亚于修复代码本身。committer 不可能你一说“我改了”就信任你他们要看到测试证明“你的修复不会破坏既有行为”。第一版被 CI 卡住反而是好事逼我把测试补全了PR 合入的阻力小了很多。4.3 社区Review关注什么兼容性与回归风险PR 提交后等了大概两天有一个 committer 开始 review。他主要问了三个问题每个都很专业第一个问题关于兼容性。“你这种改写只对多分片路由生效单分片路由保持不变那如果用户某天从单分片变成了多分片行为会不会突然变化”我的答复是这不是行为“变化”而是行为“纠正”。单分片下原来的语义就是对的多分片下原来的语义是错的改后只是让“错”变“对”不存在兼容性倒退。第二个问题关于性能。“多分片路由时多了一条 SELECT查询成本怎么评估”我的答复是对于LIMIT 10这种场景预查询走的是索引和排序比直接让 4 个分片各自扫全表取前 10 更可靠虽然多了一次网络往返但在分布式场景下数据准确性优先。更关键的是这类 UPDATE 通常是低频批处理任务不是高频在线请求性能影响可接受。committer 认可这个解释但他建议我在文档里显式标注这个行为避免用户误以为所有 UPDATE 都变慢。第三个问题关于回归风险。“如何保证两条 SQL 在同一个分片上执行的顺序可控”这里我解释了 ShardingSphere 的事务和路由模型两步操作可以通过同一逻辑库和同一事务上下文绑定到相同的数据源连接保证原子性和顺序性。整个 review 过程持续了大概一周中间改了三轮。第一轮是 checkstyle 格式第二轮是补测试用例、调整代码结构第三轮基本就是小修小补。这个 PR 最终被合入了。说实话“居然改了”这四个字里确实有惊喜的成分——毕竟这是 Apache 顶级开源项目我第一次提 PR 就有幸被合入心情还是很激动的。但复盘下来能被合入不是运气是因为从 issue 描述到代码实现、测试覆盖、review 响应每一步都按对了节奏。5. 分库分表场景下同类问题的排查手册5.1 常见SQL语义丢失案例速查表这次排查让我重新审视了一遍分库分表下的 SQL 语义一致性问题。下面这个表是我整理的“同类坑”也是我在团队内部分享过的版本你可以直接收藏参考。场景现象根因处理建议UPDATE ... ORDER BY ... LIMIT影响行数超预期更新对象错误改写阶段未处理 ORDER BY / LIMIT原样下推分片两阶段改写先查主键再精确更新或业务侧改写 SQL跨分片JOIN结果集重复行数翻倍JOIN 在分片本地执行没有全局去重与合并尽量避免跨分片 JOIN用宽表/冗余字段替代小表广播GROUP BY LIMIT分页分页偏移量不连续部分数据漏掉各分片先做 LIMIT再全局合并导致偏移错乱使用 ShardingSphere 的归并能力深分页建议用游标/keyset 分页COUNT(*)分页统计总数不准尤其带 DISTINCTDISTINCT 在各分片内去重跨分片重复值未合并确认中间件是否支持 DISTINCT 归并必要时业务侧二次去重分布式主键冲突插入数据主键重复分片内自增主键各表独立全局重复使用全局主键方案雪花算法、号段模式等没有分片键的全表查询单条 SQL 被拆成几十条下发慢查询激增全路由导致分片放大业务侧尽量带分片键条件查询考虑索引/汇总表事务跨分片部分分片提交成功部分失败本地事务不支持跨库原子性使用分布式事务AT/XA/TCC或最终一致性方案这个表格里的“根因”列其实都指向同一个核心问题分库分表后数据库自己保证不了全局语义中间件不一定能帮忙兜底。你在写 SQL 前大脑里要先过一遍“这条 SQL 会被拆成什么样子每个分片上执行什么操作结果汇总后对吗”。5.2 排查分库分表Bug的通用方法很多人遇到“分库分表结果不对”的问题第一反应是看业务代码、看数据其实最高效的路径是从中间件的执行链路找答案。我把这次排查沉淀成了四个步骤分享出来供参考。第一步最小化复现。不要用复杂的业务 SQL 去排查尽量脱敏成一个简单表、一条简单 SQL确认问题能不能稳定复现。不能稳定复现大概率是数据分布问题能稳定复现才能进入下一步。第二步核对路由结果。ShardingSphere 的 SQL 日志会打印路由信息包括 SQL 被路由到了哪些数据源、哪些物理表。也可以主动用HintManager或查看日志确认。这一步能快速区分“路由错了”还是“改写错了”。第三步关掉改写看原始 SQL。ShardingSphere 有 SQL 改写日志会打印改写前和改写后的 SQL。把改写后的 SQL 拿出来粘到单个分片的数据库里手工执行看结果是否符合预期。这是最直接的定位手段。第四步对照源码确认执行链路。如果问题出在改写那就去源码里看对应 DML 语句的改写逻辑。不要怕看源码大型中间件的代码结构其实很清晰按“解析 → 路由 → 改写 → 执行 → 归并”这条主线找很快能定位到对应模块。这个方法不仅适用于 ShardingSphere其他分库分表中间件比如 Sharding-JDBC 同类产品也基本适用因为执行链路的设计思路是相通的。5.3 我的几点心得这次给 ShardingSphere 提 PR除了修复一个具体 Bug我更强烈的一个感受是参与开源是排查深水区问题的最佳捷径。如果你只是在业务代码里排查你会停在“SQL 日志看到的改写结果不对”这一层然后想办法绕开它但当你打开源码去看为什么不对你会对整个中间件的设计思路有更深的理解以后再遇到同类问题一眼就能定位。踩过几次坑之后我也想给准备入坑分库分表的团队几句实在话第一不要迷信“中间件对 SQL 语义完全兼容”。分库分表中间件能帮你解决大部分问题但“全局排序”、“跨分片 JOIN”、“全局唯一约束”这些语义本质上是在和分布式天然特性作斗争。能避免就避免不能避免要先搞清楚中间件的支持边界。第二升级中间件版本要谨慎。这类 Bug 的修复往往伴随着 SQL 改写行为的变化是这个版本“允许但结果错”升级后可能变成“报错”或者“多执行一条预查询”。如果你的上线流程没有做 SQL 回归测试很容易被新版本的改动坑到。我当时给团队的建议是写一组“分库分表冒烟 SQL”每次升级中间件版本前跑一遍。第三发现问题主动往开源社区反馈。哪怕你提的 issue 最后被关闭了维护者的回复也可能给你指出一条新的排查思路。而且开源社区是典型的“人人为我、我为人人”——你踩过坑提出来别人就不再踩你提的代码合入后所有用这个项目的人都会受益。这个 PR 合入之后我专门留意了社区的反馈看到有用户在 release notes 里提到这个问题被修复。说实话这种“自己写的一小段代码正在被陌生人使用”的感觉比改完业务 Bug 还踏实。我给自己的后续计划也很明确再找几个分库分表场景下的“语义死角”翻一翻能提 PR 就继续提。毕竟这种边角料 Bug修一个少一个。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询