Skip to content

有人提议分库分表,分片键选错了改不回来

订单表几千万行了,慢查询开始出现,有人提议分库分表。

你打开表结构,看到 uid(用户)和 mer_id(商户)两个字段——按哪个分?

选错了不是性能问题,是改不回来的问题。

慢查询你可以第二天加个索引解决。分片键选错,你要做的事叫「数据迁移」,
而且是在一个已经几千万行、还在持续写入的表上做。

先说清楚:这篇不是实战复盘

我现在这套系统还没到必须分的量级,所以下面写的是我把账算过之后的方案,不是「我分完了告诉你踩了什么坑」。

之所以现在写,是因为分片键这个决定必须在动手之前做对——它一旦上线就几乎不可逆,而大部分讲分库分表的文章都在讲中间件怎么配,很少讲这个选择本身。

如果你想看的是「分完之后有什么坑」,这篇给不了。这篇能给的是:动手前该怎么算这笔账。

分之前先问三个问题

第一,是不是真的到量了?

单表几千万行、有合适索引的情况下,MySQL 通常还撑得住。先确认瓶颈真的在数据量上,而不是:

  • 缺索引或索引没走上
  • 查询里有 SELECT * 拉了 46 个字段
  • 分页用 LIMIT 1000000, 20 这种写法
  • 大量的 COUNT(*) 统计跑在主库上

这几样修完能顶很久,而且不可逆的代价是零。 分库分表是不可逆的,能不做就先不做。

第二,冷热能不能先分?

订单有个特点:三个月前的订单几乎没人查。

把历史订单归档到另一张表(或另一个库),热表只留最近几个月——这个方案的复杂度比分库分表低一个数量级,效果却能解决八成问题。

归档表可以用同样的结构,查询时按时间路由。用户查半年前的订单,慢一点也能接受。

第三,读写分离够不够?

如果瓶颈主要在读(列表、统计、报表),加从库可能就够了。写入压力才是真需要分片的信号。

这三个问题都过了,再谈分片。

分片键的两难:用户还是商户

这是多商户电商特有的问题。

先别急着凭感觉选。打开订单的 Mapper 数一遍——这是唯一能告诉你「谁在查」的地方。

我数的这个:629 行,14 条手写查询。按查询方分一下:

查询方条数主过滤字段
平台后台(分页 + 计数 + 积分单)4时间 / 状态
商户后台(分页 + 计数 + 单量)3mer_id
商户经营统计(金额 / 单量 / 人数,按日期)3mer_id + 日期
用户端列表1uid
其他(社群单、商品销量)3混合

14 条里有 6 条主过滤字段是 mer_id,只有 1 条是 uid

这个分布跟直觉是反的——手写 SQL 的数量不代表调用频率
用户端只有 1 条,是因为它简单到一条就够;商户端有 6 条,
是因为后台要分页、要计数、要按日期出三种统计。

所以这张表能告诉你的是「查询形态」,不是「查询压力」。
两个都要看:形态在 Mapper 里,压力在慢查询日志和监控里。
只看其中一个都会选错。

订单表上有两个天然的业务维度:

字段谁在用它查Mapper 里的条数
uid用户查「我的订单」1
mer_id商户查「我店里的订单」6

这两个查询频率都很高,而分片键只能选一个。

还有第三件事要先知道:这张表上有三个单号。

订单号、平台订单号、以及给支付平台用的商户系统内部订单号。
再加上一个「订单等级」字段(0 平台订单 / 1 商户订单)——
一个跨店订单本来就已经被拆成主单和子单了。

这件事在选分片键之前必须搞清楚,因为它决定了你分的到底是哪张表、
以及按单号查的时候,你拿的是哪一个单号

uid

好处:用户端查询全部落单库。用户查自己的订单列表、订单详情,都是单库操作,性能最好。

代价:商户查自己店的订单要扫所有库。一个商户的订单分散在所有分片上,商户后台的订单列表变成跨库聚合查询——分片越多越慢。

mer_id

好处:商户后台快。一个商户的数据都在一个库里,订单列表、统计、导出都是单库。

代价一:用户查订单要扫所有库。用户买过 5 个店的东西,订单就在 5 个库里。

