前一篇文章讲并发:两个人同时审批只有一个能成功。
这一篇讲更隐蔽的问题:一次审批要写好几处数据,如果写到一半失败会怎样?
一、一次审批到底要写多少地方
以"同意"为例,一次操作涉及:
text
1. 更新业务表:ApprovalStatus + CurrentLevel
2. 更新审批记录:当前节点标记为已同意(含意见、处理人、处理时间、审计字段)
3. 跳过同节点其他待处理记录(或签)
4. 激活下一节点(或把本轮结果回写发起记录)
5. 提交之后:发送站内信
如果不放在同一个事务里,最典型的故障是"半成功":
text
业务表:已通过 ✅
审批记录:还停在"待处理" ❌
→ 单据显示已通过,审批人待办里还挂着这个任务
或者反过来:
text
审批记录:A 已同意 ✅
业务表:还在第 1 级审批中 ❌
→ 时间线显示有人同意了,单据却像没人处理
更糟的是第 4 步失败:业务状态已经推进到第 2 级,但第 2 级的待办节点没有激活------流程悬空,谁都处理不了。
二、事务封装:ExecuteInTransactionAsync
csharp
/// <summary>
/// 在数据库事务中执行审批写操作。
/// 任一步抛异常则整体回滚,绝不允许"业务状态改了、审批记录没写"的半成功状态。
/// <para>
/// 关键点:事务体必须通过 UnitOfWork 上的仓储(ApprovalTransaction)访问数据库。
/// 直接用 _orm 的操作会游离在事务之外(自动提交),
/// 那样"下一节点缺失就回滚"会失效。
/// </para>
/// </summary>
private async Task<T> ExecuteInTransactionAsync<T>(Func<ApprovalTransaction, Task<T>> action, string operation)
{
// 事务外先确保审批记录表结构存在(避免 DDL 落在事务里)
EnsureApprovalStructure();
// UnitOfWorkManager 负责开启事务;仓储必须通过 manager.Binding 加入该工作单元,
// 否则仓储会各自新开连接(SQLite 下直接表现为 database is locked,事务也失去意义)。
var uow = _unitOfWorkManager.Begin();
var transaction = new ApprovalTransaction(_unitOfWorkManager, uow);
try
{
var result = await action(transaction);
uow.Commit();
return result;
}
catch (Exception ex)
{
try
{
uow.Rollback();
}
catch (Exception rollbackEx)
{
_logger.LogError(rollbackEx, "审批事务回滚失败,操作:{Operation}", operation);
}
_logger.LogError(ex, "审批事务执行失败并已回滚,操作:{Operation}", operation);
throw;
}
finally
{
transaction.Dispose();
}
}
这段代码包含四个必须理解的点。
1. DDL 必须在事务外
csharp
// 事务外先确保审批记录表结构存在(避免 DDL 落在事务里)
EnsureApprovalStructure();
csharp
/// <summary>
/// 确保审批记录表结构存在。
///
/// 关键:FreeSql 的仓储在第一次访问实体会触发 CodeFirst.SyncStructure(DDL)。
/// 若这一步发生在事务内部,DDL 会与事务写锁冲突(SQLite 直接报 database is locked),
/// 也会让"审批事务"里混进建表语句。因此先在事务之外同步一次。
/// SyncStructure 本身幂等,仅在事务开始前多一次轻量的表结构探测。
/// </summary>
private void EnsureApprovalStructure()
{
try
{
_orm.CodeFirst.SyncStructure<SysApprovalRecord>();
}
catch (Exception ex)
{
// 同步失败不影响后续流程(表通常已存在),只记录告警
_logger.LogWarning(ex, "同步审批记录表结构失败,将按现有结构继续");
}
}
这段注释值回票价。很多人第一次写"审批事务"时会遇到 database is locked,原因就是 ORM 在事务里第一次访问实体触发了建表语句。解法不是加超时,而是把结构同步提到事务之外。
2. 仓储必须绑定到同一个工作单元
csharp
/// <summary>
/// 事务内的数据访问入口:所有仓储都绑定到同一个 IUnitOfWork,
/// 从而保证业务状态、审批记录、审批历史的写入在同一个事务里提交或回滚。
/// </summary>
private sealed class ApprovalTransaction : IDisposable
{
private readonly UnitOfWorkManager _manager;
private readonly IUnitOfWork _uow;
private readonly List<IDisposable> _repositories = [];
public ApprovalTransaction(UnitOfWorkManager manager, IUnitOfWork uow)
{
_manager = manager;
_uow = uow;
}
/// <summary>
/// 取指定实体的仓储(与事务绑定)。
/// 仓储建立在当前租户 ORM 上,并通过 UnitOfWorkManager.Binding 纳入事务,
/// 因此事务内的读写共用同一条连接。
/// </summary>
public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class
{
var repository = _manager.Orm.GetRepository<TEntity>();
_manager.Binding(repository);
_repositories.Add(repository);
return repository;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
foreach (var repository in _repositories)
{
try { repository.Dispose(); } catch { /* 释放失败不影响事务结果 */ }
}
_repositories.Clear();
_uow.Dispose();
}
}
这里的关键是 _manager.Binding(repository)。没有绑定,仓储会各自开连接,事务就名存实亡:
text
绑定正确:
UnitOfWork(事务连接)
├── 业务表仓储
├── 审批记录仓储
└── 同一个连接 → 一起提交 / 一起回滚
没有绑定:
业务表仓储 → 连接 A → 自动提交
审批记录仓储 → 连接 B → 自动提交
→ 回滚只影响其中一个
测试 UnitOfWork_RepositoryIsBoundToCurrentTransaction 就是专门锁定这一点的。
3. 提交和回滚都要显式调用
csharp
var result = await action(transaction);
uow.Commit();
return result;
csharp
catch (Exception ex)
{
try { uow.Rollback(); }
catch (Exception rollbackEx) { _logger.LogError(...); }
throw;
}
回滚本身如果失败也会被记录,但原始异常继续向上抛------因为调用方需要知道"这次审批没成功"。
4. 资源释放放在 finally
csharp
finally
{
transaction.Dispose();
}
ApprovalTransaction.Dispose() 用 Interlocked.Exchange 保证只执行一次,逐个释放仓储,最后释放 IUnitOfWork。
三、写序列:以"同意"为例
把并发那篇的代码按事务边界重新看一遍,结构非常清晰:
csharp
outcome = await ExecuteInTransactionAsync(async tx =>
{
// 1) 业务状态原子推进
var affected = await SetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy, status, ...)
.Where(a => a.Id == billId && a.ApprovalStatus == ApprovalStatus.Pending && a.CurrentLevel == currentLevel)
.ExecuteAffrowsAsync();
if (affected <= 0) return ApprovalOperationOutcome.CreateConflict();
// 2) 原子占用当前待办节点
var claimed = await tx.GetRepository<SysApprovalRecord>().UpdateDiy...
.ExecuteAffrowsAsync();
if (claimed <= 0) return ApprovalOperationOutcome.CreateConflict();
// 3) 或签:同节点其他待处理记录自动跳过
...
// 4) 还有下一级 → 激活下一节点;否则本轮结束
if (status == ApprovalStatus.Pending)
{
var next = await ...;
if (next.Count == 0)
{
// 下一节点缺失属于流程数据不一致,必须回滚而不是让流程悬空
throw new InvalidOperationException(
$"审批流程数据不一致:单据 {billType}#{billId} 缺少第 {nextLevel} 级待办节点");
}
foreach (var record in next) record.IsCurrent = true;
await ...ExecuteAffrowsAsync();
return ApprovalOperationOutcome.Next(pending: next);
}
// 5) 本轮结束:回写发起记录
...
return ApprovalOperationOutcome.Finished(status, resultRecord);
}, $"Approve:{billType}#{billId}");
四个写操作、一个"下一节点缺失"的异常点,全部在同一个 action 里。任何一处抛异常或提前 return 冲突,事务都不会提交。
注意 CreateConflict() 的处理方式:它不是抛异常,而是返回一个"冲突"结果,事务正常提交(因为没有任何已执行的写操作生效------affected = 0 时前面的 UPDATE 根本没改数据)。这是有意的:冲突是正常的业务结果,不是系统故障,不应该记成错误日志。
四、通知必须在事务提交之后
最容易搞错的一步是通知。看提交路径的写法:
csharp
// 通知在事务提交之后发送
List<SysApprovalRecord> pendingToNotify;
try
{
pendingToNotify = await ExecuteInTransactionAsync(async tx =>
{
...
return records.Where(x => x.IsCurrent).ToList();
}, $"Submit:{billType}#{bill.Id}");
}
catch (Exception ex)
{
return new ApprovalSubmitResult(false, GenericFailure(ex));
}
if (pendingToNotify is null)
{
return new ApprovalSubmitResult(false, L("单据状态已发生变化,请刷新后重试"));
}
bill.ApprovalStatus = status;
bill.CurrentLevel = currentLevel;
// 事务已提交:此时才发送通知。通知失败不回滚审批。
if (_options.EnableNotification && pendingToNotify.Count > 0)
{
await NotifyPendingSafeAsync(pendingToNotify);
}
三个设计决定:
(1)事务内只返回"待通知的数据",不发通知。
通知涉及站内信表写入和 SignalR 推送,把它放进事务会拉长事务时间,还可能因为推送失败把审批回滚掉。
(2)通知失败不回滚审批。
NotifyPendingSafeAsync / NotifyResultSafeAsync 内部做了异常隔离。审批已经成功落库,不能因为"消息没发出去"就撤销一次合法的审批。
(3)事务失败时绝不发通知。
因为通知代码在 catch 分支之外、事务提交之后,所以不可能出现"审批失败但通知说已通过"。测试直接锁定了这个行为:
text
Notification_IsNotSentWhenTransactionFails
→ 制造脏数据让事务失败
→ 断言没有发出任何"审批成功"的通知
顺带一提,内存里的单据对象也是在事务成功之后才更新的:
csharp
bill.ApprovalStatus = status;
bill.CurrentLevel = currentLevel;
如果放在事务里更新,事务回滚后内存对象就成了"和数据库不一致"的幽灵状态。
五、失败时返回什么
csharp
private string GenericFailure(Exception ex)
{
var traceId = Guid.NewGuid().ToString("N")[..16];
_logger.LogError(ex, "审批处理失败,TraceId:{TraceId}", traceId);
return LF("审批处理失败,请稍后重试。TraceId:{0}", traceId);
}
用户看到的是"审批处理失败,请稍后重试。TraceId:xxxx",运维能拿 TraceId 去日志里定位。数据库异常、表名、SQL 片段都不会出现在界面上。
测试 FailedApprove_DoesNotLeakExceptionDetail 锁定了这一点。
六、测试怎么保证"真的回滚了"
回滚这种能力,光看代码不够,必须有能制造失败的测试。ApprovalTransactionIntegrityTests.cs 的做法是:在事务中途人为制造失败,然后断言数据库里什么都没变。
| 测试 | 制造的失败 | 断言 |
|---|---|---|
Approve_WhenNextNodeActivationFails_RollsBackBillRecordAndHistory |
下一节点激活失败 | 业务状态、审批记录、历史全部回滚 |
Reject_WhenFinalUpdateFails_RollsBackBillAndRecords |
最后一处更新失败 | 单据与记录都回到操作前 |
Revoke_WhenFinalUpdateFails_RollsBackEverything |
撤回收尾失败 | 撤回相关写入全部回滚 |
Approve_WhenNextNodeMissing_RollsBackBillAndRecord |
删除二级待办节点(脏数据) | 一级同意不生效,流程不悬空 |
Submit_WhenRecordInsertFails_RollsBackBillStatus |
插入审批流水失败 | 业务状态不进入"审批中" |
UnitOfWork_RepositoryIsBoundToCurrentTransaction |
------ | 仓储确实绑在事务连接上 |
ApprovalWrites_AlwaysUseCurrentTenantOrm |
------ | 审批读写只走当前租户库 |
Notification_IsNotSentWhenTransactionFails |
事务失败 | 不发送成功通知 |
RepeatedOperations_ProduceNoAdditionalSideEffects |
重复操作 | 不产生额外副作用 |
其中 ApproveAndRevokeConcurrently_ResultMatchesWhicheverSucceeded 这类测试更进一步:并发操作之后,数据库的状态必须与"最终成功的那一个操作"完全一致,不存在混合态。
七、边界与注意事项
- 事务只覆盖单个数据库。 多租户模式下审批服务用的是当前租户库的 ORM,用户、角色、组织、单据、审批记录全在同一个库里,所以事务是有效的。不要把审批和主库的写入放进同一个"本地事务"------跨库事务需要分布式事务方案。
- 注入的
UnitOfWorkManager要与 ORM 匹配。 构造函数里留了可注入参数:
csharp
public ApprovalService(
IFreeSql orm,
...
UnitOfWorkManager? unitOfWorkManager = null)
{
_orm = orm;
...
_unitOfWorkManager = unitOfWorkManager ?? new UnitOfWorkManager(orm);
}
如果容器里注册了更合适的 UnitOfWorkManager,注入进来即可;不注入时用当前 ORM 新建一个。
- 事务内不要发起无关查询。 源码在提交前把审批人姓名一次性查好,注释写得很直白:
csharp
// 审批人姓名一次性查好:事务内不再访问数据库(单连接的事务里再发起查询会触发 SQLite 锁等待)
单连接事务里做额外查询会放大锁竞争,尤其是 SQLite。
- DDL 不要进事务。 这条对任何用 CodeFirst 自动建表的场景都适用,不只是审批。
- 不要用
_orm直连绕过事务。 一旦绕过,回滚就不再完整,而且问题只会在故障时暴露。
八、小结
审批事务的设计可以用一张表说清楚:
| 关注点 | 做法 |
|---|---|
| 事务边界 | UnitOfWorkManager.Begin() → Commit() / Rollback() |
| 数据访问 | 仓储必须 Binding 到同一个 UnitOfWork |
| DDL | 事务开始前 SyncStructure,不混进事务 |
| 冲突 | 条件更新 affected = 0 返回冲突,不抛异常 |
| 数据不一致 | 抛异常 → 整体回滚(例如下一节点缺失) |
| 通知 | 事务提交之后发送;失败不回滚审批 |
| 内存对象 | 事务成功后才同步状态 |
| 错误暴露 | 通用提示 + TraceId,不泄露数据库细节 |
一句话总结:审批要么全部成功,要么什么都没发生。 这句话在源码里就是"所有写操作都在同一个 action 里、都通过绑定了 UnitOfWork 的仓储执行、任何异常都让事务回滚"。
如果你正在给 .NET 10 + Blazor 后台加审批,事务与并发这两块最容易被低估。EasyAdminBlazor 的审批实现把这两件事写进了源码注释和测试用例,可以直接对照改自己的业务代码。