跳到正文

DDD 与微服务:拆分、库隔离与分布式一致性 ​

一句话:微服务的边界应当来自限界上下文,但「一个上下文一个库」是目标状态而不是前置条件。拆不拆、隔不隔离,取决于一致性要求、团队所有权和事务成本三个变量;跨边界一致性默认用 Outbox + 幂等 + Saga 换最终一致。

为什么需要 ​

按技术层拆服务会得到「分布式单体」:一次下单穿过 controller、service、dao 三个服务,每个服务都缺一部分数据,最后靠 RPC 串起来,事务越补越多。按上下文拆的收益是每个服务内聚一块业务,团队能独立改模型和发版。

但另一个极端同样常见:一开始就要求每个上下文独立数据库、独立集群,结果跨边界查询全部改成 RPC,事务成本高到业务无法接受,团队最后又悄悄共享库。边界是逻辑的,隔离是渐进的,两者要分开决策。

核心机制 ​

先划上下文,再决定服务 ​

关系说明
一个上下文 → 一个服务最常见,但不是唯一
一个上下文 → 多个服务上下文大,读写或异步衍生部分独立部署
多个上下文 → 一个服务小上下文合并部署,常见于起步期和模块化单体

服务拆分的判断标准是团队边界、发版节奏、故障爆炸半径,不是「上下文数量」。同一个团队、同节奏发版、故障一起波及的上下文,先放在一个进程里,用包边界约束依赖。

库隔离:按三个变量给分界 ​

不要问「要不要共享库」,要问「这条一致性要求值多少钱」:

条件隔离方式代价适用阶段
同一聚合内强一致;同一团队;事务只写少量行同库同 schema,一个本地事务无跨库成本;边界靠代码约束起步期、模块化单体
不同上下文;同一团队;短期需要强一致同实例不同 schema,禁止跨 schema 写;读走 API 或只读视图仍有单库故障域;迁移要改连接和权限过渡期
不同上下文;不同团队;可接受秒级最终一致独立数据库;Outbox/Saga投影延迟、重复消费、补偿逻辑目标状态
强合规、独立扩缩容、故障隔离硬要求独立库 + CDC/专属集群运维成本最高核心域或受监管数据

判断顺序:

  1. 一致性:哪些不变量必须立即成立?只有聚合内不变量必须本地事务;跨聚合的「同时成功」通常是业务假设,不是硬约束。
  2. 团队所有权:谁能改 schema、谁承担故障?两个团队都能改同一张表,迟早互相破坏。
  3. 事务成本:跨库一次写要多少延迟、多少失败重试、能否补偿?成本高于收益时,schema 隔离是合理中间态。

共享表是反模式,但共享实例 + schema 隔离 + 写权限收口是可行的迁移路径。关键是写入口唯一:其他上下文只能读视图或调 API,不能直接写。

跨边界一致性:Outbox 是底座 ​

状态变更和事件发布必须原子。Outbox 把事件写进同库同一事务,再由中继任务投递:

sql
CREATE TABLE outbox (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_id VARCHAR(64)  NOT NULL,
  event_type   VARCHAR(64)  NOT NULL,
  payload      JSON         NOT NULL,
  created_at   TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  published_at TIMESTAMP(3) NULL,
  KEY idx_unpublished (published_at, id)
) ENGINE=InnoDB;
java
@Transactional
public void pay(PayOrderCommand cmd) {
    Order order = orders.findById(cmd.orderId()).orElseThrow();
    order.pay(clock.instant());
    orders.save(order);                       // UPDATE ... WHERE version = ?
    outbox.append(order.pullEvents());         // 同一本地事务
}

中继任务用 FOR UPDATE SKIP LOCKED 避免多实例抢同一批(MySQL 8.0+):

sql
SELECT id, event_type, payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED;

投递成功后回写 published_at;失败保留,靠重试和告警兜底。消费端必须幂等:建 (consumer, event_id) 唯一索引,重复投递直接命中唯一键。

Saga 与 2PC 的取舍 ​

