PAYATHON 2026

如何在一个 Transaction 中处理多个 Aggregates 和 Repositories?

支付小周

结论

在一个业务事务中使用多个 Repository 并不违反 DDD,也没必要为了遵守所谓的“一次事务只能操作一个 Aggregate”而强行合并模型。

设计是否合理,不能只看 Repository 的数量,还要看以下问题:

  • 哪些业务规则必须在提交时保持强一致;
  • Payment 和 GiftCoupon 是否有各自独立的生命周期;
  • 优惠券核销和支付创建是否必须原子完成;
  • 并发操作时,如何避免同一张优惠券被重复使用。

在这个场景中,Payment 和 GiftCoupon 通常适合设计成两个独立的 Aggregate。应用服务可以通过两个 Repository 加载并修改它们,最后由同一个 Unit of Work 或数据库事务统一提交。

如果两者存储在同一个数据库中,这通常是最直接、可靠的做法。CQRS 不会自动解决跨 Aggregate 的事务一致性问题,不能只因为使用了两个 Repository 就引入 CQRS。

为什么不应轻易合并 Aggregate

Aggregate 的边界取决于业务不变量和生命周期,而不是一个用例需要访问多少张表。

Payment 和 GiftCoupon 往往有不同的生命周期:

  • GiftCoupon 由过去的一次购买产生;
  • 优惠券可能长期处于未使用状态;
  • Payment 在本次购买时创建;
  • 一次支付可以核销多张优惠券;
  • 同一时间,一张优惠券只能由一个支付核销。

如果把所有可用的 GiftCoupon 都放入 Payment Aggregate,会带来几个明显的问题:

  • 支付创建之前,优惠券已经独立存在;
  • 优惠券数量可能很多,Aggregate 会不断膨胀;
  • 每次支付可能需要加载大量无关数据;
  • Aggregate 之间的归属关系不符合实际的业务生命周期。

除非业务明确规定,优惠券只能作为某个支付内部的组成部分存在,否则不建议仅为了在一个事务中操作而合并这两个 Aggregate。

“Aggregate 是事务边界”通常是指:Aggregate 内部的不变量应在一次事务中保持一致,外部对象只能通过 Aggregate Root 修改它。这不代表数据库事务在任何情况下都只能修改一个 Aggregate。

推荐的处理方式

可以让应用服务协调两个 Repository:

  1. 为 Payment 分配标识。
  2. 加载客户提交的所有 GiftCoupon。
  3. 验证优惠券是否存在、是否属于当前客户、是否有效以及是否已经使用。
  4. 调用 GiftCoupon Aggregate 的领域方法完成核销。
  5. 创建 Payment Aggregate。
  6. 通过两个 Repository 保存变更。
  7. 使用同一个 Unit of Work 原子提交。

Repository 不应分别调用 SubmitChanges()。如果第一个 Repository 提交成功,而第二个提交失败,系统可能留下不完整的状态。

事务的提交应由应用服务或 Unit of Work 统一负责。

示例设计

以下示例基于这些假设:

  • Payment 和 GiftCoupon 存储在同一个支持事务的数据库中;
  • Payment ID 使用 Guid,可以在插入数据库前生成;
  • Repository 共享同一个 ORM Context 或数据库连接;
  • 具体的 ORM API 需要根据项目使用的技术调整。
public sealed class GiftCoupon
{
    public int Id { get; private set; }
    public Guid? UsedForPaymentId { get; private set; }
    public DateTime? ExpiresAt { get; private set; }

    public bool IsUsed => UsedForPaymentId.HasValue;

    public void Redeem(Guid paymentId, DateTime now)
    {
        if (IsUsed)
        {
            throw new InvalidOperationException(
                $"Gift coupon {Id} has already been used.");
        }

        if (ExpiresAt.HasValue && ExpiresAt.Value <= now)
        {
            throw new InvalidOperationException(
                $"Gift coupon {Id} has expired.");
        }

        UsedForPaymentId = paymentId;
    }
}
public sealed class Payment
{
    private readonly List<int> _giftCouponIds = new();

    public Guid Id { get; private set; }
    public IReadOnlyCollection<int> GiftCouponIds => _giftCouponIds;

    private Payment()
    {
    }

    public static Payment Create(
        Guid paymentId,
        IEnumerable<int> giftCouponIds)
    {
        var payment = new Payment
        {
            Id = paymentId
        };

        payment._giftCouponIds.AddRange(giftCouponIds.Distinct());
        return payment;
    }
}

应用服务负责协调两个 Aggregate:

public sealed class PaymentApplicationService
{
    private readonly IPaymentRepository _paymentRepository;
    private readonly IGiftCouponRepository _giftCouponRepository;
    private readonly IUnitOfWork _unitOfWork;

