很多团队的数据库分库分表,是在一次线上告警之后才匆忙开始的。慢查询堆积、磁盘IO拉满、主库连接数告急,DBA半夜拉群问要不要拆库。我参与过不下十次这样的紧急评估,也亲手把一个日订单百万级的系统从单库拆到32库64表。这一路踩过的坑告诉我:分库分表真正的难点从来不在“怎么拆”,而在“要不要拆”和“拆完之后怎么活”。这篇文章不聊概念,只聊实战——什么信号下必须拆、四种拆分怎么选、分片键怎么定、迁移怎么做、拆完之后的坑怎么填。无论你现在是正在做容量评估,还是已经在分片集群里被跨库查询折磨,这些场景大概率你都会遇到。
1. 先判断是不是真需要分库分表:三个误判与三个有效信号
1.1 最容易踩的误判:把数据量大直接等同于必须分片
先说结论:数据量大不是分库分表的充分条件。MySQL InnoDB单表存几千万行很常见,只要索引命中、写入可控,很多业务跑五六年都不会碰到本质瓶颈。我见过一张物流轨迹表,单表超过一亿行、日增两百万,但因为它只有两个查询维度且索引完全覆盖,靠着归档和分区表一直撑得很好。数据量增长真正带来的问题是慢查询的放大效应:全表扫描成本随行数线性上升,二级索引过大又导致每次写入都要维护多棵B+树,随机IO明显增加。所以第一步永远不是设计方案,而是先把慢查询治理了、把该加的索引加上、把该归档的数据归档掉。
我几乎在每个团队都见过以下三种误判:
- 误判一:用“表体积”代替“瓶颈点”。表占了多少GB不代表必须分片,关键看单条SQL的响应时间,以及CPU、IO、连接池的水位。一个10GB的表如果查询都是走主键,可能比一个1GB但索引全失效的表健康得多。
- 误判二:用峰值偶发故障代替持续瓶颈。大促带来的十分钟峰值可以通过限流、队列、缓存扛过去,没必要为了一次促销就背上永久的分片复杂度。分片是持久性架构决策,不是应急手段。
- 误判三:把索引优化失败当作分片依据。索引没建对导致的慢查询,分片之后只会更慢,因为跨片聚合的成本远高于单表扫描。先找DBA看执行计划,而不是先找架构师定方案。
1.2 真正触发分库分表的三个有效信号
- 写入吞吐超过单库上限:单主库的稳定写入一般在每秒两三千到上万事务之间,取决于SQL复杂度和硬件配置。当业务峰值需要每秒数万写入,而读写分离、批量合并、异步削峰都试过之后仍然打不上去,水平拆分就是唯一出路。这个信号在IoT设备数据上报、交易流水、消息记录场景里非常典型。
- 单库连接数被打满:连接池上限、MySQL的thread_cache、文件描述符都有限度。我遇到过数据量并不大、但几百个微服务实例共用一批连接的场景,每个实例池里留20个连接,瞬间就能把数据库的连接数吃光。这种瓶颈分表解决不了,必须分库。
- 行锁竞争和索引膨胀:热点行更新导致锁等待飙升,比如秒杀场景对同一个库存行的并发update;或者一张宽表有十几个二级索引,每次insert都要同步维护十几棵索引树。这两个问题往往是“读写混合”场景特有的,也是压测时最容易暴露的。
提示:判断要不要拆,最靠谱的方式是压测。用全链路压测把流量打到两倍峰值,观察数据库的CPU、连接数、锁等待和慢查询四个指标,比任何经验公式都准确。我见过太多拍脑袋定了32库,最后实际只用8库就够了的案例。
2. 四种拆分方式怎么选:垂直拆、水平拆、分库、分表
2.1 垂直拆分:先按业务边界切,别急着按行切
垂直拆分的本质是把一个“大而全”的库拆成多个“小而专”的库,比如把用户、订单、商品、支付拆成独立库。它解决的是模块间的相互干扰:一个后台报表查询把IO打满,会拖垮所有线上订单接口。这种拆分是成本最低、风险最小的方案,也是我在绝大多数系统里推荐的第一步。
垂直拆分要注意一个边界:不能拆完之后业务join满天飞。拆分前先做领域梳理,把真正高耦合的表留在同一个库里。比如订单表和订单明细表必须同库,但订单表和用户表可以拆开,通过冗余用户快照或RPC组装数据。判断标准很简单:如果两个表的关联查询是核心链路的一部分,就不要试图用跨库join解决,先把它们放在一起。
2.2 水平拆分:数据行按规则散列到多个分片
水平拆分解决的是单表数据量或单库吞吐的问题。核心是选一个分片键,把行数据均匀打散。这里最常犯的错是把“均匀”等同于“随机”,其实更重要的是业务查询的维度——分片键必须是你最高频、最刚需的查询条件。比如订单库按 buyer_id 分片,那么“查某用户的订单列表”就是单分片查询;如果按 order_id 分片,这个查询就要广播到所有分片,那个性能差距是数量级的。
水平拆分的数据分布有两种模式:一种是连续区间,比如按用户ID范围前1000万分到分片0、1000到2000万分到分片1;另一种是离散散列,比如取模或哈希。连续区间的优点是范围扫描友好,缺点是容易倾斜(新用户集中在尾部);离散散列分布均匀,但范围查询和排序基本做不了。工程上离散散列更主流,因为单点倾斜的代价比范围查询的便利大得多。
2.3 分库、分表怎么组合
分库解决连接和IO瓶颈,分表解决单表行数膨胀,两者可以独立也可以叠加。我见过不少团队只分表不分库,结果单表数据量是降下来了,但所有表还在同一个实例上,连接数和总IO瓶颈一点没解决。反过来,只分库不分表,单表两亿行照样把查询拖垮。下面这个对照表是我做方案评审时最常用的:
| 拆分方案 | 解决的核心瓶颈 | 典型场景 | 代价 |
|---|---|---|---|
| 单库分表 | 单表数据量过大导致的查询/写入变慢 | 日志流水、订单明细 | 同一实例内维护多张物理表,事务不受影响 |
| 分库不分表 | 连接数、IO、CPU等单库资源瓶颈 | 高并发写入、多业务模块并存 | 跨库事务与跨库join基本消失 |
| 分库+分表 | 数据量与并发双高 | 头部互联网订单、消息系统 | 复杂度最高,所有难题叠加 |
实际选型时我的经验是:连接数先打满就分库,单表查询先变慢就分表,两个都到了就一起做。别为了“一步到位”直接上最高复杂度,架构是演化出来的,不是设计出来的。
3. 分片键与路由算法:成败在此一举
3.1 分片键选错的现场
分片键选错是分库分表唯一没有补救机会的失误。拆完之后想换分片键,基本上等于全量数据重分布,业务要停很久。举个例子:如果按订单号分片,但业务里大量查询是“查某个用户的所有订单”,那每次查询都要广播到所有分片,32个分片就是32次查询再聚合;更严重的是,如果分片键本身不是业务的稳定属性——比如用手机号分片但用户可能换绑——就会导致路由错乱和数据搬迁。
选分片键有三个硬性要求,缺一不可:
- 基数足够大:区分度低会导致数据集中。用状态字段(如订单状态只有几个值)分片,等于把数据压到几个分片上。
- 分布足够均匀:按用户ID、订单号这类自增或随机值通常是好的;按城市、渠道这类枚举值很容易倾斜。
- 尽量不可变:分片键一旦作为路由依据,业务上就不能再改这个字段,否则数据归属直接错乱。
满足这些条件之后,还要过最后一关:这个键要能覆盖80%以上的核心查询。如果做不到,要么接受广播查询,要么增加二级索引映射表(比如维护一张user_id到order_id的索引表),但这是额外成本,能不做就不做。
3.2 三种路由策略的对比
路由策略决定了数据怎么落到分片上,也决定了未来扩容的代价。主流方案就三种:取模、范围、一致性哈希。
取模是最直观的:shard = shard_key % 分片数,实现简单、定位精准,但扩容时数据要全量重分布。8库扩16库还好,可以用双倍映射减少迁移量(旧数据只迁移一半);如果是8库扩10库这种非倍数扩容,几乎每个分片都要搬家。
按时间/ID范围分片适合日志和冷热分离场景,比如按月建表、按季度建库。优点是天热归档方便,直接把整个分片下线就行;缺点是当前写入集中在最新分片,天然有热点,而且各分片数据量可能严重不均。
一致性哈希是理论上最优的扩容方案,数据迁移只影响相邻节点。但实际工程里中间件支持不成熟、路由计算复杂、运维排障成本高,我很少见团队在生产环境直接用纯一致性哈希。更实用的替代方案是“虚拟分片位”,也就是常说的预分片:提前把数据空间切分成4096个逻辑分片,再把这4096个逻辑分片映射到当前物理分片。扩容时只需要调整映射关系,搬走部分逻辑分片,路由逻辑不变。
| 路由策略 | 数据分布 | 扩容代价 | 适用场景 |
|---|---|---|---|
| 取模 | 均匀 | 高,需重分布 | 数据量稳定、分片数确定 |
| 范围 | 可能倾斜 | 低,只影响新区间 | 日志、时序、冷热分离 |
| 一致性哈希 | 较均匀 | 低,只迁移相邻节点 | 节点频繁变化的场景 |
| 预分片(虚拟位) | 均匀 | 低,调整映射即可 | 中长期扩容规划 |
3.3 热点与倾斜处理
即使分片键选得好,热点也无法完全避免。比如大促时某个超级用户的订单量暴涨,他的数据集中在某一个分片上,该分片IO飙升。处理热点的思路有三层:第一层是缓存前置,把热点读挡在缓存层;第二层是分片键组合化,比如订单表除了buyer_id,还可以在分片键里拼上订单日期,让单个用户的数据也能散到多个分片上;第三层是接受局部热点,把热点分片的规格提升,或者在中间件层对热点分片做读写分离。第一层最便宜,第三层最现实。
4. 从单库到分片:一套可灰度、可回滚的迁移方案
4.1 分片数怎么定:容量评估与目标行数
迁移之前先定分片数,这不是拍脑袋,而是算出来的。先估算五年的数据总量:日增行数乘以每行平均字节数,再乘以1800天左右,加上已有数据量。然后定单分片目标行数,我一般控制在2000万行以内,这是MySQL InnoDB在普通索引SQL下还能保持良好响应的大致阈值。分片数用总行数除以单分片目标行数,向上取整到2的幂。
举个例子:一张订单表日增50万行,每行约500字节,五年总量大约是50万×1800×500字节,算下来约45亿行。按单分片2000万行,需要225个分片,向上取整到256。这时候要考虑清楚:256个分片的元数据管理、连接管理、备份恢复复杂度都不低,如果读写比不高、并发上不来,也可以先做128个分片,留一倍扩展空间。这里我特别强调:分片数宁多勿少,因为后续扩容的代价远高于初期多开几个分片的成本。
4.2 双写与数据回放:迁移的正确姿势
老库到新分片集群的数据迁移,我做过的成功方案基本都长这样:先全量导出历史数据导入新集群,同时用binlog订阅增量变更回放到新集群,最后切流量前做一次数据对账。全量导出的SQL要分批limit拉取,避免一次性大事务把源库拖死;导入新集群时要关闭唯一索引校验以外的所有约束,并且按分片并行导入。
双写是更稳妥的补数方式。在代码层同时写老库和新库,顺序是先写新、再写老,以老库为数据准绳;读请求先读新库,失败或校验不一致则回退老库。双写期一般在两到四周,长短取决于业务容忍度和对账脚本的完善程度。这期间老库仍然承担全部写流量,新库只接收复制流量,所以新库的写入压力很低,不太会出现性能问题。
对账脚本是整个迁移的定心丸。不能只比对行数,要比对关键字段的哈希值,比如对主键排序后拼接所有业务字段做MD5,分段比对。我见过行数一致但某几个字段被截断的案例,只数行数根本发现不了。
4.3 灰度切换与回滚预案
数据追平之后,流量切换不能一步到位。按流量百分比灰度,通常的节奏是5%、20%、50%、100%,每档观察至少半天到一天,重点盯支付、下单这类核心链路的错误率和慢查询。切换依据是流量网关或注册中心的路由规则,而不是改代码发版。
回滚预案要在切流量之前就写好,而不是出问题再想。最有效的回滚就是“把流量切回老库”,前提是老库链路全程保留、双写不中断。灰度期间如果发现新库数据缺失或路由错误,直接切回老库,代价仅仅是丢了双写期间的部分新库数据,老库数据是完整的。一旦全量切换完成并稳定运行两到四周,老库才可以降级为只读备份库,这时候才算真正迁移完成。
5. 分片之后的四大难题:跨片查询、分布式事务、全局主键、扩容
5.1 跨分片查询:能避免就避免
分片之后最直接的冲击是SQL能力退化了。原来一条带join的查询,中间件要么不支持,要么需要在应用层做多分片并发查询再组装。我的原则很明确:分片之后禁止跨分片join和跨分片事务,业务代码要为这个约束重构。
应对跨片查询的常见手段有三种。第一是冗余宽表:比如订单分片了,但后台需要按商家维度统计,那就单独维护一张按商家分片的统计表,源数据变更时异步同步过去,用空间换查询便利。第二是异构存储:把需要复杂检索的字段同步到搜索引擎或分析型数据库,分片库只负责基于分片键的在线查询,这是最常见的做法。第三是接受聚合:对少量低频查询,直接并行请求所有分片然后在应用层合并,前提是并发数可控、结果集不大。我见过最失败的案例是有人试图在应用层“重新实现join”,写了上百行内存关联逻辑,维护成本极高,最后全删了换成宽表。
5.2 分布式事务:能不用就不用
分库之后本地事务失效,这是让很多团队最痛苦的变化。但我要说一句可能不太好听的话:绝大多数业务根本不需要分布式事务,需要的是“最终一致”。下单扣库存、创建订单、写流水这三件事,如果设计成强一致事务,代码会很爽,但系统会很难受;如果改成本地消息表或者事务消息,订单先落库,再由消息异步扣库存和写流水,对账兜底,用户体验几乎无差别。
确实需要强一致的场景,主流方案是TCC或Saga,配合Seata这类框架落地。TCC适合短事务、对一致性要求高、资源方愿意提供预留接口的业务;Saga适合长事务、可以补偿的业务。我的建议是:能用事务消息解决就不上TCC,能上TCC就不碰两阶段提交。两阶段提交在分布式数据库场景里性能损耗太大,工程落地基本是噩梦。
注意:分片之后事务边界要重新梳理。很多团队拆完库才发现,原来一个本地事务里跨了三个分片,最后只能改业务流程。所以初始化分片时,一定要把强一致的数据放在同一个分片里,分片键的设计天然决定了事务边界。
5.3 全局主键的三条路线
单库主键自增在分片之后就废了,因为多个分片各自生成的自增ID必然冲突。全局唯一ID我做过三种方案,各有适用场景。
- UUID:实现最简单、性能也够,但36位字符串做索引会让索引体积膨胀,随机字符串写入还会导致页分裂,查询性能明显下滑,只适合对性能和存储不敏感的场景。
- 雪花算法(Snowflake):64位长整型,时间戳+机器ID+序列号,趋势递增、生成速度快,是目前最主流的选择。需要注意时钟回拨问题,代码里要做时钟偏移判断,否则可能生成重复ID。我用过的生产级实现里,都会在检测到时钟回拨时等待或拒绝生成。
- 号段模式:数据库维护一个发号表,每次取一批ID(比如1000个)缓存在应用内存里,用完了再取下一批。优点是ID是严格递增的数字,对分页排序友好,数据库压力很小;缺点是发号表本身是单点,高可用要做成多节点带步长。
| 方案 | 是否递增 | 生成性能 | 主要风险 |
|---|---|---|---|
| UUID | 否 | 高 | 索引膨胀、随机IO |
| 雪花算法 | 趋势递增 | 高 | 时钟回拨 |
| 号段模式 | 是 | 中 | 发号表单点 |
实际项目我首选雪花算法,加上统一的ID生成服务封装,业务方无感。
5.4 扩容与再平衡:预分片是唯一后悔药
分片集群最怕的就是扩容。如果是用取模路由且没做预分片,扩一个分片意味着几乎全量数据要重新分布,过程中还要保证线上可用,复杂度极高。这就是我在前面强调预分片的原因:一开始就把逻辑分片切到4096个,物理分片只有32个,扩容时只需把物理分片从32个提到64个,迁移其中的逻辑分片即可,路由算法和业务代码完全不用动。
一致性哈希也可以缓解扩容问题,但对我来说,预分片方案更好理解、更好排障,也更符合多数团队的技术栈。扩容时的数据迁移,用和首次迁移一样的手段:新分片挂到集群里,通过binlog同步接收增量数据,再回放存量逻辑分片,追平后把路由映射切换过去。整个过程可以灰度,也可以随时回退。
这个过程务必在业务低峰期做,并且每迁移完一个逻辑分片就做一次校验,不要等全部迁移完再对账,否则出错时定位范围太大。
6. 复盘:分片之后的隐性成本与止损建议
6.1 运维复杂度翻倍,而且是乘法不是加法
分片之后,备份、监控、DDL变更、数据订正,所有这些运维动作的复杂度都要乘以分片数。原来一条ALTER TABLE加个索引,现在要遍历256个分片逐个执行,还要考虑每个分片执行期间的锁和主从延迟。备份策略要从单库全量变成分片并行+一致性校验,监控面板要从一张变成一整个文件夹。我见过团队在扩容后忘了把新分片接入备份任务,直到一次故障才发现某个分片根本没有备份。
所以分片集群一定要配套自动化运维平台:分片信息注册、DDL变更流水线、备份任务自动覆盖新分片、数据校验定期执行,这些在单库时代不是必需品,在分片时代都是救命稻草。没有自动化能力就急着上分片,等于开着没有仪表盘的飞机。
6.2 研发心智负担:每一条SQL都要先想路由
分片的隐性成本里,最容易被低估的是研发心智负担。单库时代写SQL只需要想逻辑对不对,分片之后每写一条SQL都得先想:分片键带了吗?这个查询是单片还是广播?跨片排序和分页还能不能做?SQL层面的习惯改变是长期的,团队新人也需要很长的适应期。
我的止损建议有三条:一是中间件层把广播查询和跨片查询的日志单独收敛,定期review,发现一次处理一次;二是把分片键约束写进代码规范,核心SQL必须走分片键,例外查询走独立的索引表或异构存储;三是在代码评审里加入路由审查项,merge前必须确认没有漏带分片键的查询。这些听着繁琐,但能避免线上事故。
6.3 线上问题排查的链路被拉长
单库时代定位一个问题,一条慢SQL日志、一个执行计划就差不多了。分片之后,一条请求要经过N个分片,问题可能出在路由、某一个分片的负载、或是中间件层的聚合逻辑。排查链路明显变长,如果没有全链路追踪和分片维度的监控,遇到故障就是大海捞针。
我在实践中沉淀下来的最低配置:全链路Trace要带上分片号和分片键,日志里必须能看出这条SQL最终路由到了哪几个分片;数据库监控要按分片维度拆分,一个分片CPU飙高不能淹没在集群平均值里;慢查询采集要把“分片号+表名+路由键”作为聚合维度。有了这三样,绝大多数的分片问题都能在十分钟内定位。
分库分表从来不是银弹,它更像一张高利贷信用卡:额度很大,但利息很高。我现在的第一原则是:能用归档解决就不分表,能垂直拆就不水平拆,能异构存储就不跨片查询。如果已经上了分片的系统,也别急着推倒重来,把分片键、路由策略、运维自动化这三件事做扎实,比换一套中间件或者重新设计架构有用得多。最后提醒一句:如果你的系统还没有分片,但正在因为数据量而焦虑,先去做一次压测和慢查询治理,分库分表应该是一张深思熟虑之后才敢动用的底牌,而不是第一张打出去的牌。