维度2PC/XASaga
一致性强一致最终一致
可用性协调者与参与者锁资源,故障时可能阻塞无全局锁,局部事务快速提交
失败处理协议回滚显式补偿动作
适用参与者都支持 XA、强一致是硬要求微服务跨边界协作的默认选择

Saga 的难点不是「调用下一步」,而是补偿的幂等和语义:扣款补偿是退款还是撤销授权?补偿失败谁来兜?这些问题必须在业务层回答,不能交给框架。

实战场景 ​

一、服务拆了六个,库还是一个 ​

现象:交易域按限界上下文拆成 6 个服务,代码边界清晰,但所有服务连同一个 MySQL 实例、共用同一套表。三个月后:加一个字段要 4 个团队排队评审;一次 DDL 锁表导致 6 个服务同时抖动;两个服务各自更新 order_status,出现互相覆盖。

排查:库隔离要按三个变量给分界,不是按服务数一刀切。

定位层取证据判定
表的写入口清单按表统计哪些服务有写权限(从 SQL 日志按 digest 聚合来源服务)一张表多个写服务 → 归属未定
变更耦合度DDL 变更单关联的服务数与排期天数排期互相阻塞 → 数据边界就是缺失的那层
一致性要求跨服务的写是否被要求同一事务强要求 → 说明边界划错,应合回同一聚合/服务
团队所有权每个上下文是否有明确业务与技术 owner无 owner 的表会被所有人写
事务成本跨服务同事务的比例与耗时比例高 → 拆分带来的不是自治而是分布式事务

“一个上下文一个库”是目标状态,不是前置条件;分界依据是一致性要求、团队所有权和事务成本三个变量。

对症做法:

text
1. 先定归属,再拆库:每张表只能有一个写服务;其余服务改为通过接口或事件读,
   不允许直连别人的表。
2. 过渡手段(不必一次到位):
   - 逻辑隔离:同一实例不同 schema + 独立账号,写权限按服务发放;
   - 视图收敛:给只读方提供视图或读模型,禁止直接 UPDATE;
   - 先拆高冲突表(order_status 这类多写表),再拆低频表。
3. 跨服务读需求用“数据所有权 + 快照”解决:订单上下文持有商品快照,
   不查询商品库,见 01 篇的 ACL 与只读快照策略。
4. 拆完必须配事件集成:没有 Outbox 的拆分等于把一致性交给运气。
sql
-- 权限层面的强制:账号即边界
GRANT SELECT ON trade.products TO 'settlement_svc'@'%';        -- 结算只能读商品
GRANT SELECT, INSERT, UPDATE ON trade.orders TO 'order_svc'@'%'; -- 订单表只有订单服务能写

复盘要点:判据是“每张表的写服务数 = 1”,用 SQL 日志按来源账号定期审计,命中多写即整改。DDL 排期天数也是信号:一次变更涉及 3 个以上团队说明边界仍共享。服务拆分带来的收益要能验证——独立发布率、故障隔离演练结果,两者没有改善就不要继续拆。

二、“先提交再发消息”丢事件,下游状态永久错位 ​

现象:订单服务在本地事务提交后直接调用 MQ 发送 OrderPaid。某天 MQ 集群抖动 30 秒,出现 14 笔“订单已支付但未进入履约”的卡单(经验值,需校准);对账任务只在第二天发现,客户已投诉。日志里既没有发送失败记录,也没有补偿痕迹。

排查:跨系统写有两个独立提交点,崩溃窗口就藏在中间。

定位层取证据判定
发送失败是否可见应用日志/指标里 MQ 发送异常计数为 0 但有卡单 → 异常被吞或未被统计
顺序代码里发送是否在事务方法内或事务之后两种顺序都有缺口,只有同事务写入才安全
是否有重试失败后是否有补投机制无重试即永久丢
消费端幂等补投时是否重复生效无幂等键时补投会造成重复处理
对账口径是否有“订单已支付但无履约单”的核对任务缺失即只能靠客户投诉发现

对症做法:

