ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分库分表核心方案与实战避坑指南

分库分表核心方案与实战避坑指南 在系统上线第三年的某个晚上我第一次在一个生产事故里真正理解了分库分表四个字的分量。当时核心订单表已经攒了上亿行数据一条简单的按时间段的统计查询慢查询日志里直接出现了十几秒的执行耗时数据库CPU报警声和运营的投诉电话几乎同时响起来。那天之后我们才把单库单表的方案推翻走上了分库分表这条路。回头看这几乎是每一个发展到一定规模的业务系统都会撞上的墙。围绕这个主题我想把这几年在真实项目里用过的方案、做过的选型对比以及踩进去又爬出来的坑尽量完整地整理出来特别是那些在官方文档里查不到、只有上线之后才会暴露的细节。1. 单库单表为什么会撑不住——瓶颈信号与拆分的本质判断先说一个经常被混淆的概念很多团队一说数据库变慢就急着拆库拆表但实际上分库分表不是银弹而且每一次拆分都要付出极大的代价。所以动手之前先要搞清楚单库单表到底是因为什么撑不住了。1.1 三个最典型的瓶颈信号第一个信号是存储容量告警。一张核心业务表的数据量到了千万级甚至亿级之后即使做了索引优化B树的层级也会加深磁盘IO的随机读写成本上涨备份和恢复的时间会拉长到难以接受。第二个信号是写入并发打满。单库的并发写入能力受制于磁盘、锁和日志机制当每秒写入量到了几千甚至上万的时候会产生明显的锁等待和磁盘IO瓶颈应用侧的写入超时开始频繁出现。第三个信号是查询性能劣化。即使命中索引当单表数据量过大索引维护的成本也会拖累整体查询速度更不用说那些没法完全命中索引的分析型查询。1.2 拆分的本质是把压力平摊到更多节点上分库分表的本质是在不改动业务逻辑的前提下把原本由一台数据库实例承担的数据存储、写入并发和查询压力横向或纵向地分散到多个节点上。用个不太严谨但好理解的类比单库单表就像一家店只有一个收银台客流小的时候完全够用客流一多队伍就排到门外了分库分表就是多开几个收银台甚至把店面分成几个区域各自排队每个收银台只管自己那部分客人。但这里有个关键判断要先做如果你的瓶颈只是慢查询多、索引设计差、SQL写法糟糕那首要任务是调优而不是拆分。我见过不少项目几百万行的表就嚷嚷着要分库分表结果加了几个复合索引、改了分页写法之后问题消失了大半。分库分表的正确触发时机是在SQL已经优化过的前提下单实例的容量、并发能力或IO吞吐依然成为瓶颈。1.3 分库分表与读写分离不是一回事很多刚接触这块的读者会把读写分离和分库分表混为一谈。读写分离解决的是读多写少场景下的读压力分摊主库负责写入从库通过复制同步数据后承载查询流量而分库分表解决的是数据量大、写入并发高场景下的容量和写入能力扩展问题。两者可以结合使用拆分之后的每个分片再搭只读副本但它们的本质目标和实施复杂度是完全不同的。在评估自己要不要做分库分表之前先把这一层分清楚能避免很多无谓的设计过度。2. 五套主流拆分方案逐项拆解选型逻辑与代价分析这五套方案本身没有绝对的好坏选型要看数据特性、访问模式、团队运维能力和业务容忍度。我把它们按照先垂直后水平、先分表后分库的建议实施顺序来拆解。2.1 方案一垂直分库——按业务域拆开垂直分库是把原本耦合在同一个库里的不同业务域的表拆到不同的库里。比如把用户相关表放进user库订单相关表放进order库商品相关表放进product库。每个业务域有自己独立的数据库实例互不共享一个实例的计算和存储资源。这么做的核心驱动力是很多业务系统的性能问题其实来自互相干扰。一个用户中心的写操作把磁盘IO占满了结果订单服务跟着遭殃某个后台批量任务的慢查询拖累了主业务。拆开之后隔离性立刻变好每个业务域可以按自己的节奏做优化和扩缩容。但它解决不了单表数据量过大的问题。它只是把一堆表分成了几堆表某个业务域的核心表该涨到几千万行还是几千万行。所以我们通常把垂直分库当作分库分表的第一步先把不同的域隔离干净再评估单个域内部是否还需要继续拆。它的成本主要在应用侧的改造原本一个连接串变成多个连接串跨库的连表查询基本要断掉事务也从单库事务变成了需要分布式事务处理的问题。2.2 方案二垂直分表——冷热字段拆开垂直分表是在同一张表里把经常查询的字段和又大又冷门的字段拆开。最典型的场景是大字段问题一张表里存了文章内容、图片URL列表、JSON扩展字段这些字段动辄几十KB甚至更大而列表页查询其实只需要标题、作者、发布时间、阅读数这几个字段。由于MySQL的InnoDB引擎行级存储方式即使你只select两个字段读取单行时也会把这行的所有字段数据从磁盘拉起来数据量超出buffer pool时这种放大效应很明显导致列表查询变慢。垂直分表的做法是把表拆成主表和扩展表。主表保留核心高频字段结构紧凑行宽小相同的数据量下占用的页更少查询效率显著提升扩展表保存大字段和低频字段通过相同的主键ID与主表关联只在真正需要详情的时候才去访问。需要特别提醒的是垂直分表不是数据量大的必选项而要看字段本身的特征。如果一张表绝大多数字段都是会被频繁查询的小字段拆分的收益就很小反而增加了写入时维护多张表的复杂度。这个方案的代价相对可控应用侧只需要改SQL映射但跨表组装数据的逻辑要写清楚。2.3 方案三水平分库——按路由分散到不同实例水平分库是同一个业务表的数据按照某种路由规则分散到多个数据库实例上。比如用户表按照用户ID的哈希值取模把数据分布到4个库里每个库都保存一部分用户的完整数据。这个方案直接把写入并发平摊到了多个实例上容量也成倍扩展。水平分库的典型触发场景是写入并发成为瓶颈。单实例的写入能力到了天花板加索引、调参数都解决不了本质问题这时候需要让N个实例同时分摊写入压力。但水平分库是所有方案里面对应用侵入最大的一种原来所有的SQL都是单库单表操作现在要根据路由字段决定去哪个库执行跨分库的查询、事务都需要复杂的中间层或业务代码来处理。这里面有一个非常重要的选型点路由字段和业务查询模式的匹配度。如果这个表最核心的访问模式都带上了同一个字段比如订单表的所有核心查询都带上用户ID那么按照用户ID水平分库是合理的但如果有大量查询是基于订单号或商家ID的没有携带用户ID这些查询就必须路由到所有分片再汇总代价非常高。这一条决定了水平分库方案的成败后面会详细展开。2.4 方案四水平分表——单库内按规则拆成多张表水平分表是同一个库内把一张表的数据按规则拆成多张表。这通常是在数据量大但并发还没到能把单库打垮的时候采用或者与水平分库组合使用。比如先分4个库每个库内部再把订单表拆成8张子表最终逻辑上的订单表被分到了32个物理分片上。这种组合模式是业内最常见的高并发大数据量方案。水平分表的拆分规则通常有三种。最常用的是哈希取模用某个业务ID的哈希值对分片数取模得到分片编号。这种规则实现简单、数据分布均匀适合等概率访问的场景。第二种是时间范围比如按年月把订单表拆成order_202401、order_202402适合强时间序列的数据按时间窗口归档和清理非常方便但容易产生热点当前月份的表永远是流量高峰。第三种是映射表维护一个业务ID到分片号的映射关系路由最灵活但映射表本身可能成为瓶颈也增加了查询开销。这里有个高频疑问如果只是数据量大了但并发还没有打满能不能只分表不分布可以。比如单表数据一亿行但写入峰值只有每秒几百这时候在单库里拆成多张子表减少了单表的索引层数和锁竞争查询性能就能恢复。但如果写入并发本身已经很高单库实例的磁盘和日志已经扛不住那就必须分库只分表解决不了物理实例的瓶颈。2.5 方案五混合拆分——水平与垂直的组合拳所谓的混合拆分就是在同一个系统里同时运用上面几种方式。比如第一步垂直分库把订单、用户、商品分别隔离第二步对订单库做水平分库拆成4个实例第三步在每个分片内做水平分表按时间或哈希再拆成8张表第四步对订单表本身做垂直分表把扩展字段拆出去。大型互联网系统的核心链路基本都是这个套路。但它也是成本最高的方案对中间件能力、运维体系、应用改造的深度都有很高要求。混合拆分的选型逻辑是先看数据量和写入并发各自处在什么水平再决定动用几层手段——并不是方案用得越多越高级而是刚好够用就好。我见过一个反面案例某业务每天订单量不到几千团队为了追求架构先进性直接上了混合拆分结果团队每天光处理分片路由和跨分片查询就焦头烂额性能提升微乎其微维护成本却翻了几倍。分库分表是解决瓶颈的手段不是KPI指标。下面用一张表对五套方案做对比方案解决的问题对应用侵入度典型代价适用场景垂直分库业务域间资源竞争中跨库查询受限、分布式事务多业务域耦合、互相干扰明显的系统垂直分表行宽过大、大字段拖慢查询低多表维护、组装查询单表存在明显冷热字段分离需求水平分库写入并发和容量瓶颈高跨库查询汇总、路由改造单实例并发写入达到上限水平分表单表数据量过大中跨表查询、路由逻辑数据量大但实例没到上限混合拆分综合容量并发字段问题很高运维和研发成本全面上升核心链路高并发海量数据3. 切分键决定生死路由与扩容的关键设计不管是水平分库还是水平分表都绕不开一个词——切分键也叫分片键。这是分库分表方案里最重要的一个设计决策选错了后面整个架构都会被拖垮。3.1 切分键的选择逻辑跟着核心查询走切分键的选择铁律是选绝大多数核心查询都会携带的条件字段。举例来说一个交易系统里的订单表绝大多数查询都是用户在自己的订单列表里翻数据SQL必然带where user_id ?这时候user_id就是天然的好切分键按用户维度把订单拆到各个分片上用户查自己的订单只需要路由到一个分片就能拿到全部数据效率最高。但现实中经常会遇到两难订单系统除了按用户查客服后台还要按订单号查、按商家查。这些查询没有携带user_id理论上要遍历所有分片再合并结果。如果这种查询的量和时效要求都不高可以让它遍历分片后汇总但如果这种查询是高频核心路径那就要考虑是不是该换切分键。有一个折中做法是基因法设计订单号的时候把用户ID的某些位嵌入订单号里面让订单号本身携带用户ID的特征这样按订单号查询时也能反推出用户ID从而定位到分片。这种设计需要业务建号阶段就配合属于偏高级的做法但对查询模式多样化的系统非常有效。3.2 路由算法的常见实现与细节确定切分键之后路由算法决定了请求最终落到哪个分片。最常见的是哈希取模shard hash(user_id) % shard_count这个方案在shard_count不变的稳定期很好用但有一个著名的坑——扩容时数据迁移痛苦。假设原来有4个分片数据按hash(id)%4分布现在要扩容到8个分片规则变成hash(id)%8你会发现绝大多数旧数据的路由结果都变了。原来在分片0的数据重新计算后可能落在分片4或分片6。这就意味着几乎全量数据都需要重新分布扩容过程等价于一次大规模数据迁移。应对策略有三种。第一种是翻倍扩容分片数从4翻到8然后利用二进制位的特性减少迁移范围经典做法是取模从%4调整为%8原本落在某个分片的数据扩容后只会在该分片和该分片N之间迁移N为原分片数迁移量大约50%而不是100%。第二种是一致性哈希把数据和节点都映射到哈希环上每个节点负责环上的一段区间扩容时只影响相邻节点的一部分数据迁移量显著降低代价是路由计算复杂度略高且节点较少时需要用虚拟节点来平滑数据分布。第三种是干脆重构索引表或映射表把ID到分片的映射关系维护到一张路由表里查询时先查路由表再路由到具体分片迁移数据时只改映射关系但增加了一次额外的查询开销且路由表本身要有高可用设计。3.3 选切分键时要避开的隐蔽坑数据倾斜就算选定了切分键并实现了路由还有一个隐蔽的问题——数据倾斜。哈希取模在理论上能把数据均匀分布到各个分片但如果业务数据本身就集中在某些哈希值上分布就会变得很不均匀。举一个真实例子某做B端商户订单的系统按商户ID做取模分片结果少数超大型商户的订单量占了总量的三成对应的分片数据量和访问量都远超其他分片拆分之后那几个大商户所在的分片单独来看性能依然吃紧。处理数据倾斜的思路是二次拆分大客户。在路由规则里加一个特殊逻辑识别出超大商户单独为其分配专用分片或子分片大商户的数据不再参与普通取模。这个逻辑要可配置、可动态调整最好在路由中间件里做成一个热点键映射表。另外如果切分键选的是时间字段也要警惕时间热点——秒杀活动期间所有数据都往当前时间分片里写其他时间分片闲着这种局部热点比基于ID的哈希分布更容易被忽视。4. 拆分后的连锁反应分布式ID、事务与查询如何收尾分库分表做完之后原来单库单表时代理所当然的东西全都变得不理所当然了。这里面最典型的三件事是主键ID怎么生成、跨分片事务怎么做、跨分片查询怎么处理。4.1 分布式ID不能再用数据库自增主键单库表的主键自增在分库分表之后立即失效。如果每个分片都从1开始自增多个分片会生成相同的主键而且无法被用来唯一定位一条数据。分布式ID需要全局唯一、尽量有序因为很多业务查询和索引构建依赖ID的有序性、且生成性能要高。目前最主流的方案是雪花算法Snowflake。它的核心结构是一个64位的long1位符号位 41位时间戳毫秒级够用约69年 10位机器ID可以拆成机房ID和工作进程ID 12位序列号同一毫秒内可生成4096个ID。单机一毫秒能生成4096个ID完全能覆盖绝大多数业务的并发需求。它生成的值是趋势递增的对数据库索引友好。还有一个在业务系统里用得很多的方案是号段模式从数据库里取一段ID区间比如从ID发号表里取[10000, 20000)这一批缓存到应用内存里用完后再次请求取号。这个方案实现简单、性能很好、ID是有序递增的适合那些对ID趋势有序有硬性要求的场景比如一些金融系统的流水号。缺点是应用重启时会浪费一段未用完的号段且发号表本身要保持高可用。对于不要求有序、只要求唯一的场景也可以直接用UUID但因为UUID是字符串且完全无序在数据库里做索引会导致页分裂频繁写入性能下降。除非业务对ID没有可读性要求且写入并发不高否则不建议把UUID当主键用在拆分后的大表上。4.2 分布式事务从ACID到最终一致性单库事务是ACID的写库要么全部成功要么全部回滚。分库之后一个业务操作可能涉及多个分片经典的本地事务不再可用。比如下单时要扣库存库存表在分片A、生成订单订单表在分片B、更新用户余额用户表在分片C这三个操作不可能在同一个数据库事务里完成。目前业界的分布事务方案大致分几类。对于强一致的场景可以用XA两阶段提交但性能开销大、实现复杂度高、对数据库支持有要求在互联网高并发场景用得越来越少。更常用的是TCC模式Try-Confirm-Cancel把业务操作拆成预留、确认、取消三个阶段本质上是用业务补偿机制来替代数据库锁但TCC对业务代码的侵入较大每个操作都要实现三个方法。在实际项目中我见过最多、也最容易落地的其实是本地消息表 最终一致性方案。核心思路是把跨分片的事务拆成多个本地事务其中一个分片的事务成功后往本地消息表写入一条消息再通过消息中间件把这条消息投递给下游分片去执行后续操作。下游执行完后确认消费如果失败就重试配合定时对账兜底。这套方案的好处是每个分片仍然使用自己本地的ACID事务应用代码不用过于复杂坏处是它只能达到最终一致性在极端时间窗口内系统状态可能不一致。选型上要给业务方一个明确的预期管理分库分表之后强一致事务的成本非常高绝大多数业务在细小的时间窗口内容忍最终一致性是完全可行的前提是有可靠的对账和补偿机制兜底。4.3 跨分片查询能避免就避免不能避免就接受代价分库分表之后最让研发头疼的就是跨分片查询。Join查询在分片之间几乎做不到了。一个订单要对商品表做join查出商品名称而商品表可能在另一个库传统的join直接失效。常规的替代思路在应用层分两次查询先查出订单数据再根据商品ID列表批量去商品库查询内存中做拼接或者做数据冗余把商品名称等高频字段直接冗余写入订单表查询时不需要join代价是商品改名时订单表里的冗余数据需要异步更新。分页和排序是另一个高频问题。单表分页直接用limit offset, size就行拆分之后如果排序字段不是切分键你只能先从每个分片查出排序后的前N条数据在内存中做归并排序再取全局前N条。这意味着涉及到的分片都要做一次全量子查询分片越多代价越大。深翻页的问题尤其严重翻到第1000页的时候每个分片都要先把offsetlimit的数据捞出来内存和耗时都会爆炸。应对策略一般是限制翻页深度只允许前100页或500页或者放弃页码跳转改用下一页游标的方式用上一个页面的最后一条记录的排序字段值作为下个页面查询的起始条件每个分片直接按游标往后取避免offset。聚合统计查询count、sum、group by同样要面临遍历所有分片再合并的情况对于实时性要求不高的统计建议做离线或异步的计算把结果写到独立的统计库里而不是在线去遍历分片。5. 实操中踩过的坑从迁移双写到深翻页的典型问题这一节我聊聊在项目实操过程中真正踩过的几个坑。这些坑在方案设计阶段几乎不会出现在PPT里却在上线后的某一天突然变成事故。5.1 迁移期的双写与校验一次失败的教训第一次做分库分表时我们的迁移方案是凌晨停服几小时跑一次性迁移脚本。理论上可行但一算账就崩溃了业务高峰期数据还在持续产生几小时的停服时间在业务方那里根本批不下来。后来改成在线双写迁移在老库和新分片上同时写入同一份数据再把老库的历史数据分批导入新分片最后通过校验工具对比两边数据确认一致后切换读流量。这个方案本身是对的但我们在第一次实操中吃了两个亏。一个是没有提前写好数据校验脚本等到切换那天才发现部分数据因为写入时序问题两边不一致数量还不少只能临时加急写对账逻辑搞得整个发布流程特别紧张。另一个是双写期间没有监控新分片的写入错误率老库写入正常、新分片悄无声息地丢了几百条数据直到业务反馈才追查出来。后来固化了标准流程迁移前先做数据全量比对工具按主键分批拉取两边数据对比字段指纹或CRC校验值双写期间每分钟统计新分片写入失败数并告警切换后保留一段时间的回滚开关把问题发现的时间窗口尽量缩短。5.2 还清分页深翻页的欠账有一个运营后台的数据导出功能用户习惯一页一页地往后翻数据量一大翻到几百页之后就明显感觉页面越来越慢。拆库之后症状更严重了因为每次翻页都要把4个分片的对应页数据捞出来做归并。最深的一次某个运营同事翻到了几千页后台直接超时挂了。排查之后我们做了三件事第一在业务层限制导出和翻页的深度超过一定页数强制走异步导出的任务流程不再提供在线翻页第二所有需要大数据量浏览的场景改为下一页游标模式用上一页末条记录的主键ID作为查询起点避免offset偏移第三给专门的导出场景单独建了汇总库提前把跨分片数据同步到汇总库导出只查汇总库不再在线打散查询。5.3 连接数被打满分库之后的新瓶颈分库之前应用连一个数据库连接池配个几十上百就够了。分库之后假设拆了4个库每个分片上都建立了独立连接池如果每个连接池默认配置和原来一样应用侧到数据库的总连接数会变成原来的数倍而数据库侧的可接受连接数是有上限的。我们遇到过一次拆分上线第二天数据库就报too many connections排查下来发现是各服务的连接池配置没有随拆分做减法每个分片都按原来的单库配置开连接把数据库的连接限制打爆了。处理方式是给每个连接池单独做过评估分片一、二、三的访问量和连接需求分别是多少单独调小配置同时在网关层对连接数做了统一监控出现接近上限的迹象就提前报警。5.4 索引命中的假象分片内索引的局限还有一个容易忽视的点分库分表之后索引的设计视角变了。单库表里你可以为一个不常作为查询条件的字段建立联合索引来优化特定SQL拆分之后如果这个查询条件里没有包含切分键即使分片内有索引也必须先遍历所有分片才能找到数据。很多时候团队拆完库之后发现某些查询比原来还慢问题就出在这个遍历所有分片上而不是索引质量问题。所以在分库分表的方案评审阶段就要老老实实把线上的核心SQL清单拉出来逐一标记是否携带切分键、是否可以接受遍历分片、是否需要改造SQL或冗余数据。这一步做得越细上线后的性能问题就越少。6. 真实项目复盘订单系统从单库到分库分表的完整过程最后用一个我实际参与过的电商订单系统改造做个完整复盘。这个场景很典型希望对正在做方案设计的人有参考价值。6.1 背景与容量评估项目背景是某电商平台的订单系统运营两年多日订单量约50万累计订单数据超过1.5亿行。当时的状况是订单主表单表数据量太大按月归档已经跟不上增长每逢大促活动晚间高峰的写入经常把数据库实例打到高负载慢查询数量激增。承压分析结论是单实例的写入IO和单表的数据量都已经接近上限必须做水平拆分。我们对远期容量做了预估假设日订单量三年后涨到300万三年累计订单量约20亿行。按这个量级拆32个分片每个分片约6000万行左右是合理的选择既避免单分片数据量过大又不至于让分片数量多到引入过多运维成本和跨分片查询开销。6.2 方案选型与切分键决策基于查询模式分析订单列表页、订单详情页、用户订单管理这三个核心路径的所有SQL都携带了user_id按用户维度水平拆分是最优解。最终方案确定为4个分库每库8张分表共32个物理分片路由规则为user_id % 32先取模定分片再通过locate定位订单号生成时内置了user_id的一部分信息这样按订单号查询也能反推出user_id不需要遍历全部32个分片。分片内部再按时间做二级分区保留按月归档的能力。6.3 实施过程中的关键节点整个改造分为四步走。第一步是搭建新分片集群和路由中间件在中间件层封装分片路由和结果合并逻辑业务代码尽量少感知物理分片的存在。第二步是老库与分片并行双写双写期间老库继续承担读流量同时对1.5亿行历史数据做分批迁移校验工具每批对比数据指纹出差异立即告警。第三步是把读流量灰度切换到分片按白名单用户逐步放大比例实时观察慢查询和错误率。第四步是切换写流量老库降级为只读归档角色新增数据全部写入分片最后保留一个月的老库只读窗口供对账。这里再补充一个当时踩过的具体坑灰度切读阶段有一批用户的数据已经迁到了分片但他们的部分订单因为双写启动前的历史数据没补齐导致切换后详情页偶发404。定位后发现是历史数据迁移脚本对老库删除状态的订单处理逻辑有缺陷有些已删除订单被跳过了。修复方案是迁移脚本改为按全量主键遍历删除状态的数据也要迁过去只是标记删除状态查询层再做过滤。6.4 改造后的效果与复盘体会拆分完成后的效果是立竿见影的大促晚高峰的写入无超时慢查询数量下降了九成以上单个分片的数据量降到几千万行索引层数和查询效率都恢复到健康水平。更重要的是系统具备了水平扩展的能力——后续容量不够时可以按翻倍策略继续扩容虽然还要经历一次迁移但整体架构不再被数据量锁死。复盘下来我个人的体会是分库分表这个事方案选型和细节设计占了七成成败真正动手写代码反而只占三成。切分键决定了一个方案的上限路由和扩容设计决定了下限而迁移是否平滑、对账是否可靠决定了上线当天是平静还是兵荒马乱。如果你正在评估要不要做分库分表我的建议是先把核心SQL清单和未来一两年的容量预估做扎实再动手设计分片方案。没有这些前置数据任何方案都只是空中楼阁。最后分享一个小技巧无论你最终选择哪种方案先在测试环境用全量生产数据量做一次压测把路由中间件的吞吐、迁移脚本的性能、跨分片查询的耗时全都压出真实数字来再决定上线的节奏。很多问题测试环境模拟不出来等上了生产再发现就被动了。
返回列表