XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

工作单元与事务 ​

这一页讲 IUnitOfWork:它的状态与成员、Transaction 阶段怎样为每次调用开启、提交与回滚、数据库事务为什么延迟到第一次写入、领域事件在提交前怎样派发,以及在管道之外自己开工作单元的写法。读完它,你能判断一次调用里哪些写入会一起提交、失败时会回滚什么,并能在后台任务里正确地读写数据。前置知识是仓储读写与执行管道。

为什么需要 ​

没有工作单元时,每个操作都要自己开事务、决定何时提交、异常时记得回滚,忘了任何一步都会留下半截数据;领域事件发早了事务可能回滚,发晚了又可能丢。框架把事务边界放进管道的 Transaction 阶段:每次调用一个工作单元,处理器只决定成败,提交与回滚由中间件按固定顺序执行,领域事件在提交前派发。数据库事务延迟到第一次写入才开,只读调用不占事务。

概念 ​

工作单元是一次调用的事务边界,作用域生命周期:同一个服务作用域里,仓储取到的是同一个 IUnitOfWork。它有四个状态,只能按 NotStarted → Active → Committed | RolledBack 推进;重复开始、未开始就提交这类违反顺序的调用抛出携带 PERSISTENCE_VALIDATION_002 的 FrameworkException。仓储只在 Active 状态下工作,否则抛出携带 PERSISTENCE_UNAVAILABLE_001 的 FrameworkException。

成员说明
State当前状态(UnitOfWorkState)
TenantId当前租户,无租户为 null;多租户实体据此写入与过滤
IsReadOnly是否只读
HasTransaction此刻是否已开数据库事务:第一次写入时开启,提交或回滚后复位
DomainEvents本工作单元写过的聚合(DomainEventTracker),按首次写入顺序、同一实例只记一次
BeginAsync(options, ct)开始,只能调用一次
CommitAsync(ct)提交,返回 Result;并发冲突、标识重复、容量已满以失败结果返回
RollbackAsync(ct)回滚

UnitOfWorkOptions(TenantId, IsReadOnly, Connection) 是开启选项:TenantId 是当前租户;IsReadOnly 开只读工作单元(不开事务、拒绝写入);Connection 绑定一个命名连接,只在它上面读写、开事务(见连接与运行时数据源);ReadsPrimary 让只读工作单元读主库;UnitOfWorkOptions.Default 是无租户、可写、不绑定连接。

工作单元由持久化 Provider 实现并注册;Provider 的特性同时把 UnitOfWorkMiddleware 登记到 Transaction 阶段。

管道里的工作单元 ​

经管道的每次调用(HTTP、gRPC、MCP、命令行、消息派发)都经过 Transaction 阶段,UnitOfWorkMiddleware 按下面的顺序处理:

  1. 从调用的作用域取 IUnitOfWork。它已经开始过(同一个服务作用域里的嵌套调用)时,以 PERSISTENCE_VALIDATION_002 结束本次调用,不执行处理器。
  2. 以 Tenant 阶段写入安全上下文的租户开始它:操作契约只读(动词 get / list,或 ReadOnly = true)时开只读工作单元,其余开可写工作单元。
  3. 调用 Handle 阶段的处理器。
  4. 处理器抛出异常:用 CancellationToken.None 回滚,异常原样抛出,由内核转为 Unexpected 结果。
  5. 调用结果是失败:回滚,保留原失败结果。
  6. 有聚合带着未派发的领域事件:没有派发器时以 PERSISTENCE_VALIDATION_003 结束并回滚;派发失败时回滚,调用以该失败结束;派发成功清空登记。
  7. 提交;提交失败时用它覆盖调用结果,调用方看到真实的持久化错误。

所以操作代码不调用提交,只决定成败:返回 Result 失败或抛出异常,本次调用的全部写入都回滚,包括批量方法里已通过校验的项。

是否已开数据库事务 ​

EF Core 与 SqlSugar Provider 在第一次写入时才开事务,只读过数据的调用不开事务,提交时也无事可做。写操作的工作单元从调用开始就是 Active,但事务要到第一次写入才开,State 说明不了这一点,IUnitOfWork.HasTransaction 报告此刻是否开着数据库事务:查找、查询、计数与空集合的批量方法之后为假;仓储的单条写入或非空批量写入之后(不论成败)、EnsureTransactionAsync 之后为真;提交(成功或返回失败)与回滚之后为假;只读工作单元里恒为假。多连接时一个值,任一连接开着即为真;绑定命名连接的工作单元只报告那个连接;内存实现没有数据库事务,第一次写入调用起同样为真。不经仓储、也不经 EnsureTransactionAsync 直接对客户端执行的写入不在事务里,也不让它变真。