java
// Outbox:事件与业务写同一事务落库,投递由独立任务负责
@Transactional(rollbackFor = Exception.class)
public void markPaid(OrderId id, Money amount) {
    Order o = orders.load(id);
    o.markPaid(amount);
    orders.save(o);
    outbox.insert(new OutboxRow(id.value(), "OrderPaid",            // 同事务,绝不丢
            json(new OrderPaid(id, amount, Instant.now(clock)))));
}

// 投递器:轮询或 CDC,成功后标记;失败退避重试,不阻塞业务
public void deliver() {
    for (OutboxRow r : outbox.pendingBatch(200)) {
        try { mq.send(r.topic(), r.payload()); outbox.markSent(r.id()); }
        catch (Exception e) { outbox.bumpAttempt(r.id(), nextDelay(r.attempts())); }  // 指数退避 + jitter
    }
}
java
// 消费端幂等:补投至少一次,语义靠唯一键收敛为“恰好生效一次”
public void onOrderPaid(OrderPaid e) {
    if (processed.exists(e.eventId())) return;
    txTemplate.execute(s -> { fulfillment.create(e.orderId()); processed.mark(e.eventId()); return null; });
}
text
投递器要加监控:未投递条数、最老未投递时间、重试次数分布;
“最老未投递 > 60s” 直接告警,比下游卡单早几分钟发现。

复盘要点:跨边界一致性默认用 Outbox + 幂等 + Saga 换最终一致,Outbox 是底座。验收口径是“注入 MQ 不可用 5 分钟,期间零丢事件、恢复后全部补齐且不重复”。反方向也要防:先发后提交会让下游收到不存在的事实,这条同样靠“事件与业务写同事务”解决。

三、Saga 补偿也失败:长流程停在中间态 ​

现象:“下单 → 扣库存 → 锁券 → 建履约单”用编排式 Saga。一次锁券失败触发补偿,但库存补偿接口超时,30 余笔停在“已回滚订单、库存仍锁定”的中间态;三天后靠人工核对才释放,期间这些库存无法售卖。

排查:Saga 的可靠性取决于补偿本身的可执行性。

定位层取证据判定
流程状态分布状态表里停留在中间态超过阈值的实例数存在“卡住的 saga”即缺少兜底
补偿失败记录每步补偿的返回码与异常补偿超时/异常被忽略是常见缺陷
补偿幂等同一补偿重复执行是否安全不幂等则重试反而制造新问题
超时与重试补偿是否有超时上限与最大重试次数无上限会让 saga 永不结束
人工出口是否有“重试 N 次后转人工”队列无出口就只能靠对账发现
隔离性中间态是否被外部读到没有预留机制时他人会读到锁定的库存

没有隔离性是 Saga 的固有代价;补偿可能失败要按设计对待,而不是按异常对待。

对症做法:

java
// 1. 每步补偿都有超时、有限重试与终态;重试耗尽转人工队列,不无限挂起
public enum SagaStepStatus { DONE, COMPENSATED, NEED_HUMAN }

void compensateWithRetry(StockReserve reserve) {
    for (int i = 0; i < 5 && !released; i++) {
        try { released = stockGateway.release(reserve.idempotencyKey()); }   // 幂等键保证重复安全
        catch (TimeoutException e) { sleepWithJitter(base * (1L << i)); }
    }
    if (!released) {
        saga.mark(reserve.sagaId(), NEED_HUMAN);
        alert.p2("saga compensation stuck", reserve.sagaId(), reserve.sku());
    }
}

// 2. 状态机 + 定时器:任何非终态超过预算就被扫描出来处理
//    每天扫 status NOT IN (COMPLETED, COMPENSATED, FAILED_FINAL) AND updated_at < now() - 30min
text
3. 预留语义显式化:库存用“冻结/释放/扣减”三态,中间态对售卖可见为不可用但可回收,
   避免把锁库存当成永久占用;
4. 长流程不要用分布式强一致硬扛(2PC 锁持有时间长,跨外部系统不可用);
   能同步收敛的步骤合到一个聚合里,只把真正跨系统的步骤交给 Saga;
