做分库分表这几年,我最大的感受是:网上讲"怎么分"的文章一大堆,真正讲清楚"分了之后怎么收场"的几乎没有。一致性哈希、取模路由、读写分离这些概念,刚接触的人背两天就能复述,可一旦线上真的把一个订单库拆成16个库、每个库再拆成128张表,你立刻会发现——单库时代的思维全部失效,SQL语法、事务边界、ID生成、分页排序、数据迁移,每个环节都在暗中惩罚你的天真。
这篇就把我实际踩过的复杂问题摊开来讲,包括每个问题的根因、取舍逻辑、排查链路,以及我现在认为相对靠谱的PHP落地方案。适合分库分表方案已经在路上、或者正在被跨库查询折腾得睡不着的人,让后来者少走几个月的弯路。
1. 先泼冷水:分库分表不是技术升级,是代价交换
坦白说,我在模拟项目X里最初接触分库分表时,以为它是个"性能增强插件"——装上之后数据库就不卡了。真正做完之后才明白,分库分表更像是一次搬家:新房子确实宽敞了,但你原来顺手的一切都变了,水电燃气要重新报装,离地铁远了通勤要重规划,你以为只是换了个地址,实际是整个生活方式的重构。
1.1 判断"什么时候才真正需要分库分表"
并不是所有数据库变慢都该分库分表。很多团队在单库单表阶段就能解决80%的问题:慢查询加索引、冷热数据分离、读写分离、把大字段拆到附属表、用缓存扛热点。这些方案的成本和复杂度,都比分库分表低一个数量级。
我当时给某零售业务的订单库做了第一轮优化,就只做了三件事:给order_no和user_id建联合索引,把十年订单按年份拆成了归档表,再用缓存扛住了秒杀时段的订单查询。结果QPS从两千涨到八千,数据库CPU从80%降到30%。所以我的判断标准很朴素:单表单库下,索引、缓存、读写分离都试过了,依旧扛不住写入吞吐或者单表数据量破亿,才轮到分库分表出场。
这背后有一个被很多人忽略的规律——数据库的容量和吞吐瓶颈,本质上是单机资源(磁盘IO、内存、CPU)的物理上限问题。分库分表的价值在于把压力分散到多台机器上,用机器数量换单机瓶颈,但它同时牺牲了SQL的完整性和事务的原子性。后者才是"复杂问题"的总根源。
1.2 拆完库之后,你真正要面对的三层麻烦
以我拆过的那个订单系统为例,分库分表带来的复杂问题其实可以归成三层:
第一层是数据访问层。原来一句SELECT * FROM order WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10,现在你得先算出这个用户落在哪张表,再带着表名去查询,代码里到处是分片路由逻辑。
第二层是数据关系层。Join没了,外键没了,原来的订单明细可能和订单主体落在了不同的库里,想一次性查出"订单+明细+商品快照+用户信息",SQL写不出来,只能靠应用层多次查询兜底。
第三层是运维与演进层。数据量继续涨怎么办?分片键选错了怎么调整?线上数据如何平滑迁移?这些问题单库时代几乎不用想,分库之后就变成了日常。
下面每一节,都围绕这三层里出现的真实复杂问题展开。
2. 全局主键的选址逻辑:为什么自增ID在分库后是灾难
分库分表之后你踩的第一个雷,大概率是主键ID冲突。原因很简单:单表自增主键依赖数据库内部的auto_increment计数器,每张表独立维护。原来一张表,ID从1到1000,没任何问题;现在拆成16张表,每张表都从1开始自增,就可能出现16个ID=1001的订单。
2.1 自增ID失效的根因与"改改就能用"的误区
网上有些方案说"设置不同步长就行",比如库1步长16,库2步长16但起始值不同。这个思路叫"设置自增偏移",实现起来确实不复杂:
// 库1执行 SET @@auto_increment_offset = 1; SET @@auto_increment_increment = 16; // 库2执行 SET @@auto_increment_offset = 2; SET @@auto_increment_increment = 16;但这里有个致命问题:这种方案只能保证初始分配不冲突,不能保证未来扩容不发生碰撞。你今天有16个库,步长16,明天业务量翻倍要扩到32个库,原来的ID分配规则全部作废,存量数据和增量数据的ID空间直接打架,你只能做数据迁移,成本极高。
我实际测试过这个方案的另一个隐性坑:当步长和偏移量配置到从库上时,主从切换之后偏移配置可能丢失,重启数据库实例也可能重置偏移量,导致同一秒内两个库生成出完全相同的ID。生产环境遇到过一次,排查花了一整天才定位到是MySQL实例重启导致全局设置失效。所以只要数据要跨库合并查询,自增ID这条路基本可以放弃。
2.2 雪花ID在PHP里的落地实现与坑点
目前业界最常用的替代方案是雪花ID(Snowflake)。它的核心思想是把一个64位整数拆成三段:
- 高位1位符号位,不用
- 中间41位时间戳,精确到毫秒
- 接着10位机器ID,表示某台机器
- 最后12位序列号,同一毫秒内自增
41位时间戳能用到69年(从自定义纪元算起),12位序列号支持单机单毫秒生成4096个ID。这组组合基本覆盖了绝大多数业务场景。
PHP落地时有个不错的实现方式:利用pack()把时间戳、机器ID、序列号打包成二进制再变成int。但更稳妥的做法是利用扩展,比如ext-ds或者商业环境里常见的uuid扩展配合自定义封装。我自己在模拟项目X里用的是基于Redis自增序列的改良方案,避免为每个ID申请都要访问Redis——先本地攒一批序列号,用完再问Redis拿下一段,性能和平稳性都好得多。
踩过最重要的坑是时钟回拨。雪花算法依赖系统时间递进,一旦宿主机开启了NTP同步且回拨时间超过序列号可抵消的范围,就可能出重复ID。我当时在线上遇到过NTP校时导致的时隙跳跃,排查的最终方案很老套但有效:启动时把机器ID绑到进程生命周期里,每次生成前检测时间戳是否小于上次生成时的值,一旦检测到回拨就轮询等待当前时间追平。
2.3 批量插入场景下ID生成的性能陷阱
另一个容易被忽视的点:使用类似雪花算法时,连续批量生成的ID最高位往往相同。如果你把主键同时当成聚簇索引,大量连续ID插入会让InnoDB的B+树在写入时频繁触发页分裂。这说的是同一个物理存储页内,当ID连续单调递增时,树的插入路径总是靠近右边缘,页分裂次数反而低于随机ID,这本来是优势;真正的性能陷阱在PHP这边——如果你在应用层循环生成ID并且每次都打一次远程调用(比如请求中心ID服务),再批量插入数据库,那么瓶颈就不是数据库,而是ID生成的网络往返时间。
我的实操建议:尽量批量预生成一段ID放到本地内存或Redis队列里,一次取1000个或10000个,在PHP进程内直接消费。这样既避开了每单一次网络IO的开销,又能通过序列号的连续性提前把ID排布好,配合批量INSERT实现较高的写入吞吐。
3. 跨库Join与聚合查询:不再幻想的替代方案
分库分表的第一个心理冲击,来自JOIN的消失。我在设计订单中心的时候,最痛苦的一行SQL不是万行级的,而是简单的三表关联:
SELECT a.order_id, b.sku_name, c.user_name FROM orders a JOIN order_items b ON a.order_id = b.order_id JOIN users c ON a.user_id = c.user_id WHERE a.order_id = 12345单库时这行SQL的执行计划很美:orders表驱动,order_items、users各走一次主键索引,三次索引查找完成。拆库之后,order_items可能在库5的items_128表,users可能在库3的users_64表,任何一次跨库JOIN都无法让MySQL在本地完成。
3.1 跨库JOIN丢失的本质:数据局部性被破坏
数据库能高效执行JOIN,前提是一张表的数据行和它关联行在物理上尽量靠近(同一个实例里)。分库分表强行把数据按分片键拆开,相当于破坏了这种"物理亲和性"。你按order_id拆了订单库,用户数据却留在独立的用户库里,两类数据根本不在同一台机器的内存里,数据库引擎自然也就没法执行原来的嵌套循环连接。
理解这一点之后,跨库聚合的问题就有了明确的解决方向:要么恢复数据的物理亲和性,要么放弃数据库层的JOIN能力,把关联动作上移到应用层。
3.2 宽表与冗余字段:最笨但最有效的方案
恢复物理亲和性最直接的办法就是冗余。在订单分库时,把当时需要的user_name、sku_name、sku_price、store_name等字段直接冗余到订单表里。这样本来要用JOIN才能拿到的信息,在订单表本地就能查到。代价是维护成本:商品改了价格、用户改了昵称,冗余字段不会自动更新,你需要额外做一个异步刷新任务。
我在实际项目里有段时间把冗余字段存得太随意——昵称直接塞进去,结果用户改昵称后历史订单展示的还是旧昵称,被业务方投诉后才补了一套"冗余字段刷新Mq消息"。所以如果决定用宽表,一定要在分库的同时把刷新机制做了,别等踩雷后再补。
这类方案的好处是查询性能极好,主查询只有一次IO,也不用做应用层数据拼装,PHP代码写起来和分库之前几乎没差别。坏处是如果业务字段关联深度大、更新频度高,冗余字段会变成数据一致性的定时炸弹。
3.3 多次查询与内存聚合:小型业务的实用路子
如果冗余做不到(比如信息是动态计算的),那就只能走"查两次"路线。PHP侧实现起来很直接:
$order = OrderShard::findById($orderId); // 先查订单,命中具体分片 $items = OrderItemShard::findByOrderId($order->id); // 再查明细分片 $user = UserService::get($order->userId); // 到用户服务查用户信息 $result = merge($order, $items, $user);这段代码在单库时代会被DBA骂死,因为三次网络往返才能拼出一次查询。但在分库分表场景,它是一个基本的、可接受的妥协。关键优化点是批量聚合:如果一次要渲染20个订单,不能循环20次查明细、20次查用户,而应该先把20个订单ID收集好,再分别按订单ID批量查明细、按用户ID批量查用户,最后在内存里做一次groupBy拼装。
这里我强烈建议在应用层维护一个"数据聚合器",把散落的分片查询收敛到统一出口。聚合器内部做两件事:把跨分片请求拆成多次单分片查询,以及把结果按主键批量拼装。这样业务方看到的是一个本地方法,感知不到背后的分片路由,后续换存储、调整分片键时影响面会被限制。
3.4 跨库分页与深翻页的排序处理
跨库分页也是一个经典难题。ORDER BY created_at DESC LIMIT 100000, 20这种深翻页,在单库下已经很慢(要扫描前面10万行再跳过),跨库后更糟糕:每个分片都必须各自排序并取出前100020条,然后应用层做归并排序,再整体取第10万到第10万零20条。每页数据都要把所有分片的前N条拉全,页数越深,跨库传输的数据量越大。
解决思路有三个。
第一,禁用深翻页。后端接口最多支持翻到前100页,之后只提供"按时间游标加载"的下一页模式。实际上绝大部分用户不会翻那么深,即使会,也不在乎是第10001页还是第10002页。实现就是用created_at和id组成游标:
SELECT * FROM orders WHERE user_id = ? AND (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20第二,排序字段落到分片键内。如果你查询条件里本身带user_id分片键,那么只有一个分片需要参与,深翻页不再有跨库归并问题。这也是为什么分片键的选择要尽量覆盖高频查询条件。
第三,用搜索引擎兜底。像订单检索这种复杂条件查询,我最后是同步到了搜索服务里。分库分表负责OLTP事务性读写,搜索服务负责条件检索和排序,各干各的,反而把矛盾消化掉了。
4. 分布式事务的痛苦与取舍:你能选的方案其实只有两个半
如果说Join丢失是分库分表的第一击,那么事务失效就是第二击,而且这一击更致命。我在做一个跨库转账的Demo时,改了几行数据发现只有一半库成功了,另一半没成功——因为MySQL的BEGIN/COMMIT只对当前连接的库生效。
4.1 分库后不能依赖本地事务的根因
所谓本地事务(本地原子性),靠的是数据库自身的redo/undo日志,能保证同库内多行操作的原子性。分库之后,一次业务操作要更新多个库的多个表,任何一个库提交成功后,其他库提交失败,都会出现"部分成功、部分失败"的状态。数据库引擎之间没有分布式协调机制,本地COMMIT无法决定其他库也提交,这就是分布式事务问题的本质。
第一反应是找全局事务管理器,比如XA协议。但实际踩坑之后发现,XA在PHP+MySQL的组合里几乎不适合在线业务:它需要两阶段提交,锁持有时间长,吞吐量低,实现不好还会造成全局阻塞。我自己真实测试过一个简化版XA,在高并发场景下数据库连接池几乎被打满,最后上线前紧急换掉了。
4.2 可靠消息最终一致:成功率最高的一档
所以实际项目里最常用的路由是最终一致性,其中最皮实的要数"本地消息表+消息队列"方案。
它的思路是这样的:把"更新订单状态"和"发送消息记录"放在同一个本地数据库事务里,事务成功后,消息表里就有一条发送状态为pending的记录,后台有个任务不断扫描消息表,把pending消息投递给下游。下游消费成功后回报ack,任务再把消息标记为发送成功。
public function createOrder(Order $order, StockDeductCommand $cmd) { $this->db->transaction(function() use ($order, $cmd) { OrderShard::insert($order); // 本库写订单 MessageOutbox::insert($cmd); // 同一事务写待发消息 }); $this->mq->dispatch(new DispatchOutboxJob()); // 事务后触发投递 }这方案为什么成功率高?因为它把"跨库原子性"转化成了"单库原子性+消息重试":订单和消息在同库事务内,要么同时成功要么同时失败,不存在跨库不一致;消息投递失败可以反复扫描重试,因为消费端做了幂等,重复消息不会造成脏数据。
我在项目中大量使用这个模式后,遇到的最大实际问题是消息表的数据积压和扫描性能。因为每个订单至少会产生一条消息,高峰期消息表膨胀到千万级别。我的处理办法是"消息表按天做物理分表",每天切一张新表,扫描任务只扫当天的pending记录,彻底解决扫描慢的问题。
4.3 Saga与TCC:什么时候才值得上
事务的另一种常见方案是Saga和TCC。这两种都属于补偿型方案,区别在于Saga更偏"流程性长事务",TCC更偏"资源型短事务"。
TCC(Try-Confirm-Cancel)在PHP里落地相对繁琐:每个资源操作要写三个方法,Try阶段做资源预留,Confirm阶段真正扣减,Cancel阶段回滚预留资源。优点是数据一致性强,业务侵入高;缺点是代码量几乎是原来的三到四倍,这还没算上每个TCC调用都需要一套独立的分布式事务状态管理。
我在模拟项目X里只在两个场景用了TCC:一个是库存扣减(必须防止超卖且能精确回滚),一个是账户余额变更(不能出现负数余额)。其余场景,比如订单创建、支付回调通知、积分变更,全部走消息最终一致性,已经满足了业务要求。
Saga的适用场景则是那种"一个操作跨多个独立服务、没有强一致要求、但需要补偿"的长链路。比如创建订单+创建物流单+创建发票,任何一步失败都要逐级反向撤销前面的动作。实现上通常用状态机来编排每一步以及对应的补偿动作,相对复杂。一般团队没有充分理由不建议自己造Saga轮子,直接考虑成熟的分布式事务中间件更划算。
4.4 事务降级与对账补偿兜底
即使用了上面所有办法,线上依然可能出现极端状况:MQ集群短暂故障、补偿任务被kill、消息重复消费导致幂等失效。所以最后一道防线是对账系统。
我习惯在每个分库的表上都预留一个version或updated_at字段,后台每半小时跑一次跨库对账任务,把"订单表实收金额"和"财务流水表累计金额"拉出来比对。一旦发现不一致,自动生成告警工单,并触发补偿脚本按业务规则回滚或补齐数据。
这个兜底方案的成本很低,但价值极高。它保证即使上层事务机制出漏洞,也能在一个可接受的时间窗口内被发现并修正。你可以把它理解成大楼的消防通道:平时不会用到,但真出事了能救命。
5. 数据分布策略与扩容:先分片还是先扩容
分库分表还会遇到一个非常容易走错的路:刚开始只有几个库里几百张表,觉得"一步到位"直接拆64个库、每库1024张表,结果维护成本爆炸。而另一种极端是按总数据量拍脑袋选分片数,半年后数据增长两倍,所有表又得重新迁移。数据分布策略本质上是"预测业务量+预分配容量"的平衡题。
5.1 一致性哈希的均衡假象
一致性哈希是分布式缓存领域的经典方案,映射到数据库分片上时,它的思路是把分片键(比如user_id)哈希成一个环上的点,每个分片负责环上的一段区间。新增节点时只影响相邻的一个分片,理论迁移量很小。
但实际落地时会发现一致性哈希有两个麻烦。
第一是数据倾斜。如果只按user_id哈希,而热点用户(比如头部商家)恰好集中到某个哈希区间,该库就会成为性能瓶颈。虽然虚拟节点能缓解,但缓解不了"业务天然聚集"的问题。
第二是路由复杂度上升。哈希环上的位置映射关系要动态维护,应用层可能需要访问配置中心才能确定某个用户落在哪个分片。一旦配置中心抖动或缓存不一致,路由就会出错,排查链路明显变长。我用一致性哈希做过一次订单分片,后来实在受不了配置同步问题,换回了取模+范围组合的方案。
5.2 取模分片与range分片的热点问题
取模分片(比如db_index = user_id % 64)实现最简单,路由计算零成本,扩展时只需把模数改大并做数据迁移。它的硬伤是:热点用户产生的数据全部落在同一个分片内,如果这个分片的数据量达到亿级,同样会撞上单表性能天花板。另一个问题是扩展性差——从64取模变到128取模,理论上只有一半数据需要迁移,但迁移量仍然很大,而且迁移过程里新旧路由规则必须同时在线,复杂度不低。
range分片(按时间范围、ID范围分片)在"冷热分离"场景下很有效——把历史数据归档到冷库、新数据留在热库。问题是数据分布不均匀:某个月活动大促,该月所在分片数据量激增;某些月份几乎没人下单,对应的分片资源白白浪费。我见过一个团队用按月份分片,结果双十一那个月的分片表被写坏,其他月分片却很闲。
5.3 预分片:从第一天就为扩容铺路
现在更稳妥的做法是预分片。所谓预分片,不是按当前数据量定分片数,而是按"预期的悲观数据量"定分片数。比如你判断3年后订单量会到5亿,那就按5亿的表容量,以每张表最多500万行,需要100张表。然后在建表第一天就创建100张物理表,但逻辑上只启用前16张,后续数据量增长时只需把新表加入路由规则,不需要做物理数据迁移。
这个方案的核心价值在于:把"数据迁移"转化成"路由规则更新"。物理表从一开始就不需要动,路由规则变一下,新数据就自然流到新表里。存量数据的迁移基本为零,只有路由配置发生变化。
我在实施预分片时踩过一个小坑:因为提前创建了大量空表,DBA的日常巡检和表空间统计会变得很难看,运维自动化脚本也会把空表误判为异常对象。后来在初始化脚本里加了一个"分片注册表",只在启用某张表时把它的元数据写入路由配置,其他空表保持休眠状态,既达到了预分片的目的,又不干扰运维巡检。
5.4 历史数据平滑迁移:从双写到灰度切流
如果已经上线了、且需要做分片调整(比如从按月分片改成按用户分片),那就没法避免迁移了。此时最常用也最稳的方案是双写+灰度切换。
流程大概是:
- 在旧分片按原逻辑继续读写的同时,把写入流量同步复制一份到新分片
- 后台跑全量数据校验,比对两边数据是否一致
- 全量确认后,把读流量按比例灰度切到新分片(比如先切5%,再切20%,再到100%)
- 灰度期间持续监控新分片的性能、延迟、错误率
- 全部切完后,停掉旧分片写入,把旧分片数据降级为备份
我在做模拟项目X的分片改造时,双写失败的主要原因是数据重复。因为老代码和新代码同时写入,某个特定更新操作没有做幂等,导致新分片里插入了重复行。最后靠在全量校验任务里增加一个dedup检查,才把问题暴露出来。所以双写改造上线前,务必给所有核心数据表加上业务唯一键,并且在迁移过程中持续跑幂等校验任务,不要想着切换后再清理。
6. 被忽略的运维侧问题:唯一索引、全链路追踪与数据归档
最后这部分容易被技术方案文档一笔带过,但我在实际生产中觉得最费时间。分库分表做完之后,很多"单库时代习以为常"的运维能力消失了,你得自己想办法补回来。
6.1 全局唯一索引是被分库分表吞掉的隐藏能力
单库时代,你可以对任意业务字段建唯一索引,比如user_mobile一列上加UNIQUE,数据库能保证全局不会出现重复手机号。分库分表之后,这种全局唯一索引只在"同一分片内"有效:两张不同表里可以同时存在phone = 13800138000,因为它们分别落在不同分片,唯一索引约束根本碰不到对方。
解决思路通常有两种。一是在业务代码里做前置校验(查询全部可能的分片,确认手机号未被占用后再插入),这会带来额外的查询开销。二是用"汇总表/注册表"单独存储全局唯一键,插入数据前先向注册表插入并依赖注册表的唯一索引兜底。注册表的写入量必须很小,否则它自己也会成为瓶颈。
我的建议是:全局唯一键越少越好,不要贪多,控制在业务上必须的唯一字段。手机号、业务单号这种必须唯一,其他什么"用户昵称唯一"、"商品编码唯一"能放开就放开,否则你要多维护一堆注册表,反而增加系统的复杂度和故障点。
6.2 多库日志归档与全链路追踪
分库分表之后,一次请求可能涉及多个库的多条SQL,出了问题要查"这条数据在哪个库哪张表",没有链路追踪工具会把人逼疯。我在项目里集成了一套简单的链路ID方案:在PHP入口处用uniqid()生成一个trace_id,通过HTTP Header和消息队列的producer属性传递到下游服务,所有应用的日志框架统一把trace_id打到日志里。排查时只需要拿trace_id去日志搜索,就能把一次请求经过的所有库、所有消息队列、所有服务的日志串起来。
这个方案的坑是:如果PHP-FPM进程里有异步任务(比如pcntl_fork或者Swoole的Task进程),继承不到上层进程的trace_id,链路上就断了。处理方式是让子进程在启动时读取环境变量或Redis里的trace上下文,确保链路ID能跨进程传递。
另外,分库分表之后,传统"一张表一个慢查询日志"的思路也要调整。建议为每个分片库开启独立日志,并定期汇总到统一的慢查询分析平台,否则你没办法快速定位像"某个库的SELECT * FROM orders WHERE order_no = ?特别慢"这类问题。
6.3 冷热数据分离与归档策略
最后说归档。单个分片表的数据量如果不受控,无论分片数多少,单表的数据增长都会让查询性能缓慢下滑。实际上分库分表不能消除数据膨胀问题,它能把膨胀均匀分散到多个表,但每张表内部仍然会涨。
运维层面的解决思路是冷热分离:把超过一定时间范围的订单数据从热表迁移到冷表/归档表。冷表可以放在同一个实例但用不同的存储引擎(比如压缩率更高的引擎),也可以干脆推到对象存储里。热表只保留最近3个月到1年的活跃数据,让单表行数维持在一个稳定水平。
我在做归档时遇到的实际问题是:归档任务也是读库任务,如果直接在高流量时段跑,会拖垮正库性能。最终方案是把归档任务放到每天业务低峰期执行,并且按分片拆成多个小批次,每批处理一部分,减少单次锁表和IO压力。归档时顺手做一份备份到对象存储,万一业务方要查历史数据,再从对象存储恢复或者直接提供冷查接口。
冷热分离的收益比很多人想象的更大,它其实比增加分片数更有效。因为数据库的性能瓶颈通常来自"活跃数据量"而非"总数据量",把活跃数据压缩在一个合理范围内,比盲目扩容更能稳定性能曲线。
7. 我的最后几点实操建议
如果你正在开发一个分库分表方案,我的建议是:分片键的选择比任何其他决策都重要,务必先列全所有高频查询条件,看看哪些条件是天然可以被一个共同字段统一的。比如订单系统里,几乎所有查询都带user_id,那user_id就是最佳分片键;有些查询带order_no不带user_id,那就该通过映射表把order_no关联到user_id,或者在order_no表也放一份冗余副本。把分片键定好,后面很多跨库问题其实都不会出现。
其次,别执着于让所有操作都支持跨分片查询。设计API时明确告诉业务方:这个查询必须带分片键,否则只能走提供的检索服务。与其在代码层做复杂的全分片路由,不如把"不分片键不允许执行"变成规范,逼着设计者一开始就把查询模型想清楚。
最后,务必为所有入口做幂等设计。分库分表之后,MQ重试、双写补偿、对账任务都会让同一数据被写入多次。任何写操作前先查一下该业务单号是否已存在,是成本最低的保护措施。我在项目上线后补幂等逻辑补到吐,如果一开始就把幂等当基础能力做,后面能省一半的故障处理时间。