用 SQL Server 应用锁解决并发重复提交

用 SQL Server 应用锁解决并发重复提交
移动端报修提交时,经常会遇到一种问题:前端已经做了按钮 loading,后端也查了“同一设备是否已有未完成工单”,但现场仍然出现了同一设备生成两张未完成工单。
根本原因不是按钮没有禁用,而是后端的“查重”和“新增”不是一个不可打断的整体。
问题在哪里
普通写法大概是这样:
bool isHave = entities.engsys_taskDoc.Any(w => w.assetsCode == data.assetsCode && w.flowStatus == 1);
if (isHave)
{
return GZAPIBadRequest($"固定资产编号:【{data.assetsCode}】有未完成的工单,不允许重复提交工单");
}
entities.engsys_taskDoc.Add(data);
entities.SaveChanges();
单个请求看起来没问题,但两个请求并发时会出现这个过程:
请求 A:查询,没有未完成工单
请求 B:查询,也没有未完成工单
请求 A:新增工单
请求 B:新增工单
于是同一设备就可能被重复报修。
为什么不用内存缓存
接口开始时写一个内存缓存,结束时删除缓存,确实能挡住一部分重复点击。但它不适合作为最终限制。
| 方案 | 问题 |
|---|---|
| 前端 loading | 只能挡用户连点,挡不住重复请求或接口重放 |
| 后端内存缓存 | 和数据库提交不是一个事务整体 |
| 缓存过期时间 | 时间短了挡不住慢请求,时间长了误拦正常提交 |
| SQL Server 应用锁 | 和数据库事务绑定,能保护“查重 + 新增” |
这个规则本质上是数据规则:
同一设备不能同时存在两张未完成工单
所以限制应该放在数据库提交附近。
使用 sp_getapplock 加事务级排他锁
SQL Server 提供了 sp_getapplock,可以给一个自定义资源名加锁。这里可以把资产编号作为锁名的一部分:
DECLARE @result int;
EXEC @result = sp_getapplock
@Resource = @LockResource,
@LockMode = 'Exclusive',
@LockOwner = 'Transaction',
@LockTimeout = 10000;
SELECT @result;
关键参数说明:
| 参数 | 含义 |
|---|---|
@Resource | 锁的名字,例如 engsys_taskDoc.assetsCode:AG00264 |
@LockMode = 'Exclusive' | 排他锁,同一时间只允许一个事务拿到 |
@LockOwner = 'Transaction' | 锁跟随事务,提交或回滚后自动释放 |
@LockTimeout = 10000 | 最多等待 10 秒,不是固定等待 10 秒 |
后端实现方式
核心思路是:开启事务后,先按资产编号加锁,再查重,再新增或修改。
using (var trans = entities.Database.BeginTransaction())
{
try
{
var validateResult = ValidateFixedAssetsDocNotExists(data);
if (validateResult != null)
{
trans.Rollback();
return validateResult;
}
entities.engsys_taskDoc.Add(data);
entities.SaveChanges();
trans.Commit();
}
catch (Exception ex)
{
trans.Rollback();
return GZAPIThrowException(ex);
}
}
校验函数示例:
private GZAPIResult ValidateFixedAssetsDocNotExists(engsys_taskDoc data, string excludeRowID = null)
{
if (data.assetsType != 1)
{
return null;
}
if (String.IsNullOrEmpty(data.assetsCode))
{
return GZAPIBadRequest("固定资产编号不能为空");
}
data.assetsCode = data.assetsCode.Trim();
var lockResult = entities.Database.SqlQuery<int>(
@"DECLARE @result int;
EXEC @result = sp_getapplock @Resource = @LockResource, @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = 10000;
SELECT @result;",
new SqlParameter("@LockResource", "engsys_taskDoc.assetsCode:" + data.assetsCode)
).Single();
if (lockResult < 0)
{
return GZAPIBadRequest($"固定资产编号:【{data.assetsCode}】正在提交工单,请稍后再试");
}
var query = entities.engsys_taskDoc.Where(w => w.assetsCode == data.assetsCode && w.flowStatus == 1);
if (!String.IsNullOrEmpty(excludeRowID))
{
query = query.Where(w => w.rowID != excludeRowID);
}
if (query.Any())
{
return GZAPIBadRequest($"固定资产编号:【{data.assetsCode}】有未完成的工单,不允许重复提交工单");
}
return null;
}
锁什么时候释放
因为使用了:
@LockOwner = 'Transaction'
所以不需要手写释放代码。
| 情况 | 释放时间 |
|---|---|
trans.Commit() | 事务提交时自动释放 |
trans.Rollback() | 事务回滚时自动释放 |
| SQL 连接断开 | SQL Server 自动清理事务和锁 |
| 接口异常进入 catch | 回滚事务后释放 |
@LockTimeout = 10000 的意思是最多等 10 秒,不是每次都等 10 秒。前一个请求 0.2 秒处理完,后一个请求就只等 0.2 秒。
最终效果
同一个设备并发提交时,执行顺序会变成:
请求 A:拿到 AG00264 的锁
请求 A:查重,没有未完成工单
请求 A:新增工单
请求 A:提交事务,释放锁
请求 B:拿到 AG00264 的锁
请求 B:查重,发现已有未完成工单
请求 B:拒绝提交
这样就能保证“查重 + 新增”不会被另一个请求插队。
小结
前端防重复点击可以保留,但它只是用户体验层面的保护。真正防止同一设备重复报修,应该放在后端数据库事务里。
对于这种“先查重、再插入”的并发问题,SQL Server 的事务级应用锁是一个很合适的方案:不需要新增表,也不依赖内存缓存,并且锁会随着事务自动释放。
版权声明:本文为YES开发框架网发布内容,转载请附上原文出处连接
post MCP自动发表文章