调用里要立即对外产生、不随回滚撤回的副作用(立即推送、调用外部系统)之前据此判断:为假时本工作单元还没有可能回滚的写入,可以先推送、最后写库;为真时改用随提交生效的做法(发件箱、提交之后再发)。HasTransaction 只在 Active 时为真,不必再判 State。

最小示例 ​

一个操作里既读又写,看 Transaction 阶段替它做了什么。SampleMessages.ResourceMissing 是 SimpleApp 自己的消息键。

csharp
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Primitives;

namespace SimpleApp.Orders;

public sealed record AdjustAmountRequest(Guid Id, int Delta);

[Operation("orders.adjust", Permission = "orders.write")]
public sealed class AdjustAmount(IRepository<Order, Guid> orders) : IOperation<AdjustAmountRequest, OrderResponse>
{
    public async ValueTask<Result<OrderResponse>> HandleAsync(AdjustAmountRequest request, InvocationContext context)
    {
        // 读:不开数据库事务
        var found = await orders.FindAsync(request.Id, context.CancellationToken);
        if (!found.TryGetValue(out var order))
        {
            return Error.NotFound("SAMPLES_NOTFOUND_001", SampleMessages.ResourceMissing);
        }

        order.Amount += request.Delta;
        // 写:第一次写入时开事务;失败作为结果返回,中间件回滚;成功则中间件派发领域事件(如有)并提交
        var updated = await orders.UpdateAsync(order, context.CancellationToken);
        return updated.IsFailure ? updated.Error! : new OrderResponse(order.Id, order.Name, order.Amount, order.CreatedAt);
    }
}

时间线:Transaction 阶段以当前租户开可写工作单元;FindAsync 不开事务;UpdateAsync 开事务并立即写入(EF Core 与 SqlSugar)或进覆盖层(内存);处理器返回成功,中间件提交。更新失败(如并发戳过期)时处理器返回失败,中间件回滚,存储里不留痕迹。

提交与失败 ​

情况工作单元调用结果
处理器成功,没有待派发事件提交处理器的结果
处理器返回失败 / 抛出异常回滚原失败 / Unexpected
有待派发事件但没有派发器回滚PERSISTENCE_VALIDATION_003
派发器返回失败回滚派发器的失败
提交时并发冲突 / 标识重复 / 内存表超出容量回滚PERSISTENCE_CONFLICT_001 / PERSISTENCE_CONFLICT_002 / PERSISTENCE_UNAVAILABLE_002
只读工作单元拒绝过写入、EnsureTransactionAsync 抛出、EF Core 有未保存的跟踪变更回滚PERSISTENCE_UNEXPECTED_001
同一服务作用域里再经管道的嵌套调用不动外层的工作单元PERSISTENCE_VALIDATION_002(只结束内层)

标识重复通常在 AddAsync 时就按项返回,只有并发新增同一标识的后到者在内存实现上于提交时得到它,见仓储读写。

各 Provider 的事务 ​

  • 内存:写入进本工作单元的覆盖层,覆盖层叠在已提交的表上实现读己之写;提交时给涉及的表依次加锁、逐表校验首次读到的状态后应用覆盖层,一次提交涉及的全部表原子;回滚丢弃覆盖层。
  • EF Core:第一次写入时 BeginTransaction,每次写入立即 SaveChanges,同一事务内可见;提交先保存残余变更再提交事务,回滚同时清空变更跟踪器;一个 DbContext 一个连接。
  • SqlSugar:第一次写入时经 AsTenant() 在客户端的全部连接上开事务,每次写入立即执行;提交逐连接顺序提交,回滚逐连接回滚;每个连接各自原子,跨连接是顺序提交,不是两阶段提交,后一个连接提交失败时前一个已经提交,需要严格原子性的写入放在同一个库。

作用域结束时事务仍未提交(中间件之外直接使用),释放即回滚。

领域事件与发件箱 ​

仓储在新增、更新、删除(单条与批量里通过校验的项)成功后,把实现 IHasDomainEvents 的实体登记到 IUnitOfWork.DomainEvents。处理器成功后、提交前,中间件取出带有未派发事件的聚合(DomainEventTracker.Pending()),连同工作单元的租户交给 IDomainEventDispatcher.DispatchAsync。