    public PaymentApplicationService(
        IPaymentRepository paymentRepository,
        IGiftCouponRepository giftCouponRepository,
        IUnitOfWork unitOfWork)
    {
        _paymentRepository = paymentRepository;
        _giftCouponRepository = giftCouponRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> MakePaymentAsync(
        IReadOnlyCollection<int> giftCouponIds,
        CancellationToken cancellationToken)
    {
        if (giftCouponIds.Count == 0)
        {
            throw new ArgumentException(
                "At least one gift coupon is required.",
                nameof(giftCouponIds));
        }

        var distinctCouponIds = giftCouponIds.Distinct().ToArray();

        if (distinctCouponIds.Length != giftCouponIds.Count)
        {
            throw new InvalidOperationException(
                "Duplicate gift coupon IDs are not allowed.");
        }

        var coupons = await _giftCouponRepository.GetByIdsAsync(
            distinctCouponIds,
            cancellationToken);

        if (coupons.Count != distinctCouponIds.Length)
        {
            throw new InvalidOperationException(
                "One or more gift coupons do not exist.");
        }

        var paymentId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        foreach (var coupon in coupons)
        {
            coupon.Redeem(paymentId, now);
        }

        var payment = Payment.Create(paymentId, distinctCouponIds);

        await _paymentRepository.AddAsync(
            payment,
            cancellationToken);

        await _unitOfWork.CommitAsync(cancellationToken);

        return paymentId;
    }
}

这里采用 Guid 并不是重点。重要的是,所有变更都在最后统一提交,而且两个 Repository 共享同一个 Unit of Work。

如果 Repository 使用不同的 Context,即使各自只保存一次,也无法自然地组成同一个本地事务。

使用数据库自增 PaymentID 时怎么办

如果 PaymentID 需要在插入数据库后生成,可以在同一个显式事务中分两次执行数据库操作:

  1. 插入 Payment 并取得 PaymentID;
  2. 使用该 PaymentID 更新 GiftCoupon;
  3. 提交事务。

执行两次 SQL 不等于使用两个事务。只要两次操作都属于同一个数据库事务,第二步失败时,第一步仍可回滚。

await using var transaction =
    await dbContext.Database.BeginTransactionAsync(cancellationToken);

try
{
    var payment = new PaymentEntity();

    dbContext.Payments.Add(payment);
    await dbContext.SaveChangesAsync(cancellationToken);

    foreach (var coupon in coupons)
    {
        coupon.UsedForPaymentId = payment.PaymentId;
    }

    await dbContext.SaveChangesAsync(cancellationToken);
    await transaction.CommitAsync(cancellationToken);
}
catch
{
    await transaction.RollbackAsync(cancellationToken);
    throw;
}

这段代码只用于说明事务结构,并不是针对某个 Entity Framework 版本的完整实现。

必须处理优惠券重复核销

先查询 UsedForPaymentID == null,再在内存中修改,并不足以防止并发操作造成重复核销。

两个操作员可能几乎同时输入同一个 CouponID:

  1. 请求 A 查询到优惠券尚未使用;
  2. 请求 B 也查询到优惠券尚未使用;
  3. A 创建 Payment 并核销优惠券;
  4. B 也尝试创建 Payment 并核销同一张优惠券。

因此,数据库层必须提供并发保护。常见做法包括:

  • 使用乐观并发版本字段;
  • 执行带条件的原子更新;
  • 在事务中为优惠券记录添加适当的行锁;
  • 使用能够表达“未使用”唯一性的数据库约束。

例如,可以执行下面这样的条件更新:

UPDATE GiftCoupon
SET UsedForPaymentID = @PaymentID
WHERE GiftCouponID = @GiftCouponID
  AND UsedForPaymentID IS NULL;

然后检查受影响的行数:

  • 受影响行数为 1,表示核销成功;
  • 受影响行数为 0,表示优惠券不存在,或者已被其他事务使用。

如果一次支付需要使用多张优惠券,只要其中一张更新失败,就应回滚整个事务。

领域对象中的 IsUsed 检查仍有必要,因为它表达了业务规则。数据库条件更新或其他并发控制措施,则负责在实际的并发竞争中保证这条规则不会被破坏。

是否需要重新审视事务要求

首先要明确以下业务规则:

  • 优惠券核销成功但 Payment 创建失败,是否可以接受?
  • Payment 创建成功但部分优惠券尚未核销,是否可以接受?
  • 支付失败后,已经占用的优惠券应该立即恢复,还是转入人工处理?
  • 优惠券是否可能通过外部服务核销,或者跨系统、跨数据库使用?

如果业务要求支付创建和所有优惠券核销必须全部成功或全部失败,而且数据位于同一个数据库中,那么本地数据库事务很合适。

如果业务允许短暂的不一致,可以采用下面的流程:

  1. 创建状态为 Pending 的 Payment;
  2. 逐张预占或核销 GiftCoupon;
  3. 全部成功后,将 Payment 标记为 Completed;
  4. 任一步失败时,释放已经预占的优惠券,并将 Payment 标记为 Failed。

这种方式接近 Saga 或 Process Manager,适合跨服务、跨数据库,或者无法使用统一事务的场景。不过,它的复杂度更高,需要处理:

  • 消息重复;
  • 幂等;
  • 超时;
  • 补偿失败;
  • 中间状态;
  • 最终一致性。

对于单体应用和单数据库,没有必要为了形式上的 Aggregate 边界引入这些复杂机制。

CQRS 是否适合

CQRS 与“一个事务中使用两个 Repository”没有直接关系。

CQRS 用于分离读模型和写模型的职责,比较适合以下情况:

  • 查询模型和领域写模型差异很大;
  • 查询负载和写入负载需要分别扩展;
  • 写入侧包含复杂的领域行为;
  • 系统可以承担额外的同步和一致性成本。

即使采用 CQRS,写入侧仍要决定如何保证 Payment 与 GiftCoupon 的一致性。CQRS 不会自动提供跨 Aggregate 事务,也不会自动解决优惠券重复核销的问题。

对于当前场景,可以先采用以下设计:

  • 独立的 Payment 和 GiftCoupon Aggregate;
  • 一个负责协调业务流程的应用服务;
  • 两个 Repository;
  • 一个共享的 Unit of Work;
  • 数据库级并发保护;
  • 整个操作只提交一次。

只有当查询和写入的复杂度确实需要分离时,再考虑 CQRS,不要把它当成多 Repository 事务的解决方案。

备注:内容仅供参考。