☰
分库分表Bug修复实录:UPDATE ORDER BY LIMIT语义被改写,中间件如何兜底
2026/10/7 3:52:33 网站建设 项目流程

前阵子给 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 flag=1 WHERE ... ORDER BY last_login DESC LIMIT 1000。
  • 日志/流水清理:每个用户只保留最近 100 条操作记录,清理任务常写成“删除除了最近 100 条之外的数据”,或者反过来“只更新最近 100 条”。
  • 状态流转批任务:把最近创建的待处理订单批量推进到下一状态,类似上面那个场景。
  • 排行榜/置顶逻辑:把一个分类下最新的 N 条记录置为推荐位。

这些场景在单库时代很容易跑通,一旦分库分表,SQL 原样下发就会踩雷。为什么很多人没发现?原因也很现实:

  1. 测试环境很多还是单库,或者只有 2 个分片,执行完看着“行数差不多”,没细究。
  2. 这类批任务大多在凌晨跑,行数超了也不影响线上功能,日志没有告警。
  3. 有些中间件会直接对这种 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 个分片。执行下面的 SQL:

UPDATE 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); Collection<Object> 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 can't 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(*)分页统计总数不准(尤其带 DISTINCT)DISTINCT 在各分片内去重,跨分片重复值未合并确认中间件是否支持 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,修一个,少一个。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询