派发器由 Messaging 子系统实现:AddDomainEvent<TEvent>(topic, type, jsonTypeInfo, subject) 登记一种事件的主题、CloudEvents 类型、源生成的 JsonTypeInfo 与可选的 subject,并注册派发器;SimpleApp 的 OrdersFeature 就这样登记 OrderPlaced。派发发生在提交之前,事件去向取决于是否装配了发件箱:

装配事件的去向与业务写入的关系
OutboxFeature写进发件箱表,与业务写入在同一事务;提交后由投递循环发到代理一起提交、一起回滚
没有发件箱直接发布到代理发布失败回滚业务写入;发布成功而随后提交失败时,事件已经发出

要让事件只随成功的写入发出,装配发件箱:builder.AddXiHan(x => x.UseStandardProfile().UseFeature<OutboxFeature>())。发件箱表、投递循环、事件到消息的映射与消费端的写法见领域事件与发件箱。

在管道外开工作单元 ​

单例服务、IHostedFeature 的启动与停止、后台循环、定时清理这些代码不经管道,没有 Transaction 阶段替它们开工作单元。框架自己的发件箱投递循环与审计落库就是这样做的:每次操作建一个服务作用域,在里面开一个工作单元。

  1. 从根服务提供者 CreateAsyncScope(),一个作用域只承载一个工作单元;按租户分库时先用 TenantShardScope.BindTenantAsync(tenantId, ct) 绑定分片。
  2. 取 IUnitOfWork,BeginAsync(UnitOfWorkOptions.Default, ct);读写租户数据时传 new UnitOfWorkOptions(tenantId)。
  3. 取仓储读写,CommitAsync 并检查返回的 Result。
  4. 提交前任何一步抛出异常时,若工作单元仍是 Active,用 CancellationToken.None 回滚。
csharp
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.Domain.Specifications;
using XiHan.Framework.Persistence.Abstractions.Queries;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Persistence.Abstractions.UnitOfWork;

namespace SimpleApp.Orders;

// 单例:每次清理自己建作用域与工作单元,不经管道
public sealed class OrderPurger(IServiceProvider services, TimeProvider clock)
{
    public async Task<int> PurgeAsync(string tenantId, CancellationToken cancellationToken)
    {
        await using var scope = services.CreateAsyncScope();
        var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
        // 订单是多租户实体:工作单元不带租户就既看不到也写不了
        await unitOfWork.BeginAsync(new UnitOfWorkOptions(tenantId), cancellationToken);
        try
        {
            var orders = scope.ServiceProvider.GetRequiredService<IRepository<Order, Guid>>();
            var cutoff = clock.GetUtcNow().AddDays(-30);
            var stale = await orders.ListAsync(EntityQuery.All<Order>().Where(Specification.Of<Order>(o => o.CreatedAt < cutoff)), cancellationToken);
            foreach (var result in await orders.RemoveRangeAsync(stale, cancellationToken))
            {
                result.ThrowIfFailure();
            }

            (await unitOfWork.CommitAsync(cancellationToken)).ThrowIfFailure();
            return stale.Count;
        }
        catch
        {
            // 取消也可能是异常的原因,回滚用 None 保证一定执行
            if (unitOfWork.State == UnitOfWorkState.Active)
            {
                await unitOfWork.RollbackAsync(CancellationToken.None);
            }

            throw;
        }
    }
}

登记为单例:builder.Services.AddSingleton<OrderPurger>();。Result.ThrowIfFailure() 把失败转成携带同一错误码的 FrameworkException,进入 catch 回滚。

按租户分库时,绑定必须在作用域里第一次创建持久化客户端之前完成;绑定只选连接,工作单元的 TenantId 仍由 UnitOfWorkOptions 给出,两者应一致。见租户分片与分库。

管道外的写入不自动派发领域事件

领域事件只由 Transaction 阶段的中间件派发。管道外写入带事件的聚合时,调用方在提交前自己派发,否则事件留在聚合的缓冲里,不会发出。

管道外需要派发时,按中间件的做法在提交前调用派发器:

csharp
var pending = unitOfWork.DomainEvents.Pending();
if (pending.Count > 0)
{
    // 派发器由 AddDomainEvent 注册;派发成功会清空各聚合的缓冲
    var dispatcher = scope.ServiceProvider.GetRequiredService<IDomainEventDispatcher>();
    (await dispatcher.DispatchAsync(pending, unitOfWork.TenantId, cancellationToken)).ThrowIfFailure();
}

(await unitOfWork.CommitAsync(cancellationToken)).ThrowIfFailure();

只读操作与只读工作单元 ​