5. 人工队列要有 SLA 与操作手册:卡单处理是运维流程,不是临时救火。

复盘要点:判据是“非终态 saga 数量与最长停留时间”,两者都要有预算并接告警;补偿失败率单独统计,超过阈值说明某个下游的补偿接口不可靠,要在契约层面解决而不是加重试。取舍要说清:Saga 换的是可用性与跨系统可编排,付出的是隔离性和补偿复杂度;2PC/XA 换强一致,付出的是锁持有时间与参与者可用性,见 分布式事务。

最小上手 ​

用一条支付链路验证整条一致性链路:

  1. 建库建表:执行上面的 outbox DDL,业务表加 version 列。
  2. 写应用服务:状态更新和 outbox.append 放在同一个 @Transactional 方法里。
  3. 写中继:@Scheduled(fixedDelay = 1000) 拉取未发送记录,投递 MQ 后更新 published_at。
  4. 写消费者:处理前 INSERT 幂等表,唯一键冲突就跳过。
  5. 验证:停掉中继,支付一笔订单,确认业务状态已变、outbox.published_at IS NULL 有记录;恢复中继后记录被投递,消费者只处理一次。

验证 SQL:

sql
SELECT COUNT(*) AS pending, MIN(created_at) AS oldest
FROM outbox WHERE published_at IS NULL;

pending 持续增长或 oldest 越来越老,说明投递链路已断,不是「消息慢」。

生产动作 ​

  • 库隔离决策表进设计文档:每个跨上下文依赖写清一致性要求、隔离方式、补偿策略和 owner。没有这张表,拆库讨论会变成偏好之争。
  • 写权限收口:用数据库账号或服务网格限制「只有 owner 能写本 schema」;SHOW GRANTS FOR 'svc_order'@'%' 可核对。共享表却多写入口,是最难排查的一类污染。
  • Outbox 监控:未发送条数、最老记录年龄、中继失败率、消费延迟、死信数。最老年龄持续上升优先于条数,因为它直接反映卡了多久。
  • 排障:状态已提交但下游没收到 → 查 outbox 该聚合的未发送记录和 MQ 发送日志 → 若记录不存在,说明事件没和状态同事务;若记录存在但未发送,查中继和 MQ 连接。不要手工补发后就算完。
  • 排障:下游数据翻倍 → 查消费端幂等键、唯一索引和重试记录 → 没有幂等键就补,不能假设「MQ 只投一次」。
  • 排障:一次业务跨五个服务 → 用链路追踪统计跳数和数据归属 → 多个服务写同一张表就判定边界未划开,先收口写权限,再谈拆服务。

边界 ​

  • 模块化单体不是落后:一个进程内按上下文分模块、用 ArchUnit 约束依赖,能获得大部分边界收益,运维成本最低。
  • 不要为了拆而拆:团队没独立、发版没独立、故障域没独立,拆服务只会增加网络和一致性成本。
  • 最终一致不是「最终一定一致」:没有对账和补偿,消息丢了就是永久不一致。核心账务要有日终对账。
  • 2PC 不是洪水猛兽:参与者都支持 XA、且强一致是监管硬要求时,它仍然有效;只是在微服务里通常不是默认选项。
  • 事件 schema 要版本化:消费者可能滞后于生产者,新增字段必须可选,删除字段要先停用再删。

讲清楚 ​

DDD 和微服务的结合点只有一个:边界来自限界上下文,不来自技术分层。但上下文是逻辑边界,库和服务是物理落地,中间要按一致性、团队所有权、事务成本做决策。起步期可以同库同 schema,过渡期用 schema 隔离并收口写权限,只有跨团队、可接受最终一致时才拆独立库。跨边界一致性用 Outbox 把状态和事件写进同一本地事务,中继投递,消费端按业务键幂等;长流程用 Saga 补偿,2PC 只在强一致硬要求且参与者支持时使用。边界划错,基础设施越强烂得越快;边界划对,晚一点拆库也不会乱。

初稿 2026-10 | 最后修订 2026-10

把一门技术讲透到既懂也会用