ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分表分库不是银弹:何时拆分、分片键设计与替代方案

分表分库不是银弹:何时拆分、分片键设计与替代方案 很多人一听到数据库性能问题第一反应就是“分表分库”。这个念头我太熟了当年团队里一遇上慢查询就有人拍桌子说“上分库分表”结果真做了之后数据迁移、联表查询、分布式事务这些坑一个一个冒出来反而把系统拖得更累。分表分库不是银弹它是一把手术刀只有病变到了特定阶段才该动刀切早了伤元气切晚了会出事。这篇文章就围绕“什么时候考虑分表分库”这个核心问题展开。我会从业务数据量的真实瓶颈、性能指标的临界点、拆分方案的选型逻辑、分片键的落地设计再到“不拆也能扛”的替代手段完整还原我自己在项目里做过的判断、踩过的坑和总结出的经验。不管你现在是还在单库单表挣扎还是已经被领导要求“评估分库分表方案”这篇内容都能给你一套可执行的判断依据。1. 分表分库到底在解决什么问题先别急着看数字和指标。要判断“什么时候”首先得想明白“为什么”。分表分库本质上不是在解决慢查询而是在解决两件事单机的物理上限和单一实例的并发上限。这两件事经常被混在一起谈但拆开看处理思路完全不同。1.1 单库单表的容量瓶颈每台服务器都有物理极限。磁盘再大单张表的索引、数据文件在MySQL里最终都会受限于文件系统、内存缓冲池和CPU扫描能力。一张表的数据量从百万级涨到千万级再到亿级InnoDB的B树层数会从三层变成四层每一次查询的随机IO次数就会增加。IO一旦多了延迟就开始抖。我见过太多系统在表数据量到2000万左右时即使按照索引走也开始出现批量查询超时。因为2000万行数据加上二级索引热数据已经无法完全放进缓冲池磁盘IO成了常态。这时候加索引、调参数还能撑一阵但撑不了多久。容量瓶颈不只表现在数据行数还有表宽度。一张表几十个字段varchar动不动就几百字节一行数据可能超过8KB那么一页16KB的数据页只能存一两行扫描效率急剧下降。这种“宽表”即使行数不多也会因为单页存储效率低而变慢。所以判断容量的时候要看行数、行宽、索引大小、总数据量而不是只盯着某个指标看。1.2 连接数与QPS压力第二个核心矛盾是单实例的连接数和查询并发能力。MySQL默认最大连接数一般是151虽然可以调到几百甚至几千但每一个活跃连接都会占用线程资源、内存排序缓冲、临时表空间。当QPS上来以后即使每条查询只有10毫秒线程也会快速堆积最终出现“连接数打满、新请求排队”的现场。更麻烦的是业务侧通常会用连接池比如HikariCP默认最大10个连接。如果单库单表的响应变慢每条查询阻塞200毫秒那么应用侧很快会发现连接池被占空新的数据库操作全部等待。这个时候数据库的CPU、磁盘可能还没满但应用已经“假死”了。这种问题很多人归咎于分表分库其实根子是并发能力和单点响应时间。所以“什么时候考虑分表分库”第一个判断维度不是拍脑袋定一个表行数而是要同时看容量和并发。数据量压垮的是IO和缓冲池并发压垮的是线程和连接池。两者只要有一个逼近临界就需要开始评估了。1.3 分清“该不该分”和“能不能分”“该不该”和“能不能”是两回事。很多系统确实到了该分的时候但业务模型不允许。比如一张订单表天然带订单号、用户ID、商家ID你可以按用户ID分也可以按订单号分但如果核心查询是按商家维度统计按用户ID分片就会让商家查询变成全表扫描这就不“能分”。所以在考虑时机时必须同时评估业务的查询维度是否支持拆分。我见过一个糟糕案例把用户表按用户ID取模分成了64张表登录和用户详情都走得好好的结果后台运营需要按手机号查用户每次都遍历64张表再合并查询时间从几十毫秒变成了几秒。这就是典型的“该分但没设计好分片键”的问题。所以不要单纯因为数据量达标就动手先画出核心业务流程的所有查询条件看看有哪些查询必须带着分片键哪些是可以容忍跨分片聚合的。2. 什么信号出现才需要认真考虑有了底层逻辑再谈具体阈值就顺理成章了。但阈值不是死的不同业务容忍度完全不同。电商大促期间订单表一天涨几百万和SaaS后台操作日志一天涨几十万压力模型完全不一样。下面我给出我自己习惯使用的一组参考信号这些信号不是绝对红线但达到之后必须认真做容量评估。2.1 数据量指标单表行数超过2000万到3000万并且还在以每月超过10%的速度增长这是一个强信号。这里的“超过10%”很关键因为如果一个表已经3000万行但不再增长可以通过归档、清理来治理不一定非要分表。还有总数据量达到物理机内存的30%以上这也是一个容易被忽略的信号。InnoDB缓冲池一般设置为物理内存的50%到70%如果一张表的数据量加索引已经远超缓冲池那么热数据命中率就会下降随机读性能会明显变速。当你在监控里看到磁盘读IOPS持续飙高而缓存命中率低于95%就说明数据热集已经放不下了。2.2 性能指标性能维度的信号更直观核心接口在数据库层的P95延迟超过300毫秒或者单条简单主键查询在索引存在的情况下仍然超过50毫秒。另外一个典型特征是数据库慢查询日志里出现大量“扫行数超过几十万”的SQL即使这些SQL最终命中了索引索引回表的随机IO也扛不住。还有一个很容易被忽视的指标数据库的CPU使用率。如果CPU长期维持在70%以上且是由SQL扫描导致而不是由并发计算导致的那说明数据布局已经不合理了。这时候即使分库分表不是唯一解也至少要优化数据分布了。2.3 业务指标业务层面的信号往往比技术指标来得更早。比如业务方开始频繁抱怨“报表查询越来越慢”后台导出功能成为数据库压力最大的任务又比如核心表的数据保留策略和业务需求的冲突越来越明显——“数据不能删但也不能慢”。当产品经理开始提“什么时候数据量大会卡”这种问题的时候你就该明白技术债已经暴露到业务侧了。另一个业务信号是数据增长率发生阶段性变化。比如产品切入新市场注册用户量翻倍比如新功能上线导致订单量暴涨。这种变化会让原本还远的数据量指标在半年内逼近临界。如果有这种规划应该提前一个季度做拆分设计而不是等卡了才救火。2.4 制定自己的红线清单我建议每个团队都有一张自己的“分表分库红线清单”而不是复制网上的经验。我的团队目前用的是这四条单表超过2000万行且月增长超过10%单实例QPS持续超过5000或者连接数经常接近最大值核心查询的P99延迟超过500毫秒且持续一周单表数据量加索引后总大小超过内存缓冲池的50%四选二就要进入评估流程四选三就必须给出方案和上线时间。这套红线不一定适合所有场景但它让团队不再靠感觉决策尤其是当老板问“为什么要分表分库”的时候你能拿出数据来回答。3. 拆分的几种主流方案怎么选到了评估阶段最纠结的事情就来了垂直拆分、水平拆分、分库分表一起上到底怎么选。我的建议是不要一上来就想到中间件水平拆而是从业务的数据模型出发一层一层剥。3.1 垂直拆分把宽表变瘦把频繁访问的表拆开垂直拆分的核心是按业务模块把字段拆到不同表里。比如用户表里既有登录认证信息又有用户资料、积分、标签等字段每次登录只需要认证字段而运营后台查询需要资料字段两个场景的访问模式完全不同。把认证信息和资料信息拆成两张表可以减少单行数据宽度提高数据页缓存效率。垂直拆分也包含“分库”也就是把不同业务域的库拆开比如订单库、用户库、商品库。很多系统一开始都在一个库里表之间通过外键约束和联表查询互相依赖等业务大了管理权限、备份恢复、资源隔离全部混在一起。把核心业务域拆成独立的库运维会清爽很多。但垂直拆分有个前提跨库联表需要业务层自己拼装或者用冗余字段解决。如果团队没有服务层封装能力拆了库之后运营后台查询会非常痛苦。我见过一个系统把订单库和商品库拆开结果报表统计需要同时查两个库最后还是用同步工具把数据合并回一张宽表才解决等于绕了一圈。3.2 水平拆分把数据行打散到多个实例水平拆分就是把同一张表的数据按某个规则分散到多张表或多个库中。典型方式是取模比如用户ID对64取模数据分布到64张表。取模的好处是规则简单、数据分布均匀坏处是一旦扩容比如从64变成128所有数据都要重新分布停机迁移成本极高。另一种常见方式是按照时间范围分表比如订单表按月分表每个月一张子表。时间分表非常契合归档和数据生命周期管理但热数据往往集中在本月或近三个月单表的数据量增长并不均匀而且需要定期建表。还有一种是范围分片比如按照用户ID的范围分成0到1000万、1000万到2000万。范围分片的好处是扩容方便加一个新范围即可但容易产生数据热点——新用户大量涌入时最高的范围段会忙得不行。真正具备弹性的是一致性哈希。它把数据均匀挂在哈希环上扩容时只需要迁移部分数据。很多中间件如ShardingSphere、Vitess都支持一致性哈希。但要注意一致性哈希的分片键一旦确定查询如果不是带分片键一样要广播。3.3 中间件选型不是万能也没有银弹市面上常用的分库分表中间件有三类客户端代理型、服务端代理型和云数据库自带的分布式能力。客户端代理型如ShardingSphere-JDBC以jar包形式嵌入业务应用SQL解析和数据路由发生在应用层。优点是部署简单、性能损耗低缺点是强侵入业务代码而且多语言支持麻烦。服务端代理型如MyCat、ShardingSphere-Proxy、Vitess独立部署一个服务应用连接它就好像连接普通数据库一样。优点是业务无侵入切换时应用只要改一下数据库连接地址缺点是增加一层网络转发大约会增加10%到20%的延迟而且代理本身变成高可用组件需要单独运维。云厂商的分布式数据库如OceanBase、PolarDB-X也支持自动分片业务侧不需要感知。但如果服务器都在自建机房云数据库的方案就得重新评估。我的个人建议是如果团队小、只有Java体系可以先用ShardingSphere-JDBC因为它的分片策略和读写分离都成熟踩坑资料也多。如果团队有独立的数据库中间件运维能力再考虑Proxy模式。最重要的一点是不管选什么都要预留从中间件脱离的接口不要让自己被某一种方案绑架。4. 实战中的分片策略与扩容设计等真到了动手做水平拆分的时候最核心的工作不是写SQL而是设计分片键和扩容路径。这一步直接决定了后续两年你的运维是安心睡觉还是天天救火。4.1 分片键选对了事半功倍选错了天天全表扫描分片键的第一原则是尽量贴近核心查询维度。一个订单系统买家端最主要的功能是查看“我的订单”那么按买家IDuser_id分片就能把每个买家的订单集中到一个分片中查询只需路由到一张表。但卖家端需要查看“店铺订单”如果按user_id分片卖家查询就会打到所有分片上。这个时候要做取舍要么卖家端允许使用搜索引擎或宽表要么将订单同时复制一份按seller_id分片的冗余表。实现冗余表的方式很多主流做法是业务在写订单时双写或者用消息队列异步同步。双写有数据一致性风险异步同步有延迟最好的方式是用支持分布式事务的数据同步组件但会增加复杂度。我看到有不少系统是先用搜索引擎存卖家视角的订单数据后台查询全走搜索引擎MySQL只服务核心流程。这样虽然增加了一个组件但逻辑简单很多。分片键的第二原则是数量量级要足够大避免数据倾斜。用户ID天然平均分布按它取模基本均匀。但如果是按地区、按渠道分片就容易出现某几个大渠道独占大量数据最后热点分片被打爆。比如按手机号运营商分片移动用户占六成这个分片一定是热点。第三原则是分片键一旦确定尽量不要改。那么涉及用户ID和订单号的关系就需要通过“路由表”来转换。有些系统用订单号里的分片位比如订单号生成时包含user_id取模后的分片号这样只要一条SQL能同时带订单号和分片号就能精确定位。这种方式很有用但要求全局发号器和订单号规则从一开始就设计好如果已经跑了几年的老系统就非常难改。4.2 取模、时间还是范围根据业务增长模式来定如果业务增长是线性且可预测的比如用户量每年翻一倍那么取模分片加预期容量设计是稳妥的。设计时直接按未来三到五年的峰值规划分片数比如需要支撑1亿用户每个分片3000万行那就分32片而不是16片。不要指望快速扩容。一旦容量规划偏保守后续扩容就是一场灾难。如果业务有典型的时间周期性比如交易集中在某几个月那么时间范围分片更合适。它天然支持“热月访问更高”配合归档可以把冷数据从主库剥离。但时间分片有个致命问题跨月查询会被拆成多次比如查询“近三十天订单”就要横跨一个月末到下个月初需要应用层做聚合。好在大多数统计需求能接受稍微慢一点的聚合查询。如果业务活动导致流量突发比如秒杀那么分片键也不能简单取模。秒杀场景的写入集中在同一个商品上按商品ID分片会形成单点写。这个时候需要考虑把“库存扣减”和“订单流水”分开库存用单独的缓存或数据库原子操作订单流水可以按用户分片。4.3 扩容不是“加几张表”那么简单很多人以为分表之后扩容就是修改取模基数把64变成128然后重新hash一遍。但真这么操作过的都知道这需要停机或双写迁移而且很容易在迁移过程中丢数据、乱序、唯一键冲突。更平滑的扩容方案是“双写影子迁移”。影子迁移的步骤是先建立新分片集群保持旧集群继续服务同时业务写入时双写旧和新再对旧的存量数据做批量迁移迁移过程中通过校验工具对比新旧数据差异等到数据追平且验证通过后切换读流量到新集群最后停掉双写。这个方案听起来复杂但它可以做到几乎不停机而且每个步骤都可回滚。市面上很多数据库同步工具比如DataX、Canal都支持这类用途但需要业务代码配合。另一种思路是“使用一致性哈希预留扩容槽位”。比如分片键先用一致性哈希环环上每个节点对应一个物理库。扩容时新加一个节点只需要迁移节点附近部分数据无需全量重新分布。这种方式比取模要平滑得多但实现起来比取模复杂需要中间件支持而且查询路由也要考虑虚拟节点。还有一条省心路径在确定数据增长之前不要做物理分片而是做逻辑分片。比如在一套MySQL实例里建多张结构相同的普通表业务代码通过一个路由函数决定读写哪张表。这种“表级分片”不需要中间件只需要代码里维护一个分片规则。它的极限是单实例的容量和连接数但很多系统其实压根没到那个极限先用逻辑分片观察半年等数据量真上来了再过渡到真分库成本会低很多。4.4 分布式环境下的ID生成和事务边界分片后第一个会遇到的问题就是主键唯一性。单表自增主键不行了因为多个分片各自生成自增ID会冲突。常见方案是使用雪花算法生成全局唯一ID。雪花ID包含时间戳、机器号、序列号可以保持趋势递增而且不依赖数据库。在此基础上如果想让订单号自带分片信息可以在雪花ID的中间插入分片位这样通过订单号就能反查分片位置。事务问题也绕不开。原来单库一份事务拆成多库之后就变成分布式事务。如果业务能接受最终一致性优先用本地消息表或者事务消息来处理。如果必须强一致X/AT分布式事务的代价都很高性能跌得厉害。我见过一个团队把一个下单流程拆成跨三个库的事务用Seata实现强一致结果接口延迟从200毫秒涨到800毫秒大促根本扛不住后来又改回了串行化任务加补偿。所以拆分的边界要配合事务边界尽量把一份业务强一致操作放在同一个分片内这就是为什么按用户ID分片那么重要——一个买家的下单相关操作都在同库里就不需要分布式事务。5. 别急着动刀先看看能不能不拆我在前文提了很多“什么时候该分”但最后还得泼一盆冷水有大量系统根本轮不到分表分库只需要把基础治理做完性能就能翻倍。盲目拆分不仅浪费人力还可能把好好的业务模型搞复杂。5.1 索引、缓存、归档三板斧先做SQL审计。把慢查询日志抓出来看看是不是有大量没有命中索引的查询。我辅助排查过一个项目单表3000万行线上各种慢查询结果发现是几个业务SQL在where条件里对索引字段做了函数运算导致索引失效。把函数去掉用冗余列替代查询从1秒降到十几毫秒压根不用分表。缓存也很有效。热点数据比如商品信息、用户资料延迟敏感但更新频率低用Redis挡一层数据库的读压力能降一半以上。但缓存要注意穿透、击穿、雪崩以及缓存和数据库的一致性。归档更是立竿见影。很多订单业务表里保存着三年前的历史数据这些数据几乎不会被核心链路访问但一直占着表空间、拖慢全表扫描和统计查询。把超过一年且已完结的订单迁移到历史库主表数据量骤降性能立刻恢复。归档不用停机可以用定时任务分页搬数据搬完后校验再逻辑删除或物理删除。5.2 只读副本和读写分离如果瓶颈在查询并发而不是写入那么先做读写分离比分表分库简单得多。利用MySQL主从复制把读流量分发到多个从节点每个从节点承担一部分查询压力。只要主库的写入不是瓶颈一主两从就能扛很大的读并发了。读写分离要注意主从延迟问题。刚写完的数据去读从库可能还没同步到。对这种场景可以强制读主库或者等待一段时间再读。也可以使用中间件的读写分离功能比如ShardingSphere和MyCat都支持。5.3 独立搜索和数据分析引擎如果慢查询主要来自后台复杂的条件检索和报表统计把这块能力迁移到Elasticsearch或者OLAP引擎而不是硬扛MySQL是更聪明的选择。把订单数据通过Canal同步到ES后台所有模糊查询、组合筛选都在ES上完成MySQL只负责核心事务。我做过一个项目后台查询原本每秒查三次MySQL每次扫描几十万行迁移到ES后MySQL负载直接降了七成。这个方案的难点是同步链路的稳定性和数据一致性但相比分库分表它的业务侵入性小得多不影响原有主链路。长期来看MySQL保持轻量搜索能力交给更擅长搜索的组件符合“专业的工具做专业的事”。5.4 我个人的取舍建议如果让我给一个决策路径我会这样选择单表几百万到千万级别SQL有优化空间先优化。千万到两千万级别读写分离加缓存同时做数据归档。两千万以上且增长快核心查询必须带某个维度考虑水平分表。超过单实例并发极限必须水平分库。如果已经有多个业务域在同一个库先垂直拆分。这个路径不是绝对的但能避免过早拆分的悲剧。你甚至可以一半表用分表、一半表不分不用追求所有模块都搞成分片模式。分片的范围越小系统越稳。6. 分表分库上线后的运维要点一旦真的上了分库分表后续运维就和单库时代完全不同了。这里整理几个我踩过坑的运维要点希望能帮你少走弯路。6.1 监控必须覆盖分片维度的健康度单库时代你只需要盯一个实例的QPS、CPU、连接数。分片之后每个分片的访问量可能不均衡某个分片悄悄变成热点你都不知道。所以必须针对每个分片建立独立的监控大盘。除了常规指标还要注意“分片请求分布图”——某张分片表的QPS占全网比例如果持续超过20%就要排查数据倾斜了。我遇到过“取模分片后仍然倾斜”的情况原因不是取模算法问题而是某个大客户贡献了多数订单或者某些用户ID是内部测试账号导致单分片数据量畸高。这时候需要单独治理热点分片比如再增加细粒度分片或者把热点客户数据单独迁移。6.2 数据迁移与校验流程要固化分表分库的项目必然伴随数据迁移。千万不要用“跑一次SQL然后看行数一致”来验证。必须做全量字段级别的哈希校验。常用的做法是把新旧表的每一行按主键排序计算该行所有字段拼接后的CRC32汇总成每个分片的校验值对比两边校验值是否一致。有差异的记录再单独比对和修复。迁移过程中还要注意自增ID的起始值和步长问题。分片之后如果用AUTO_INCREMENT不同步调整容易出现重复主键。在迁移测试环境就要把自增偏移量配好别到生产才慌忙处理。6.3 分布式事务和链路追踪的落地上线分库分表后一个业务操作涉及多个库的概率会提高。即便你把核心事务收敛到了同一个分片仍然有部分统计类需求跨分片执行。这时候每个数据库操作的链路号必须贯穿始终。我建议在业务系统统一使用traceId并在数据库访问层打印路由到的分片号。排查问题时知道“这条SQL路由到了哪几个分片”比什么都重要。另外跨分片的聚合查询未必非要在应用层做很多中间件支持将分片结果合并排序、分页。但要注意全局分页如果数据量极大会成为性能瓶颈。比如“ORDER BY create_time LIMIT 10000, 20”中间件需要把每个分片都拉取10020条再合并排序代价很高。遇到这种查询要么限制深分页要么改为“基于上次ID的游标翻页”要么把数据同步到ES里检索。6.4 备份恢复要面向分片做重组分片之后每个库的备份是独立的。万一要从备份恢复一个分片的数据不能只恢复该分片还要确保业务整体的一致性。建议备份时通过全局时间戳来对齐各分片恢复时在所有分片恢复到同一时间点。这个细节很容易被忽略等真出了故障才发现各分片的备份时间差了几分钟数据根本对不上。如果用的是物理备份还需要确认备份的binlog位置恢复后可以在从库上继续追日志。分片越多备份恢复的演练越要勤快。最好每季度做一次随机分片的恢复演练否则关键时刻大概率手忙脚乱。7. 最后再聊点实在的写了这么多还是想强调一句分表分库是一个“听起来很高级做起来很麻烦”的工程决策。我在实际项目里见过太多因为盲目跟风而失败的例子也见过不少靠着合理的容量预估和分片设计稳定支撑亿级流量的系统。两者的差别往往不在于用了多牛的中间件而在于做决策之前有没有把业务访问模型彻底想清楚。我个人现在的习惯是每次接到“要不要分表分库”的评估需求先要求团队提供一份完整的核心链路查询清单包括查询条件、频率、是否必须带分片键、数据增长速度、高峰期QPS、单条SQL扫描行数。拿不到这份清单我就不做方案。因为所有合理的拆分都源于对业务访问模式的尊重而不是对数据量的恐惧。如果这篇文章能让你在下次面对“分表分库”四个字的时候多问一句“真的到了那一刻了吗”而不是急着找中间件文档那我觉得写这几千字就值了。最后留个小建议先把单表的索引优化、归档、读写分离三板斧做实了你会发现很多“必须分表”的结论其实是被慢查询吓出来的。真到了非分不可的那天你也已经提前把路由设计、ID生成、迁移方案都预演过好几轮一点不会慌。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进