我们当年拆微服务,在数据库上踩的最深的一个坑,是从一张订单表开始的
一次深夜的数据修复,让我真正理解了微服务拆分时数据库迁移的致命之处。那天凌晨两点,运维的电话把我从床上拽起来——订单系统全线超时,数据库连接池被打满。排查到最后,问题出在一张订单表上。我们为了追求服务拆分干净,把订单主表和订单明细表拆到了两个数据库,却忘了改一个埋在三层调用深处的关联查询。ORM 的懒加载特性让这个 N+1 查询在生产环境默默跑了三个月,直到流量翻倍才爆炸。
那是 2018 年的事,系统日订单量刚破 50 万。单体架构的 MySQL 实例 QPS 峰值已经到了 8000,慢查询告警每天能刷上百条。CTO 拍板启动微服务改造,我被分到了订单模块。
当时的订单表结构不算复杂,但关联很深。主表 orders 有 60 多个字段,外键挂到用户表、商品表、优惠券表、支付表、物流表。很多业务逻辑直接写在存储过程里,最长的一个存储过程 sp_create_order 有 2300 行,从验库存到算优惠到写流水全在一个事务里。这套东西跑了好几年,谁都不敢轻易动。
拆分的第一个争论点就卡在数据库上:到底是先拆服务还是先拆表?
架构组坚持先拆表,"数据库不拆,服务拆了也是假拆分"。我的直觉相反——代码层面先理清边界,数据库暂时不动,等新服务稳定了再做数据迁移。直觉是对的,但我当时说不出所以然。
后来翻了不少资料,才找到 Martin Fowler 在 2016 年写的那篇关于数据库解耦的文章,他明确建议"先服务后数据库"。原因很简单:代码的依赖关系比数据库的依赖关系更容易梳理和回滚。你把一个 Service 拆错了,改几行代码重新部署,十分钟搞定。你把一张表拆错了,数据迁移脚本写岔了,轻则丢数据,重则整个业务线停摆。
但我们当时还是先拆了表。原因是架构组担心"如果数据库还在一起,开发团队就没有动力去真正解耦"。这个判断事后看是错的——不是逻辑错,是低估了数据迁移的工程复杂度。
拆表方案看起来很美。orders 表按业务边界切成三块:订单核心表(order_id, user_id, status, total_amount 等基础字段)、订单支付表(支付流水号、支付渠道、回调信息等)、订单物流表。每个表放到对应服务的独立数据库里。跨库的外键约束全部干掉,改成应用层维护关联关系。
第一个坑出现在数据迁移脚本上。我们写了一套分批迁移的 Python 脚本,每次读 5000 条数据,用多线程写进三个目标库,每条记录处理完打一个迁移标记。脚本在测试环境跑了三轮,数据校验全部通过。上线那天晚上 11 点开始执行,预估 4 小时跑完全量 3200 万条数据。跑到凌晨 2 点的时候,脚本挂了——不是因为数据量大,是因为有一个批次里的订单关联的用户记录在迁移过程中被删除了,导致写入目标库时违反了我们自己设的唯一约束。
这个问题暴露了一个更深层的设计缺陷:我们在迁移过程中没有处理"正在发生变化的数据"。迁移脚本读取源库的时间点是 T1,写入目标库的时间点是 T2,在 T1 到 T2 之间,源库的数据可能已经被业务操作修改了。这就是分布式系统里经典的"读未提交"问题,只不过这次发生在数据迁移场景下。
修复方案不优雅但有效:暂停所有写操作,重新跑迁移。半夜三点,我发了一条全员通知,把订单相关的所有服务关了,包括一个正在跑的双十一预热活动。那次停机总共 47 分钟,第二天复盘的时候被业务方喷了两个小时。
跨库事务的问题紧接着就来了。拆表之前,创建订单是一个单体事务:BEGIN → INSERT orders → INSERT order_items → UPDATE inventory → INSERT payment → COMMIT。拆完之后,这个流程变成了跨四个数据库的分布式操作。
我们当时的过渡方案是"最终一致性 + 补偿"。具体做法是:订单服务先写自己的库,状态标记为 CREATING,然后发消息到 Kafka,库存服务消费消息扣库存,成功则回写订单状态为 CONFIRMED,失败则进入死信队列,由补偿服务在 15 分钟后重试,重试 3 次仍失败则标记订单为 FAILED 并触发退款流程。
这个方案在流量低的时候跑得挺好。问题出在流量上来之后。2019 年 618 大促,零点刚过,订单创建 TPS 从平时的 1200 飙到 4800。Kafka 的消费延迟从毫秒级涨到 30 秒。紧接着库存服务的数据库连接池也满了——因为它不仅要处理正常的下单请求,还要处理大量的补偿重试。最后形成恶性循环:延迟越大,超时越多;超时越多,重试越多;重试越多,延迟更大。
那一天我们临时加了三台库存服务的实例,把 Kafka 的分区从 8 个扩到 32 个,才算稳住。但根本问题没解决——用消息队列做异步解耦,确实避免了分布式事务的强依赖,但引入了一整套新的复杂度:消息乱序怎么办?消息重复消费怎么办?消费失败后业务状态如何回滚?这些问题每一个都能单独写一篇事故报告。
后来我们做了一次大的架构调整,引入了本地消息表模式。订单服务在同一个本地事务里写订单数据和一条"待发送"的消息记录,然后由一个独立的后台线程扫描这张消息表,把消息投递到 Kafka。如果投递失败,消息状态不变,下次继续扫描。这个模式的好处是:订单写库和消息持久化是同一个本地事务,不会出现"订单写成功了但消息没发出去"的情况。代价是多了一张消息表和一套扫描投递逻辑,数据量大了之后消息表本身也成了瓶颈——高峰期消息表每天新增 200 万条记录,保留 7 天就是 1400 万,查询性能下降得很快。
真正让我觉得找到了正确方向的是 2020 年的一次重构。我们重新审视了订单域的业务边界,发现之前拆分的粒度有问题。订单支付信息和订单物流信息确实属于不同的子域,但它们在业务流程上是强关联的。用户下单后立即支付,支付成功后立即触发物流,这个流程本质上是一个"长事务",强行用异步消息拆开反而增加了复杂度。
我们的解决方案是:把订单核心、支付、物流合并回一个"订单聚合服务",共享同一个数据库实例(但不是同一张表)。订单创建、支付确认、物流创建这三个操作在同一个服务内完成,用本地事务保证 ACID。只有真正跨域的操作(比如订单完成后通知积分系统加积分)才走异步消息。这个调整之后,订单模块的线上故障率从月均 2.3 次降到了 0.4 次。
回过头看,这段经历让我对微服务数据库拆分有了几个很具体的认知。
第一,数据库拆分的时机比方式重要得多。如果你的单体数据库还没到性能瓶颈(MySQL 单表 5000 万以下,QPS 5000 以下),先别拆。代码层面的模块化重构带来的收益远大于数据库拆分,风险却小得多。我们当时日订单量 50 万就急着拆,其实远远没到不得不拆的地步。
第二,如果一定要拆,遵循"先读后写"的迁移策略。先把读流量切到新库,观察一段时间,确认数据同步没问题了,再切写流量。我们后来在做用户表拆分的时候用了这个策略,通过 CDC(我们用的是 Debezium + Kafka Connect)把源库的变更实时同步到目标库,读流量切过去跑了整整两周,期间做了 12 轮数据一致性校验,最后切写流量的时候只停机了 8 分钟。
第三,跨库事务没有银弹。最终一致性方案在绝大多数业务场景下够用,但你要清楚它能容忍的不一致窗口有多长。我们当时的订单创建流程,从用户提交订单到收到确认通知,最坏情况下需要 15 分钟(补偿重试间隔)。这对普通商品可能还行,对秒杀商品就是灾难。后来我们把秒杀场景的订单流单独抽出来,走的是预扣库存 + 定时释放的乐观锁方案,不走异步消息。
再说一个细节,关于数据一致性校验。拆分之后,你永远不知道哪个角落还藏着一个跨库查询。我们的做法是在 CI/CD 流水线里加了一个静态检查脚本,扫描所有 SQL 语句和 ORM 配置,只要发现跨库关联查询就直接阻断构建。这个脚本用正则匹配 JOIN 关键字加上不同数据源的标识,粗暴但有效。跑了半年,拦下了 7 次可能引发生产事故的代码提交。
如果今天让我重新做一次微服务拆分,我会花至少 60% 的时间在数据库迁移方案的设计和验证上,而不是急着写服务代码。具体的顺序会是:先梳理业务边界,确定聚合根;在单体数据库内部通过视图或新表来模拟拆分后的结构;等新表结构稳定了,开始做数据双写;双写跑通并验证一致性后,逐步切读流量;最后切写流量,下线旧表。整个过程以月为单位推进,每一阶段都有可回滚的方案。
常见问题
数据库拆分后,跨库的 JOIN 查询怎么处理?
不要在应用层做 JOIN,也不要用联邦查询引擎去模拟。正确的做法是在设计表结构时就避免跨库关联。如果一个查询必须要关联两张表,那这两张表很可能应该放在同一个库里,说明你的拆分边界划错了。对于确实需要聚合多个数据源的查询场景,用 CQRS 模式,在写入端维护一个独立的读模型(比如 Elasticsearch 索引),查询走读模型而不是走数据库。
本地消息表方案里,消息表越来越大怎么办?
已投递成功的消息要定期清理。我们当时的策略是:消息投递成功并收到 Kafka 的 ACK 后,状态改为"已发送",每天凌晨 3 点清理 7 天前已发送的记录。对于投递失败的消息,保留 30 天,超过 30 天还未投递成功的转存到冷存储(S3 或 OSS),同时触发人工介入告警。消息表要按时间分区,清理操作用 DROP PARTITION 而不是 DELETE,避免锁表。
分布式事务一定要上 Seata 之类的框架吗?
不一定,而且大多数情况下不建议。Seata 的 AT 模式本质上是两阶段提交,性能开销大,锁持有时间长。除非你的业务对强一致性有硬性要求(比如金融转账),否则最终一致性方案更实用。我们后来在订单域用的是"本地事务 + 异步补偿",在支付域用的是"TCC 模式",没有引入全局事务协调器。
拆分过程中,怎么保证数据迁移不丢数据?
迁移前做全量校验(行数、关键字段 SUM 值),迁移过程中开启源库的 binlog 监听,增量同步变更数据,迁移完成后做最终校验并在业务低峰期短暂停写,确保两边数据完全一致再切流。我们的校验脚本每天自动跑一次,对比源库和目标库的每一条记录的 MD5 值,不一致的记录写入差异表,人工确认后修复。