我见过太多团队把“分布式系统”做成了“分布式事故现场”订单库拆了查询却慢成狗事务消息上了消息却悄悄丢熔断器配了雪崩照样发生。标题里这三大块——分库分表、分布式事务、熔断补偿看着像各自独立的八股考点实际上是一条完整的技术债锁链拆了库才需要分布式事务事务做不到强一致才需要补偿补偿一多才需要熔断兜底。这篇文章不是给你背概念的是我把这些年在生产环境里踩过的坑、验证过的方案、被流量教训出来的经验从头到尾捋一遍帮你在真正动手设计系统时知道每一步该解决什么问题、用什么手段、留什么后路。文章适合三类人正在做分库分表改造但还没想清楚分片键怎么选的后端开发微服务里事务开始出现不一致在TCC、事务消息、SAGA之间摇摆的架构师以及系统已经被线上故障逼着加熔断补偿却总感觉“加了跟没加一样”的运维和研发。我会把这三大块串成一条主线来聊不是散装知识点。1. 分库分表先搞清楚“为什么拆”再动手也不迟1.1 单库单表撑不住的信号数据量大只是其中一个指标很多人对分库分表的理解停留在“表里数据超过一千万就该拆”。这是个危险的想法。我在生产环境里见过一张订单明细表两年涨到2亿行依然跑得好好的也见过一张只有三百万行的配置表把整个数据库拖到告警。区别不在数据量而在访问模式和资源瓶颈。真正需要拆表之前你会先看到几个典型信号我按出现顺序排一下活跃连接数持续逼近上限正常的业务高峰连接池里的连接几乎全部被占用新请求要排队拿连接。这时候你去看慢查询不一定有特别慢的SQL只是每条SQL多等了几十毫秒排队整体RT就上去了。磁盘IO和缓冲池命中率恶化当一张热表的索引和数据总量超过InnoDB缓冲池能缓存的范围每次查询都可能发生物理读。订单表如果全是随机访问热点数据维持不住磁盘IO就会变成瓶颈。单条SQL的扫描范围失控即使有索引如果你的查询条件总是绕过索引做范围扫描行数一旦到千万级CPU和IO都扛不住。比如“查某用户在最近一个月的订单”如果没有以user_id为前缀的索引优化器大概率走全表扫描。所以我的判断标准很简单数据量大了以后先把索引优化、冷热数据归档、读写分离这些常规手段全部试过仍然达不到业务指标才轮到分库分表。而且分库分表一旦做了就不是换个中间件那么简单是一整套数据访问方式的改变必须按“融资最后一轮”的严肃程度来评估。1.2 垂直拆分与水平拆分一个解决耦合一个解决容量分库分表的分法有两条路很多人混着说但解决的问题完全不一样。垂直拆分是按业务域把一个数据库拆成多个库。比如把用户库、订单库、商品库分开配合微服务做数据隔离。它的作用是解决“多个业务模块互相抢数据库资源”的问题。一个库里同时跑订单和库存大促时订单写入量暴涨可能把库存查询也拖慢。垂直拆分之后每个业务域自己的库自己管故障影响面也变小了。水平拆分是把同一张表的数据按某个分片键均匀或按规律分布到多个库多个表。它解决的是“单表数据量过大、写入并发过高”的容量问题。比如订单表按月拆成分片或者按用户ID取模散到16个库。这两者不是二选一通常的做法是先垂直后水平。微服务化完成之后订单服务发现自己这一亩三分地还是扛不住再对订单表做水平拆分。这个顺序不能反否则你会面临一个尴尬局面一边做水平拆分一边又把订单和库存放在同一个事务里操作最后分布式事务的复杂度远超你省下来的那点数据库压力。1.3 分片键和路由规则一切以“最高频查询怎么走”为中心分片键选错是分库分表最大的灾难而且这种灾难不是上线当天爆发的是业务跑起来以后慢慢露出来的。原则只有一条分片键必须是你最高频查询的必带条件。用户侧访问订单习惯是“查我的订单”所以user_id是最常见的订单分片键。但商家后台要“查这个店铺所有订单”如果订单表按user_id拆商家侧查询就要扫全部分片还得跨库聚合这是生产上非常典型的烂尾方案。解决思路有三个方向索引表单独建一张“商户ID到订单ID”的映射表商家查询先命中映射表再按主分片键去查具体分片。宽表冗余商家维度需要的字段异步同步到一张商家维度的宽表里查询走宽表不碰原始分片表。中间层聚合通过ShardingSphere这类中间件把一次跨分片查询拆成多路查询再归并。这个方案实现成本最低但只适合低频查询否则聚合层的性能就是新的瓶颈。路由规则上我见过最常用的三种路由方式优点缺点适用场景取模分片数据分布均匀实现简单扩容要迁移大量数据路由依赖分片总数数据量可控短期内不需要扩容范围分片按时间或ID范围分区利于范围查询扩容只需加表容易冷热不均尾部热点明显日志、流水等时序数据一致性哈希节点变化时迁移数据少分布相对均匀实现复杂可能存在哈希倾斜需要虚拟节点缓存集群这类动态扩缩容场景轮询/随机写入均衡查询基本都要全路由不适合OLTP几乎不用在事务型系统里另外一个容易翻车的地方是热点账号。不管用哪种路由电商大促时总有那么一两个“爆款店铺”订单量是普通店铺的几十倍。取模分片后这些热点订单集中在某几个分片其它分片却很闲。要治这个通常得做两级路由先按普通维度分片检测到热点时把热点数据再拆到一个单独分组用额外字段标记。这个设计最好在分片设计之初就预留字段否则后期很难补。2. 分库分表之后的连锁问题全局ID、平滑扩容与跨分片查询2.1 自增ID“废了”雪花ID的bit分配和时钟回拨处理分表之后每个表里的自增主键会从1开始重复如果不能保证全局唯一后面做数据合并、跨库关联、日志追踪全都会乱套。所以必须有一种全局ID生成方案。现在的主流选择是雪花算法Snowflake。它生成的ID是一个64位Long1位符号位不用41位毫秒时间戳可以使用约69年10位机器编码最多支持1024个节点12位毫秒内序列号同一个节点同一毫秒内可以生成4096个ID。这个结构的经典之处在于从ID里可以反解出生成时间和机器对排查问题很有帮助而且整体趋势递增对数据库索引友好。但雪花算法有个生产环境特别容易踩的坑时钟回拨。如果服务器启用了NTP时间同步偶尔把系统时间调回去了几十毫秒可能在同一毫秒内生成出重复ID。比较稳妥的解法是发号服务记录上次生成ID的时间戳发现当前时间小于上次时间说明时钟回拨直接拒绝发号或等待时钟追平小幅度回拨比如几十毫秒可以用“预留序列号”顶过去但实现复杂度会高一些。如果不想自己维护雪花算法也可以考虑号段模式例如Leaf的Segment方案。它从数据库里取一段号段例如一次取1000个ID放到内存服务直接从内存发号。这个方案实现简单ID不包含太多可解析信息胜在可控性强。UUID我基本不推荐字符串太长在InnoDB里做主键会产生严重的随机IO性能比数值型主键差太多。2.2 扩容与数据迁移取模分片从4扩到8怎么平滑切换取模分片的最大痛点是一旦分片总数变了路由规则就变了所有数据都要重新分布。比如原来user_id % 4扩容到8个库后原来落在0号库的数据有一部分要迁到4号库其他同理。停机扩容最简单维护窗口内停写、跑脚本迁移数据、校验、切换路由。小团队、低流量时段可以用。但你要是碰上一个7x24小时或者大促前夜才想起来的扩容需求就必须用平滑方案。我自己验证过的一套操作流程按次序来新库准备新分片库建好表结构开启双写。业务写入时按新旧两套路由规则同时写一份数据到新位置。存量数据迁移通过binlog监听或ETL任务把旧库的历史数据按新路由规则回放到新库。数据校验这一步不能省。对比旧库和新库的数量、抽样字段校验、查关键记录checksum。线上经验是双写和迁移期间最容易出现重复字段、缺失记录这类问题。灰度切读把查询流量按比例切换到新路由比如先切5%的用户观察SQL耗时、报错、数据一致性逐步放大。停止双写确认新库数据完全追上并且稳定运行一段时间后停掉双写逻辑旧库降级为只读备份或直接下线。整个过程最核心的一句话先对账再切量不要相信“应该没问题”。双写期间业务代码要保证幂等因为同一个用户可能写两份数据如果双写逻辑重复执行就可能插入重复记录。2.3 跨分片查询的三种解法索引表、宽表与聚合归并分片之后所有“按非分片键查询”的请求都变成了跨分片查询。这实际上是分库分表附加成本的大头。我拿订单系统举例子。订单表按user_id分片用户端查询没有问题但商家端要查店铺订单怎么办三条路索引表维护一张shop_id - user_id, order_id的映射表。商家查询时先在索引表里查到关联的user_id再到分片上进行访问。这个方案写入成本高但查询效率最高适合商家量级可控的场景。宽表单独建立一张商家维度的宽表把订单核心字段订单号、金额、状态、时间等同步进去。同步可以走MQ或binlog异步更新。查询商家订单列表时直接查宽表完全不碰分片表。宽表可以放在ES里也可以放在单独的MySQL库取决于查询复杂度。中间层聚合归并通过ShardingSphere等中间件自动把SQL拆成多个分片的查询然后归并结果。比如分页查询中间层会在每个分片上查出多页数据再在内存里做全局排序和分页。这个方案开发成本最低但要注意大数据量下的内存消耗。这三种方案不互斥。我的习惯是高频、对性能敏感的查询优先做索引表或宽表低频、搜索条件复杂的查询交给聚合归并。另外要提醒一点如果业务里有大量表需要做join在分片设计时最好把需要join的表用相同的分片键这样它们能落在同一个分片内中间层可以做单库join避免广播join性能会好非常多。3. 分布式事务从“本地事务失效”到“最终一致性”的取舍3.1 拆库之后一笔订单扣库存的流程变成了什么先看一个最经典的“订单与库存”场景。改造前订单表和库存表在同一数据库里一单业务逻辑是这样创建订单记录扣减库存同一个事务里提交。begin; insert into order ...; update stock ...; commit;一句本地事务就能保证原子性要么都成功要么都回滚。分库分表以后订单库和库存库物理上分开了各自都是独立的本地事务。原来那套写法顺理成章地变成了订单库插入订单记录提交事务调用库存服务扣减库存。问题来了如果第二步失败或者网络超时订单库的本地事务已经提交了库存却没扣。这时候业务上出现超卖风险订单数据也不一致。你不可能用一条SQL去控制两个分开的库所以必须引入分布式事务。分布式事务的本质是让一组跨进程、跨数据库的操作达到业务上可以接受的“原子性”。注意不是所有场景都要求强一致。大部分互联网业务真正想要的不是“同时成功”而是“最终不会错”。3.2 CAP与BASE为什么最终一致性是多数业务的最优解讲到分布式事务必然绕不开CAP。CAP理论说在网络发生分区Partition的时候你必须在一致性Consistency和可用性Availability之间二选一。理解这个理论的关键不是把C、A、P三个字母背下来而是理解为什么没有“三者兼得”。分布式系统的节点之间隔着网络只要网络有延迟、会中断分区就是一定会发生的事实。在分区期间你要么拒绝服务来保证数据不冲突选C要么继续服务然后接受临时的数据不一致选A。这个过程没有中间态。所以实际工程里绝大多数高并发系统选择的是BASE模型。BASE不是不要一致性而是把一致性从“瞬间达成”放宽到“最终达成”基本可用Basically Available系统在出问题时还能保证核心功能可用比如下单流程可以走但推荐列表可能短暂变成默认值软状态Soft State允许系统存在中间状态比如订单状态是“处理中”库存是“待扣减”最终一致Eventually Consistent经过一段确定的时间所有数据副本最终达到一致。选型的底线在于业务能接受多长的不一致窗口。如果业务要求支付扣款和积分发放必须同时成功那就要仔细考虑强一致方案如果订单生成后库存晚一秒扣减完全能接受那就优先选异步最终一致。3.3 2PC、TCC、事务消息、SAGA四个方案怎么选市面上的分布式事务方案说到底是围绕“实现一致性”和“牺牲多少性能与复杂度”之间的权衡。我按方案逐个讲清楚再给一张对比表。两阶段提交2PC/XA有协调者第一阶段让所有参与者准备prepare并加锁资源全部成功后第二阶段统一提交commit任何一方失败则统一回滚abort。它保证强一致但问题也很明显prepare阶段要持锁到最终决策同步阻塞时间长协调者本身单点挂了整个事务卡住。所以XA在高并发互联网里用得越来越少但在跨行转账这类低频、强一致场景仍然有位置。TCCTry-Confirm-Cancel业务级别的两阶段。Try阶段做资源预留比如冻结库存Confirm阶段真正扣减Cancel阶段释放预留。它的好处是资源不是一直被锁而是“冻结”可以在高并发下做严格业务校验。代价是侵入性强每个参与方都要实现三段逻辑。事务消息/本地消息表核心思想是把一个分布式事务拆成“本地事务消息”两步。业务操作和消息发送放在同一个本地事务里事务提交后消息一定发出去了下游消费消息做后续操作。这个方案适合可以异步的链路最终一致性好但需要处理消息重试和消费幂等。SAGA把一个长事务拆成一串短事务每个短事务都有对应的逆向操作。比如“创建订单 - 扣库存 - 扣余额”如果扣余额失败就依次反向执行“释放库存 - 取消订单”。SAGA不需要加长事务锁适合长流程但没有隔离性中间状态可能被其他事务读到需要业务上容忍或额外加控制。方案一致性强度实现成本性能影响典型场景2PC/XA强一致中依赖数据库高同步阻塞低频、强一致跨行转账TCC最终一致或准强一致高业务侵入强较低资源冻结订单、库存、余额的高频场景事务消息最终一致中需处理幂等低异步解耦积分、通知、下游异步处理SAGA最终一致高需编排和补偿低无锁定跨系统长流程例如风控审核我的建议是能异步解决的不要同步能靠消息解决的不要硬上TCC。不要为了面试里那句“我们用了TCC”就在业务里引入巨大复杂度。很多场景本地消息表加定时补偿已经完全够用了。4. TCC和事务消息的落地细节空回滚、悬挂、幂等一个都不能少4.1 Try、Confirm、Cancel到底在干嘛为什么不是直接扣减再回滚拿订单和库存来说TCC三个阶段的动作拆得很清晰Try检查并冻结资源。比如库存表增加一个“冻结库存”字段下单时把要扣的数量登记为冻结但不真正扣减可用库存。这个阶段还做业务校验比如校验用户状态、订单金额、库存是否充足。Confirm执行业务提交。把冻结库存真正扣减掉更新订单状态为已确认。Cancel释放预留资源。把Try阶段冻结的库存加回可用库存订单状态改为已取消。为什么不干脆“先扣减失败再恢复”因为恢复操作在分布式环境下不可靠。试想扣减库存成功了但下游确认超时你要“加回库存”这个加回操作可能因为网络又失败或者因为中间有人已经读取了库存导致超卖。而TCC的Try阶段做“冻结”Cancel操作本身是幂等的它只是把冻结数减回去可用库存一直没有被动过这样安全得多。这里还要特别注意一个点Confirm和Cancel都必须实现幂等。因为协调者在网络抖动下可能重试同一个事务的Confirm可能被调用多次。你需要在每个参与方的事务控制表里用“全局事务ID 分支事务ID”作为唯一键记录状态重复请求直接返回成功不做第二次扣减。4.2 空回滚和悬挂两个最容易写错的反向操作TCC落地光写好三个方法还不够。还有两个隐蔽的坑我分别讲。空回滚指的是Cancel阶段执行时Try阶段还没真正执行成功。这通常发生在网络超时后协调者已经判定失败直接发起Cancel而Try请求还在路上。此时你的Cancel如果直接去扣减冻结库存会发现冻结库存根本没有数据。正确处理是Cancel前先查事务控制表发现该全局事务没有Try记录标记为“已回滚”然后返回成功。悬挂和空回滚是镜像问题。Cancel已经执行完了Try请求才到达。这时候如果Try继续冻结库存这个冻结就变成了一笔永远没人消费的悬浮资源。正确处理是Try执行前查事务控制表如果发现事务已经是“已回滚”状态直接拒绝执行Try不再冻结资源。从前到后的完整逻辑可以这样理解每次Try、Confirm、Cancel都先查事务控制表Try阶段写入记录状态为“Try已执行”如果已经是“已回滚”拒绝Cancel阶段如果查不到Try记录说明发生了空回滚标记“已回滚”并停止后续操作Confirm阶段如果状态不是“Try已执行”拒绝执行或按幂等返回。这部分的实现必须依赖一张可靠的事务状态表存放全局事务ID、分支ID和状态。别把状态放在内存里服务一挂全丢线上肯定出问题。4.3 事务消息的幂等与对账别让“最终一致”变成“永远不一致”事务消息的经典实现有两种本地消息表和RocketMQ事务消息。原理上是一样的核心都是“业务操作和消息写入保持同一事务”但落地细节千差万别。本地消息表的做法是在业务库创建一张消息表字段包括消息ID、业务类型、业务主键、消息状态、重试次数业务操作和一条“待发送”的消息放在同一个本地事务里提交定时任务扫描“待发送”状态的消息推送到MQ消费端处理消息处理成功后调用接口确认消息表状态改为“已发送”。这套方案的可靠性在于“业务成功了消息一定会落库消息只要在库就一定会被扫描到”。但它很依赖消费端的幂等因为消息可能被重复投递。消费端必须在处理逻辑中用自己的业务主键做唯一约束例如插入订单流水前先查是否已经处理过或者用数据库唯一索引直接拦截重复插入。RocketMQ事务消息的思路类似变成两阶段生产者先发一条half message然后执行本地事务根据事务执行结果commit或rollback消息。RocketMQ会定时回查生产者的事务状态确保不会丢消息。使用上确实比本地消息表简洁但坏处是你得依赖中间件如果公司已有RocketMQ还好否则成本和中间件运维也需要算进去。最后不管是哪种方案我强烈建议你必须有一张“分布式事务对账表”或至少是监控大盘。每天跑一个定时任务扫描那些长时间处于中间状态比如订单已生成但库存补偿还没完成的记录。超过阈值就告警。因为最终一致性系统的最大痛点不是消息丢了而是“消息没丢但一直没人消费”或者“消费了但本地事务提交失败”。这些问题只有靠对账才能发现。5. 熔断、限流、降级与补偿系统被流量打挂前的最后防线5.1 雪崩的源头往往是一个慢接口加无限重试分布式系统里一个服务不可能永远正常。真正的灾难不是“某次调用失败”而是失败后调用方用了最糟糕的方式去应对等待、重试、等待、再重试。假设订单服务调用库存服务库存服务因为数据库慢查询变慢了原来50ms返回现在2秒还没返回。订单服务默认超时时间是3秒那每个请求就占着线程池里的一个线程3秒。订单服务每秒进来500个请求线程池只有200个线程很快就全部耗尽。后面的请求全部排队排队又把RT拖得更长。库存服务的负载被卡住的请求越堆越多最终整个链路一起崩溃。重试会让情况雪上加霜。如果订单服务里写了“异常自动重试3次”那么一次下游故障会变成4倍流量打出去。下游已经从慢变挂你还主动给它加压。所以凡是涉及跨服务调用必须做三件事设置合理的超时时间连接超时、读超时分开配置控制重试次数非幂等写操作不要重试幂等读操作重试最多一次重试时使用退避策略不要立即重试指数退避加随机抖动。5.2 熔断器的三态Closed、Open、Half-Open熔断器的概念和电路断路器一模一样。它的作用是当下游服务连续出错超过阈值时自动“跳闸”后续请求不再调用下游直接快速失败。这个设计不是为了保护下游而是为了保护调用方自己不让自己被一个半死不活的服务拖死。熔断器有三种状态状态机非常关键Closed关闭正常状态请求照常放行。但熔断器会统计最近一个时间窗口内的调用成功/失败比例。Open打开失败比例超过阈值后熔断器打开。此时所有请求快速失败不再真实调用下游。这个状态持续一段时间给下游留出恢复窗口。Half-Open半开熔断器打开一段时间后允许少量探测请求“试水”。如果这些请求成功说明下游恢复熔断器关闭如果失败熔断器继续打开。以Resilience4j的配置为例resilience4j.circuitbreaker: instances: inventorySvc: slidingWindowSize: 100 # 统计最近100次调用 failureRateThreshold: 50 # 失败率超过50%触发熔断 waitDurationInOpenState: 10s # 熔断打开后等待10秒进入半开 permittedNumberOfCallsInHalfOpenState: 10 # 半开状态下允许10个探测请求 registerHealthIndicator: true这些参数没有统一答案要按你自己的业务SLA来定。我一般会先定一个最小基数比如“1分钟内调用量少于20次就不判断熔断”防止低流量下的抖动把熔断器误触发。半开阶段的探测请求数量也不能太小否则下游刚恢复你只放一两个请求统计意义不够。5.3 降级和补偿不是一回事要么快速失败要么后台慢慢还熔断器打开之后调用方有两种选择。很多人把“降级”和“补偿”混在一起其实它们的分工完全不同。降级是熔断触发后的即时响应策略。我把它分成两类Fail-Fast 后台补偿当下游熔断时请求立刻返回失败但把失败的请求信息写入一张补偿任务表。后台任务之后会用自己的节奏去重试直到把账还清。这个方式适合核心链路比如“下单扣库存”你不能一直让用户等但也不能直接说成功。下单请求快速失败用户重试或等待后台补偿任务会把中途没做完的库存扣减完成。返回兜底数据当下游熔断时返回一个可用的默认值或缓存值。适合非核心链路比如价格标签服务挂了前端可以先展示上次的缓存价格而不是报错。补偿是后台任务的执行过程目的是让系统从“软状态”恢复为“最终一致”。一个简单的补偿任务状态机可以这样设计PENDING任务刚创建等待执行RETRYING执行中如果失败按退避策略重新入队SUCCESS补偿完成确认最终一致FAILED多次重试仍失败DEAD超过最大重试次数进入人工处理队列。整体思路是对失败的调用不要同步死磕而是快速失败、异步补偿。补偿任务必须有独立的线程池或者独立的MQ消费组不能和主链路抢资源。5.4 熔断与分布式事务的配合宁可异步不要原地重试这一节算是把前面所有内容串起来。一个完整的下单链路可能是这样设计的请求进来走TCC的Try阶段冻结库存如果库存服务调用超时熔断器打开Try阶段快速失败返回给用户“系统繁忙请稍后重试”此时可能出现一个问题Try虽然失败了但库存服务实际上可能已经执行了冻结只是响应丢失。这就是典型的“不确定状态”后台补偿任务扫描到这笔“不确定”的单据主动去查库存服务的状态如果是Try成功但响应丢失就继续走Confirm或者视情况Cancel把这个未完成事务收口。你会发现熔断和分布式事务其实是配套的。如果没有熔断下游一慢调用方不断重试TCC的事务状态机根本来不及收口系统就被拖垮了。有了熔断流量被挡在外面后台补偿任务才有机会“慢慢还债”。所以我的经验是分布式事务的Cancel、Confirm调用也要配置熔断和限流否则补偿任务本身会把下游再打挂补偿任务重试时使用指数退避和随机抖动避免所有补偿任务在同一时刻集中执行形成“补偿风暴”监控大盘上除了常规的QPS、RT、错误率一定要有“分布式事务中间状态数量”和“补偿队列深度”这两项指标。它们才是分布式系统真正的健康度指标。我在实际维护中感受最深的一点是分布式系统的核心不是用哪个框架、哪种算法而是每个环节都要有兜底每个兜底都不能再成为新的单点。分库分表后查询要兜底分布式事务后状态要兜底熔断打开后补偿要兜底。这套“链路式防御”的意识比任何具体技术方案都值钱。