代价二,而且更严重——数据倾斜。 头部商户的订单量可能是尾部商户的几百倍。按商户分,大商户所在的那个库会先撑爆,而其他库很闲。分片的意义就没了。

我会怎么选

uid

理由有三条:

一、C 端查询频率远高于 B 端。 用户天天刷订单,商户一天看几次后台。优先保证高频路径。

二、uid 的分布比 mer_id 均匀得多。 用户量大、单用户订单量有天然上限,不容易倾斜。商户则天然是长尾分布。

三、商户端可以用别的办法补。 给商户后台单独做一份按商户维度的数据(异步同步到 ES 或单独的商户订单表)。读多写少、能接受秒级延迟的场景,用异构索引补比硬分片划算。

真实系统里已经有这个结构的雏形——主订单之外还有一张商户订单表。一个跨店订单会拆成多个商户订单。如果主订单按 uid 分,商户订单表按 mer_id 分,两边各自服务各自的查询方,这是比二选一更好的答案。

分了之后必然要面对的四件事

一、订单号里要带路由信息

分片后,只给订单号查订单,你不知道去哪个库找。

解法是把分片信息编进订单号。 比如订单号里嵌入用户 ID 的哈希后缀,解析订单号就能算出分片。

这件事必须在分片之前定好——已有的订单号没有这个信息,你得为它们单独维护一张路由表。订单号规则改起来比想象的麻烦,因为它可能已经打印在快递单上、发给用户了。

二、跨库事务基本没法用

一个订单要写主订单、订单明细、库存、流水,如果它们在不同库,本地事务就没了。

常见做法是接受最终一致:主流程保证核心数据一致,其他的靠补偿任务对齐。

这意味着你的对账逻辑要更强——因为不一致会真实发生,你需要能发现它。

三、分页和排序会变难

跨库分页是分库分表最恶心的地方之一。

「按时间倒序取第 100 页」,在单库里是一条 SQL,跨库要从每个库各取一批再归并。页数越深越慢。

实用的规避方式:C 端订单列表用「上拉加载更多」而不是页码跳转,用游标(上一页最后一条的时间 + ID)代替 OFFSET这样每次只取一批,不需要归并整个结果集。

四、十二张关联表怎么办

订单不是一张表。真实系统里跟订单直接相关的有十来张:订单明细、商户订单、退款单、退款明细、退款状态、流水记录、发票、发票明细、核销记录、分账记录……

如果只分主表,关联查询会跨库,等于没解决问题。

原则是:跟订单强绑定的表,用同一个分片键分,保证同一订单的数据落在同一个库。 让主订单和它的明细、流水永远在一起。

不强绑定的(比如全局的商品表、用户表)就不要分,做成广播表或者走单独的库。

我的判断

能不分就不分。

先把索引、慢查询、冷热分离、读写分离都做完,再评估。这几样加起来能顶很久,而且每一样都可以随时回退。

真要分,分片键选 uid,商户维度用异构索引补。

别一次分太多片。 从 4 或 8 个分片起步,留出扩容的余地。分片数改动是要迁移数据的,起步就分 64 片纯属自找麻烦。

顺带一句:这篇从头到尾没讲中间件怎么配。
因为那部分有文档,而分片键选谁没有文档 —— 它取决于谁在查你的表,
而这件事只有你自己数得出来。

常见问题对照表

现象真正的原因解法
分完之后商户后台变得极慢uid 分,商户查询跨全部分片给商户维度做异构索引或独立的商户订单表
某个库先撑爆,其他库很闲mer_id 分,头部商户数据倾斜uid 作分片键,或对大商户单独处理
拿订单号查不到订单订单号里没有路由信息分片信息编进订单号;历史单靠路由表兜底
深分页越翻越慢跨库 OFFSET 要归并全量结果改用游标分页,不做页码跳转
订单和明细对不上关联表用了不同分片键,落在不同库强关联表用同一分片键
下单偶发部分数据丢失跨库事务失效,没有补偿接受最终一致 + 补偿任务 + 对账能发现
分片数不够想扩容,发现要迁全量数据起步分片数选得太随意起步 4/8 片,用一致性哈希或预留虚拟分片

这条链路上的其他几篇

  • 《订单状态机是怎么烂掉的,以及怎么不烂》
  • 《退款不是把钱退回去就完了,说说完整的资金链路》

签名-B

大粽子