如何在一个 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:
- 为 Payment 分配标识。
- 加载客户提交的所有 GiftCoupon。
- 验证优惠券是否存在、是否属于当前客户、是否有效以及是否已经使用。
- 调用 GiftCoupon Aggregate 的领域方法完成核销。
- 创建 Payment Aggregate。
- 通过两个 Repository 保存变更。
- 使用同一个 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 需要在插入数据库后生成,可以在同一个显式事务中分两次执行数据库操作:
- 插入 Payment 并取得 PaymentID;
- 使用该 PaymentID 更新 GiftCoupon;
- 提交事务。
执行两次 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:
- 请求 A 查询到优惠券尚未使用;
- 请求 B 也查询到优惠券尚未使用;
- A 创建 Payment 并核销优惠券;
- B 也尝试创建 Payment 并核销同一张优惠券。
因此,数据库层必须提供并发保护。常见做法包括:
- 使用乐观并发版本字段;
- 执行带条件的原子更新;
- 在事务中为优惠券记录添加适当的行锁;
- 使用能够表达“未使用”唯一性的数据库约束。
例如,可以执行下面这样的条件更新:
UPDATE GiftCoupon
SET UsedForPaymentID = @PaymentID
WHERE GiftCouponID = @GiftCouponID
AND UsedForPaymentID IS NULL;
然后检查受影响的行数:
- 受影响行数为
1,表示核销成功; - 受影响行数为
0,表示优惠券不存在,或者已被其他事务使用。
如果一次支付需要使用多张优惠券,只要其中一张更新失败,就应回滚整个事务。
领域对象中的 IsUsed 检查仍有必要,因为它表达了业务规则。数据库条件更新或其他并发控制措施,则负责在实际的并发竞争中保证这条规则不会被破坏。
是否需要重新审视事务要求
首先要明确以下业务规则:
- 优惠券核销成功但 Payment 创建失败,是否可以接受?
- Payment 创建成功但部分优惠券尚未核销,是否可以接受?
- 支付失败后,已经占用的优惠券应该立即恢复,还是转入人工处理?
- 优惠券是否可能通过外部服务核销,或者跨系统、跨数据库使用?
如果业务要求支付创建和所有优惠券核销必须全部成功或全部失败,而且数据位于同一个数据库中,那么本地数据库事务很合适。
如果业务允许短暂的不一致,可以采用下面的流程:
- 创建状态为
Pending的 Payment; - 逐张预占或核销 GiftCoupon;
- 全部成功后,将 Payment 标记为
Completed; - 任一步失败时,释放已经预占的优惠券,并将 Payment 标记为
Failed。
这种方式接近 Saga 或 Process Manager,适合跨服务、跨数据库,或者无法使用统一事务的场景。不过,它的复杂度更高,需要处理:
- 消息重复;
- 幂等;
- 超时;
- 补偿失败;
- 中间状态;
- 最终一致性。
对于单体应用和单数据库,没有必要为了形式上的 Aggregate 边界引入这些复杂机制。
CQRS 是否适合
CQRS 与“一个事务中使用两个 Repository”没有直接关系。
CQRS 用于分离读模型和写模型的职责,比较适合以下情况:
- 查询模型和领域写模型差异很大;
- 查询负载和写入负载需要分别扩展;
- 写入侧包含复杂的领域行为;
- 系统可以承担额外的同步和一致性成本。
即使采用 CQRS,写入侧仍要决定如何保证 Payment 与 GiftCoupon 的一致性。CQRS 不会自动提供跨 Aggregate 事务,也不会自动解决优惠券重复核销的问题。
对于当前场景,可以先采用以下设计:
- 独立的
Payment和GiftCouponAggregate; - 一个负责协调业务流程的应用服务;
- 两个 Repository;
- 一个共享的 Unit of Work;
- 数据库级并发保护;
- 整个操作只提交一次。
只有当查询和写入的复杂度确实需要分离时,再考虑 CQRS,不要把它当成多 Repository 事务的解决方案。
备注:内容仅供参考。