只读操作,也就是动词是 get / list,或以 [Operation(..., ReadOnly = true)] 声明的查询变体(departments.tree、users.list-online),在 Transaction 阶段得到只读工作单元。管道外用 new UnitOfWorkOptions(IsReadOnly: true) 开启的也一样:

  • 不开数据库事务;查找、查询、计数照常。
  • 仓储的新增、更新、删除与批量形式在写入时就拒绝、不碰后端,返回 PERSISTENCE_UNEXPECTED_001,并把工作单元记为失败:处理器忽略这个失败、照常返回成功时,提交回滚并返回同一错误,误写不会被悄悄吞掉。
  • 直接用 DbContext 或 SqlSugar 客户端写入之前要调用工作单元的 EnsureTransactionAsync:只读工作单元里它抛出同码的 FrameworkException,Transaction 阶段转为结果。不经它直接执行的写入不在守卫之内,也不在事务里。
  • EF Core 提交时还查变更跟踪器:只读操作里把读出的实体改了却没保存,同样以 PERSISTENCE_UNEXPECTED_001 回滚。只读操作里要变换就映射成响应对象,不要把读出的实体当草稿改。

PERSISTENCE_UNEXPECTED_001 是操作实现的缺陷,不是调用方能改正的输入:HTTP 500、gRPC Internal。修正的办法是让操作不再写入,或者它本来就要写,就换一个非只读的动词,或把写入放进新服务作用域里的另一次调用。

框架读取租户目录与权限授予、审计查询、探测租约时自己开只读工作单元;后台的统计、报表这类只读路径也建议用它,配了从库又要读主库时设 ReadsPrimary:

csharp
await unitOfWork.BeginAsync(new UnitOfWorkOptions(IsReadOnly: true) { ReadsPrimary = true }, cancellationToken);

嵌套调用 ​

一次调用一个工作单元,各调用按自己的契约开自己的。在操作里经管道再调另一个操作(IOperationInvoker.InvokeAsync),要新建服务作用域,内层才有自己的工作单元:

csharp
// 在 orders.get 的处理器里:记一次查看,写入交给 order-views.create(scopes 是注入的 IServiceScopeFactory)
await using var scope = scopes.CreateAsyncScope();
var call = App.CreateContext(scope.ServiceProvider, context.CancellationToken);
call.SetSecurity(context.GetSecurity());   // 以调用方的身份执行,Authorize 阶段照常按契约判定
var recorded = await scope.ServiceProvider.GetRequiredService<IOperationInvoker>()
    .InvokeAsync<RecordViewRequest, RecordViewResponse>(call, RecordView.Id, new RecordViewRequest(request.Id));

只读不向内、也不向外传播:只读操作里调用的写操作在自己的可写工作单元里提交,外层随后失败也不会把它回滚;要与外层同进退的写入不应拆成嵌套调用。用同一个服务作用域再经管道(传入外层的 context)时,内层以 PERSISTENCE_VALIDATION_002 结束,处理器不执行,外层不受影响。AI 工具与 MCP 的每次 tools/call 都按这个方式执行。

错误码 ​

本页涉及的错误码:PERSISTENCE_VALIDATION_002(状态不允许,抛 FrameworkException;嵌套调用是调用结果)、PERSISTENCE_UNEXPECTED_001(只读工作单元拒绝写入,HTTP 500)、PERSISTENCE_VALIDATION_003(有事件没有派发器)、PERSISTENCE_UNAVAILABLE_001(没有活动工作单元,抛 FrameworkException),以及提交结果里的 PERSISTENCE_CONFLICT_001 / 002 与 PERSISTENCE_UNAVAILABLE_002。完整表见持久化与 Provider。

常见问题 ​

我需要在操作里手动调 CommitAsync 吗 ​

不需要,也不应该。Transaction 阶段在处理器成功返回后提交、失败或异常时回滚,操作里只返回 Result。手动提交会让中间件的提交撞上 PERSISTENCE_VALIDATION_002。

为什么我的 GET 操作写入时得到 500 ​

get / list 动词的操作开只读工作单元,写入被拒绝并以 PERSISTENCE_UNEXPECTED_001 结束。这是操作实现的缺陷:要写就换一个非只读的动词,或把写入放进新服务作用域里的另一次调用。

领域事件在没有装配发件箱时安全吗 ​

不完全安全:事件在提交前直接发布到代理,发布成功而随后提交失败时事件已经发出。要「事件只随成功的写入发出」就装配 OutboxFeature,事件与业务写入同事务。

相关包与下一步 ​

Released under The MIT License