集群环境下的高并发接口,最常见的风险不是一台服务器扛不住,而是多实例之间互相拆台。库存扣减前先检查库存,检查完还没更新,另一个实例已经拿走最后一件商品;前端用户手滑点了两次提交,两个请求同时落在不同实例上,订单被重复创建。这些问题在单机时代可以用lock、synchronized或数据库事务压住,但部署成集群后,进程内的锁互相看不到,就必须引入分布式锁。Redis 因为性能高、部署简单、命令表达能力足够,是后端团队实现分布式锁的高频选择。本文以 .NET 10 WebAPI 接口服务为例,从 Redis 环境准备开始,逐步实现一个带过期时间、带持有者校验、支持重试的 Redis 分布式锁,并把它应用到库存超卖和重复请求两个场景中。文章不只是一个演示,还会解释每一步背后的并发原理、常见坑位和生产落地建议。
1. 先想清楚:集群下为什么单机锁挡不住超卖
1.1 典型超卖代码的问题在哪
看一段最常见的库存扣减伪代码:
public async Task DeductAsync(long productId, int count) { var stock = await _stockRepository.GetStockAsync(productId); // 读库存 if (stock < count) { throw new BusinessException("库存不足"); } await Task.Delay(10); // 模拟业务处理:生成订单、写流水等 await _stockRepository.DecreaseStockAsync(productId, count); // 扣减 }问题出在“读库存、判断库存、扣减库存”这三步不是原子的。两个请求同时读到库存为 100,都通过了stock < count的判断,然后各自执行扣减,最终数据库可能只剩 98,但订单却生成了两份。单机部署时,把这段代码放进lock或synchronized可以让同一进程内的线程串行执行;一旦服务部署成两个实例,A 实例上的锁对 B 实例没有任何约束力,两个进程同时执行同一个业务方法,超卖就出现了。
从 Java 后端切到 .NET 10 的同学尤其要注意:C# 的lock和 Java 的synchronized本质上都是进程内锁,它们解决的是线程互斥,不是进程互斥,更不是跨主机互斥。
1.2 分布式锁的本质和四个条件
分布式锁的通俗说法是:让所有实例都去同一个外部协调器抢一把锁,谁抢到谁进入临界区,执行完主动释放,持有者崩溃时靠过期时间自动释放。它的技术定义可以理解为:存储在外部协调器中的临时互斥标记,客户端通过原子指令竞争这个标记的所有权。
一个可靠的分布式锁至少要满足四个条件:
- 互斥性:同一时刻只有一个客户端能持有锁。
- 防死锁:持有者崩溃或网络分区后,锁能自动过期释放。
- 安全释放:只能释放自己持有的锁,不能误删其他请求重新获取的锁。
- 高可用:锁服务本身不能成为明显单点,极端故障时要有应对策略。
很多团队在 Redis 分布式锁上翻车,不是因为加锁命令没学会,而是后面三个条件没有逐条验证。
1.3 为什么 Redis 是常见选择
实现分布式锁的常见外部协调器有 Redis、数据库和 ZooKeeper,各有侧重。
| 方案 | 优点 | 主要限制 |
|---|---|---|
| Redis 分布式锁 | 性能高,SET NX EX 一条原子命令完成,生态工具多 | 极端故障时可能丢锁,需要结合过期时间、续期、主从策略兜底 |
| 数据库悲观锁 | 与业务事务一致性好,适合强一致场景 | 吞吐低,长事务会放大锁等待,数据库连接占用久 |
| 数据库唯一键或乐观锁 | 适合幂等和防超卖兜底,实现简单 | 只能解决单表或单操作冲突,无法覆盖跨表多步骤临界区 |
| ZooKeeper 临时顺序节点 | 强一致,基于会话感知客户端故障 | 部署维护成本高,性能低于 Redis |
在 .NET 生态里,并没有像 Java Redisson 那样功能很完整的分布式锁组件,所以自己封装一个很小的锁服务是常见做法。这也是本文把实现过程完整展开的原因:只有理解了锁的原子性、过期时间、持有者校验和续期,才能真正驾驭它,而不是把一个看起来能跑的代码复制到生产环境。
2. 环境准备:.NET 10 和 Redis 缺一不可
2.1 确认 .NET 10 开发环境
本文按 .NET 10 SDK 编写。动手前先确认本机 SDK 版本,因为 .NET 版本迭代较快,实际下载到的正式版或预览版会持续变化。
dotnet --version dotnet --list-sdks环境要求可以按下表确认:
| 组件 | 要求 |
|---|---|
| .NET 10 SDK | 从 dotnet.microsoft.com 下载当前官方正式版或最新预览版 |
| IDE | Visual Studio 2022,需勾选“ASP.NET 和 Web 开发”工作负载 |
| Redis | 7.x 或更高版本,本机、Docker 或远程服务器均可 |
| 数据库 | 演示库存可使用 SQLite 或 SQL Server,ORM 可用 EF Core 或 Dapper |
2.2 Redis 安装:Windows、Linux、Docker 三种方式
首先要明确:Redis 官方没有发布 Windows 原生版本。Windows 下开发通常有三种选择:
- 使用 WSL2 运行 Linux 发行版并安装 Redis。
- 使用第三方 Windows 移植发布包。
- 使用 Docker Desktop 运行 Redis 容器。
Docker 方式最省事,一条命令即可启动:
docker run -d --name redis-local -p 6379:6379 redis:7如果使用 Windows 移植包,运行命令通常是:
redis-server.exe redis.windows.conf redis-cli.exe pingredis-cli ping返回PONG表示 Redis 可用。Linux 下可以使用包管理器安装,也可以源码编译;对于版本受限的场景,源码编译更可控,但需要额外安装编译工具链。
启动 Redis 后,检查版本和基本状态:
redis-cli INFO server | grep redis_version2.3 Redis 基本配置和可视化客户端
本地学习阶段,Redis 配置文件可以保持最简单的状态,但有两个点要提前注意:端口不要和已有服务冲突,生产环境不要裸奔。
bind 127.0.0.1 protected-mode yes port 6379 # 本地学习可以先不设密码,生产环境必须设置 # requirepass your-strong-passwordbind 127.0.0.1表示只允许本机访问。如果应用服务器和 Redis 不在同一台机器,需要把 bind 改为应用服务器可访问的网卡地址,或者放在同一个内网安全组内,不要直接暴露公网。
可视化客户端建议准备一个,例如 Another Redis Desktop Manager、Redis Desktop Manager 等。它们主要用来快速查看 Key、Value、TTL 和数据类型,排查分布式锁残留时非常有用。
3. 在 .NET 10 WebAPI 中接入 Redis 客户端
3.1 创建 WebAPI 项目和项目结构
使用 CLI 创建项目:
dotnet new webapi -n OrderService cd OrderService模板默认生成最小 API 风格。如果习惯 Controller 风格,在 Visual Studio 2022 的模板选项里勾选“使用控制器”,或者手动创建 Controllers 目录。
为了后续演示清晰,建议按下面的结构组织代码:
OrderService/ ├── Controllers/ │ ├── StockController.cs │ └── OrderController.cs ├── Services/ │ ├── IDistributedLockService.cs │ ├── RedisDistributedLockService.cs │ ├── StockService.cs │ └── IdempotentOrderService.cs ├── Program.cs └── appsettings.json添加 Redis 客户端包:
dotnet add package StackExchange.RedisStackExchange.Redis 是 .NET 生态中使用最广的 Redis 客户端,API 贴近 Redis 命令,资料也最多。其他如 FreeRedis、NewLife.Redis 也可以实现同样功能,本文以 StackExchange.Redis 为例。
3.2 配置连接字符串并注册 Redis 连接
在appsettings.json中配置连接字符串:
{ "ConnectionStrings": { "Redis": "localhost:6379,password=,defaultDatabase=0,abortConnect=false" } }每个参数的含义如下:
| 参数 | 作用 | 建议 |
|---|---|---|
| abortConnect=false | 启动时 Redis 连接失败不阻止应用启动 | 学习阶段方便;生产环境要配合健康检查,不能长期掩盖连接问题 |
| defaultDatabase | Redis 逻辑库编号 | 默认 0 即可,不要通过多库强行隔离不同业务 |
| password | Redis 密码 | 生产环境必填 |
| connectTimeout | 连接超时时间 | 默认 5 秒,按网络情况调整 |
注册 Redis 连接对象:
builder.Services.AddSingleton<IConnectionMultiplexer>(sp => { var config = builder.Configuration.GetConnectionString("Redis"); return ConnectionMultiplexer.Connect(config); });ConnectionMultiplexer是线程安全且复用连接的对象,整个进程只应创建一个。这里有个常见的 AI 生成代码坑:把ConnectionMultiplexer写在每次请求的处理方法里new一个,或者把密码硬编码在代码中。这两点都要人工检查。
3.3 封装最常用的 Redis 操作
写一个最小可用的 RedisService,供后续锁服务和业务服务调用:
public class RedisService { private readonly IConnectionMultiplexer _redis; public RedisService(IConnectionMultiplexer redis) { _redis = redis; } private IDatabase Db => _redis.GetDatabase(); public async Task<string?> StringGetAsync(string key) => await Db.StringGetAsync(key); public async Task<bool> StringSetAsync(string key, string value, TimeSpan? expiry = null) => await Db.StringSetAsync(key, value, expiry); public async Task<bool> StringSetIfNotExistsAsync(string key, string value, TimeSpan expiry) => await Db.StringSetAsync(key, value, expiry, When.NotExists); }这里的核心是使用 Redis String 类型。Redis String 不只可以存文本,也可以存数字、JSON、位图等。分布式锁的 Key 和 Value 都用 String,其中 Value 必须保存请求的唯一标识,后面会详细解释。
4. 分布式锁的完整实现:从两行代码到可上线的锁服务
4.1 第一版:SET NX EX 为什么是加锁的最小完整命令
很多文章会告诉你加锁要用SET key value NX EX seconds,但不会解释为什么不能用“先 Exists 再 Set”。看下面这个错误写法:
if 不存在: set key value两个请求可能同时通过Exists判断,然后都执行Set,锁就失效了。Redis 的SET NX EX是一条原子命令,NX 表示只有 Key 不存在时才写入,EX 或 PX 表示设置过期时间,单位分别是秒和毫秒。原子性意味着判断和写入之间没有其他命令可以插入。
在 StackExchange.Redis 中,加锁代码非常短:
public async Task<bool> TryLockAsync(string lockKey, string requestId, TimeSpan expiry) { return await _db.StringSetAsync(lockKey, requestId, expiry, When.NotExists); }这个方法对应 Redis 命令:SET lockKey requestId PX 过期毫秒 NX。When.NotExists就是 NX,第三个参数 TimeSpan 就是过期时间。
三个核心参数的含义:
| 参数 | 含义 |
|---|---|
| lockKey | 锁的标识,按业务维度设计,例如 order:stock:lock:1001 |
| requestId | 当前请求唯一标识,用于解锁时校验持有者 |
| expiry | 锁自动过期时间,防御持有者崩溃后锁永久不释放 |
如果没有过期时间,持有者进程崩溃后锁永远存在,其他请求全部拿不到锁,这就是死锁;有了过期时间,崩溃后最多等待一个过期周期。所以过期时间不是可选项,而是分布式锁的保底机制。
4.2 第二版:解锁必须用 Lua,不能直接删除 Key
第一版加锁看起来已经完整,但解锁如果写成下面这样,会在高并发下埋雷:
await _db.KeyDeleteAsync(lockKey);为什么不能直接删?考虑这个时间线:
- 请求 A 获取锁,Value = A。
- A 业务执行时间超过锁过期时间,锁在 Redis 中自动消失。
- 请求 B 获取同一把锁,Value = B。
- A 业务执行完,执行
KeyDelete(lockKey),直接把 B 刚获取的锁删掉了。 - 请求 C 也拿到锁,与 B 同时进入临界区,互斥失效。
正确做法是先判断锁的 Value 是否还是自己的 requestId,是则删除,否则不处理。但“判断”和“删除”两个操作之间仍然有并发窗口:判断结果是自己的,删除之前锁刚好过期并被其他请求获取,此时删除就会误删新锁。所以“比较 Value”和“删除 Key”必须放在 Redis 的 Lua 脚本中原子执行。
释放锁的 Lua 脚本:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 endC# 调用代码:
private static readonly string UnlockScript = @" if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; public async Task<bool> ReleaseLockAsync(string lockKey, string requestId) { var result = await _db.ScriptEvaluateAsync( UnlockScript, new RedisKey[] { lockKey }, new RedisValue[] { requestId }); return (long)result == 1; }返回 1 表示删除成功,返回 0 表示 Value 不匹配,说明锁已经不属于当前请求,不能删除。
注意:释放分布式锁时,不能直接调用 KeyDelete。必须判断锁的 Value 是否仍属于当前请求,并且判断和删除必须放在同一个 Lua 脚本里执行。
对应地,加锁时传入的 requestId 必须是全局唯一值,一般使用Guid.NewGuid().ToString("N")或雪花 ID。如果两个请求使用同一个 requestId,A 持锁期间锁过期,B 用相同 Value 获取新锁,A 释放锁时get结果与自己相等,就会误删 B 的锁。requestId 的作用就是区分锁的持有者。
4.3 第三版:拿不到锁时按策略重试
前面两版都是“拿不到锁就立即失败”。实际接口中,锁可能被一个很快的事务持有,比如耗时只有几十毫秒,此时直接让用户失败体验很差。常见做法是自旋等待一小段时间。
public async Task<bool> AcquireLockWithRetryAsync( string lockKey, string requestId, TimeSpan expiry, int maxRetry = 3, TimeSpan? retryDelay = null) { retryDelay ??= TimeSpan.FromMilliseconds(100); for (var i = 0; i < maxRetry; i++) { if (await TryLockAsync(lockKey, requestId, expiry)) { return true; } await Task.Delay(retryDelay.Value); } return false; }更严格的方式是用 Stopwatch 控制总等待时间,避免在高并发时大量线程同时循环争抢:
var watch = Stopwatch.StartNew(); while (watch.Elapsed < maxWait) { if (await TryLockAsync(lockKey, requestId, expiry)) { return true; } await Task.Delay(50); } return false;学习环境用简单循环即可理解思路。生产环境如果要严格控制 Redis 压力,还可以在本机用 SemaphoreSlim 限制同时等待锁的线程数量,避免所有请求都打到 Redis 上。
4.4 锁过期时间和续期问题
第二版解决了误删,但新的问题是:锁过期了,业务还没执行完,怎么办?
假设锁过期时间设 10 秒,业务最慢路径需要 15 秒。第 10 秒时锁被 Redis 自动删除,另一个请求获取锁进入临界区,两个请求同时执行库存扣减逻辑,分布式锁形同虚设。解决思路有几种:
- 把过期时间设大一些,保证大于业务最大耗时。最简单,但过期时间过大时,持有者崩溃后其他请求等待时间会变长。
- 手动续期:后台任务每隔
expiry / 3的时间刷新锁的过期时间,业务执行完取消续期并释放锁。这类似 Redisson 的 WatchDog 思路。 - 使用第三方 RedLock 实现,选择库之前要确认项目的维护情况、并发模型和需求匹配度。
续期本身也要用 Lua 脚本,确保只有锁仍归自己持有时才刷新过期时间:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end简化版的续期循环思路:
var renewalCts = new CancellationTokenSource(); _ = RenewLoopAsync(lockKey, requestId, expiry, renewalCts.Token); try { // 执行业务逻辑 } finally { renewalCts.Cancel(); await ReleaseLockAsync(lockKey, requestId); } async Task RenewLoopAsync(string key, string requestId, TimeSpan expiry, CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(expiry / 3, token); await RenewAsync(key, requestId, expiry); } }生产环境还要处理续期 Task 的异常,避免后台续期线程崩溃后锁变成裸过期。分布式锁的三版演进可以用下表总结:
| 版本 | 加锁 | 解锁 | 解决的问题 | 遗留问题 |
|---|---|---|---|---|
| 第一版 | SET NX EX | KeyDelete | 互斥、防死锁 | 可能误删他人锁 |
| 第二版 | SET NX EX | Lua 比较后删除 | 误删他人锁 | 锁过期后业务未完成 |
| 第三版 | SET NX EX + 重试 | Lua 比较后删除 | 拿不到锁时自动等待 | 仍需合理过期时间或续期 |
5. 实战一:库存超卖场景的分布式锁接入
5.1 库存业务为什么不能只依赖一条 UPDATE
有一种观点是:库存扣减只要写成UPDATE product_stock SET stock = stock - @count WHERE product_id = @productId AND stock >= @count,数据库自带行锁,不会超卖,为什么还需要分布式锁?
这句话对“扣减库存”这个单点操作成立,但对完整的下单流程不成立。真实的库存扣减很少只有一条 UPDATE,而是“查询库存、检查库存、创建订单快照、写流水、扣减库存”多个步骤。多个请求可能都通过了库存检查,都创建了订单,直到扣减时才有一个失败,但订单数据已经被写进去了,业务上已经产生了脏数据。
分布式锁在这里的作用,是让“读库存、业务判断、业务登记、扣减库存”作为一个临界区串行执行。数据库的WHERE stock >= count是最后一道防线,两者不是替代关系,而是配合关系。
另一个常见误区是“Redis 里用 DECR 原子扣减就不需要锁”。DECR只保证 Redis 中的计数不超卖,无法保证库存扣减前那些业务预占、订单登记、消息通知的一致性。Redis 的原子命令不能包住业务状态流转,锁才能包住临界区。
5.2 定义锁服务接口
把第 4 章实现封装成接口,方便库存服务、订单服务复用:
public interface IDistributedLockService { Task<bool> TryLockAsync(string lockKey, string requestId, TimeSpan expiry); Task<bool> AcquireLockWithRetryAsync(string lockKey, string requestId, TimeSpan expiry, int maxRetry = 3, TimeSpan? retryDelay = null); Task<bool> ReleaseLockAsync(string lockKey, string requestId); }实现类RedisDistributedLockService中,把第 4 章的TryLockAsync、AcquireLockWithRetryAsync、ReleaseLockAsync和 Lua 脚本放进去即可。
5.3 库存扣减的完整代码
Controller 层尽量保持薄,只做参数接收和结果返回:
[ApiController] [Route("api/[controller]")] public class StockController : ControllerBase { private readonly StockService _stockService; public StockController(StockService stockService) { _stockService = stockService; } [HttpPost("{productId}/deduct")] public async Task<IActionResult> Deduct(long productId, [FromQuery] int count) { var requestId = Guid.NewGuid().ToString("N"); var result = await _stockService.DeductAsync(productId, count, requestId); return Ok(result); } }Service 层把锁、库存、订单服务组合起来:
public class StockService { private readonly IDistributedLockService _lockService; private readonly IStockRepository _stockRepository; private readonly IOrderRepository _orderRepository; public StockService( IDistributedLockService lockService, IStockRepository stockRepository, IOrderRepository orderRepository) { _lockService = lockService; _stockRepository = stockRepository; _orderRepository = orderRepository; } public async Task<DeductResult> DeductAsync(long productId, int count, string requestId) { var lockKey = $"order:stock:lock:{productId}"; if (!await _lockService.TryLockAsync(lockKey, requestId, TimeSpan.FromSeconds(10))) { return DeductResult.Failed("系统繁忙,请稍后重试"); } try { var stock = await _stockRepository.GetStockAsync(productId); if (stock < count) { return DeductResult.Failed("库存不足"); } // 模拟业务处理:创建订单快照、写流水、通知下游等 await _orderRepository.CreateOrderAsync(productId, count, requestId); var affected = await _stockRepository.DecreaseStockAsync(productId, count); if (affected == 0) { return DeductResult.Failed("库存不足,请重试"); } return DeductResult.Success(); } finally { await _lockService.ReleaseLockAsync(lockKey, requestId); } } }几个关键设计点:
- 锁 Key 按 productId 拆分,不同商品使用不同锁,避免所有商品共用一把锁导致吞吐下降。
- requestId 同时作为锁持有者标识,可以顺带写入订单表作为请求来源标识,方便排查。
- 释放锁放在 finally 中,即使业务抛异常,锁也会主动释放。
- 过期时间先给 10 秒,生产环境要结合业务最慢路径重新评估。
- 实际项目中,
CreateOrderAsync和DecreaseStockAsync应包在同一个数据库事务中,这里为了突出锁的逻辑做了简化。
5.4 模拟并发验证超卖是否消失
本地验证可以用并发 Task 模拟一批请求:
var tasks = Enumerable.Range(0, 1000) .Select(i => _stockService.DeductAsync(1, 1, Guid.NewGuid().ToString("N"))); var results = await Task.WhenAll(tasks); var successCount = results.Count(r => r.Success); var failCount = results.Count(r => !r.Success);预期结果是:初始库存 100,1000 个并发请求最终successCount = 100,failCount = 900。如果去掉分布式锁,会观察到successCount > 100,或者扣减后库存变成负数。
这里要明确一点:Task.WhenAll只模拟了单进程内的并发,只能验证接口逻辑是否合理,不能验证跨实例的互斥效果。要真正验证 Redis 分布式锁在集群下有效,需要启动至少两个 WebAPI 实例,通过负载均衡分发请求,并连接同一个 Redis。再使用 JMeter、k6 或 wrk 发起并发压测。
6. 实战二:用分布式锁拦截重复请求
6.1 重复请求的典型来源
重复请求不一定是高并发导致的,更常见的是时间上先后到达,但业务结果不能重复。典型来源包括:前端双击提交按钮、移动端弱网环境自动重试、网关或 RPC 框架超时重试、消息队列重复消费。
这类问题和库存超卖不同。超卖关心的是“多个实例同时修改同一个库存”,重复请求关心的是“同一个请求被处理了多次,产生多条订单或多次扣款”。
6.2 锁加幂等表比单独 Set NX 更可靠
一个直觉方案是:请求进来时直接SET requestNo 1 NX EX 60,设置成功就执行业务,设置失败就返回重复提交。这个方案能挡住并发,但有一个明显漏洞:如果锁过期时间比业务执行时间短,第二个请求会在锁过期后进入并执行业务,幂等失效。即使把过期时间设得很长,Redis 里也无法区分“处理中”和“已完成”两种状态。
更可靠的做法是:锁负责并发排队,幂等表负责状态记录。同一个 requestNo 的请求先在锁外排队,拿到锁后进临界区查询幂等表;如果已经处理过,直接返回第一次处理的结果;如果没有处理过,执行正常业务,并在同一个事务里写入幂等记录。幂等表的唯一索引作为最终兜底。
6.3 幂等表设计
以创建订单为例:
CREATE TABLE idempotent_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_no VARCHAR(64) NOT NULL, business_type VARCHAR(32) NOT NULL, response_body TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_no_biz (request_no, business_type) );request_no是客户端生成的请求唯一标识,business_type区分不同的业务类型,两者组成唯一索引。即使极端情况下两个请求同时进入,也只有一个能插入成功,另一个会收到唯一键冲突,业务层捕获后返回已处理结果。
6.4 防重接口代码
[HttpPost("create")] public async Task<IActionResult> CreateOrder(OrderCreateRequest request) { var requestNo = request.RequestNo; if (string.IsNullOrWhiteSpace(requestNo)) { return BadRequest(new { code = 400, message = "RequestNo 不能为空" }); } var lockKey = $"biz:order:create:{requestNo}"; var requestId = Guid.NewGuid().ToString("N"); if (!await _lockService.TryLockAsync(lockKey, requestId, TimeSpan.FromSeconds(5))) { return Conflict(new { code = 409, message = "请求正在处理中,请勿重复提交" }); } try { var exist = await _idempotentRepository.GetByRequestNoAsync(requestNo); if (exist != null) { return Ok(exist.ResponseBody); } var result = await _orderService.CreateAsync(request); await _idempotentRepository.SaveAsync(requestNo, JsonSerializer.Serialize(result)); return Ok(result); } finally { await _lockService.ReleaseLockAsync(lockKey, requestId); } }这里有几个容易误解的地方:
requestNo必须由客户端生成并透传,服务端自动生成的请求号无法用于幂等,因为每次生成的结果都不同。- 锁的过期时间 5 秒只是示例,要大于创建订单的业务耗时。如果业务可能超过 5 秒,仍需要配合续期。
SaveAsync写入幂等记录时,如果遇到唯一键冲突,要按“已经处理过”处理,而不是直接抛异常。- 幂等记录和订单写入尽量放在同一个事务中,避免订单成功但幂等记录没写入,导致下次重试再次创建订单。
7. 分布式锁最容易踩的坑与排查链路
7.1 坑1:释放锁时误删他人的锁
现象:偶发性地出现多个请求同时进入临界区,库存超卖或订单重复。
原因:解锁直接KeyDelete,删掉了其他请求重新获取的锁。
排查:在日志中输出释放锁的结果。如果出现“释放锁跳过”,说明 Value 校验没有通过,锁不是当前请求持有的。
解决:使用 Lua 脚本,先get比较 Value,再del删除,两个动作必须原子。
7.2 坑2:锁过期时间设置不合理
现象:业务正常执行,但锁提前过期,后续请求进入临界区;或者锁过期时间过长,持有者崩溃后其他请求长时间等待。
原因:没有结合业务最慢路径评估过期时间,也没有对长任务做续期。
解决:先统计业务在临界区内的最大耗时,设置过期时间时留出缓冲;长任务额外做续期;不要把 Redis 调用、外部 HTTP 调用等长耗时操作放在锁内。
7.3 坑3:锁粒度设计错误
现象:接口吞吐很低,所有请求排成一串;或者锁内再嵌套加锁,出现死锁。
原因:锁 Key 设计太粗,所有业务共用一把全局锁;或锁 Key 太细且加锁顺序不一致。
解决:按业务实体拆分锁 Key,例如按 productId、userId、requestNo 拆分。锁内避免继续申请其他锁,如果确实需要多把锁,要保证所有路径加锁顺序一致。
7.4 坑4:Redis 主从切换导致锁丢失
现象:Redis 主库宕机,从库升级为主库后,原来写入到主库的锁 Key 不存在,多个请求同时进入临界区。
原因:锁写在主库,但主从复制是异步的,Key 还没同步到从库时主库宕机,从库成为新主库后没有这把锁。
解决:Redis 哨兵解决了主从自动切换的高可用问题,但没有解决异步复制带来的锁丢失。RedLock 通过多节点 quorum 提高容错,但会带来性能和一致性成本,也不是绝对安全。对库存、扣款这类一致性要求极高的场景,可以在数据库侧再做一次基于唯一键或行锁的兜底。
7.5 坑5:缓存和锁混用,清理缓存时误删锁
现象:某个缓存清理任务执行后,分布式锁全部失效,接口并发异常。
原因:锁 Key 和缓存 Key 的命名空间没有隔离,或者有人执行了FLUSHDB、KEYS *删除操作。
解决:锁 Key 统一使用lock:前缀,缓存 Key 使用cache:前缀;生产环境禁止执行FLUSHDB、FLUSHALL和KEYS命令。
7.6 从现象到根因的排查链路
排查分布式锁问题,可以按下面的顺序走:
- 确认现象:是库存超卖、订单重复,还是接口大面积失败。
- 看日志:检查加锁、解锁日志是否输出了 requestId、lockKey、成功状态,统计加锁失败率。
- 查 Redis:用命令查看锁 Key 的 TTL 和 Value。
redis-cli -a 你的密码 --no-auth-warning TTL order:stock:lock:1001 redis-cli -a 你的密码 --no-auth-warning GET order:stock:lock:1001TTL 为 -1 表示锁没有过期时间,需要检查加锁代码是否漏掉了 expiry;TTL 为 -2 表示 Key 不存在。GET 返回的 Value 与当前请求 requestId 不一致时,可能是误删或锁已过期被重新获取。
- 看主从:执行
redis-cli INFO replication检查主从状态,确认事故时间是否和主从切换时间吻合。 - 看业务耗时:通过日志或链路追踪统计临界区内耗时,判断是否超过锁过期时间。
- 看数据库:检查订单表、幂等表中是否存在相同 requestId 的重复数据。
常见问题的汇总表:
| 现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 多个请求同时进入临界区 | 锁过期时间比业务耗时短 | 对比业务耗时和锁 TTL | 调大过期时间或增加续期 |
| 偶发误删锁后并发进入 | 解锁未校验 Value | 看日志是否出现释放锁跳过 | 改用 Lua 脚本解锁 |
| 库存不为负但订单重复 | 只防了扣减没防创建 | 查订单表 requestId 重复数 | 锁内加幂等表或唯一键 |
| 锁 Key 还在但拿不到锁 | 锁过期时间过长 | 查 TTL 和请求等待时间 | 缩短过期时间,增加续期 |
| 主从切换后锁丢失 | 主从异步复制丢锁 | 核对切换时间和锁 Key 状态 | 数据库兜底或按一致性要求评估 RedLock |
8. 生产落地:参数、监控、检查清单和扩展方向
8.1 Key 设计与命名规范
分布式锁的 Key 建议按业务:领域:lock:业务ID格式设计,例如:
order:stock:lock:1001pay:refund:lock:order_20250101biz:order:create:req_123456
Value 统一使用请求唯一 ID,建议是Guid.NewGuid().ToString("N")或雪花 ID。锁 Key 和缓存 Key 必须分开命名空间,锁统一加lock:前缀,避免缓存清理任务误删锁。
8.2 过期时间与重试策略选择
| 参数 | 学习环境建议 | 生产环境建议 |
|---|---|---|
| 锁过期时间 | 5-10 秒 | 预估最大业务耗时乘 2,并配合续期 |
| 重试次数 | 3 次 | 3-5 次,或控制总等待时间不超过 200-500 毫秒 |
| 重试间隔 | 100 毫秒 | 短任务 10-50 毫秒,并考虑指数退避 |
| 锁粒度 | 按商品、按请求号 | 按业务最小维度,避免嵌套加锁 |
不要全局只使用一把锁。锁 Key 越粗,并发吞吐越低;锁 Key 越细,管理成本和死锁风险越高,需要在业务维度上做取舍。
8.3 Redis 持久化和高可用准备
生产环境的 Redis 至少要考虑持久化和高可用两个层面。持久化方面,开启 AOF 或 RDB 可以解决 Redis 重启后的数据恢复问题,但要注意,持久化不能解决主从切换时的异步复制丢锁,它和锁丢失是两个不同的问题。
高可用方面,可以使用主从加哨兵,或者根据规模选择 Redis Cluster。哨兵负责故障自动切换,Cluster 负责数据分片和高可用。无论哪种方式,锁的高可用都要结合业务一致性和性能要求综合考虑,不要把 Redis 分布式锁当作所有场景的万能方案。
网络和安全方面,Redis 和应用服务器之间不要走公网,建议放在同一个内网安全组中,并设置强密码。连接字符串中的密码不要硬编码在代码或appsettings.json中,要使用环境变量或密钥管理服务。
8.4 发布前检查清单
- [ ] Redis 是否设置了访问密码,并且只允许应用服务器访问
- [ ] 连接字符串是否来自环境变量或密钥管理,没有提交到源码仓库
- [ ] 加锁是否设置了过期时间,解锁是否放在 finally 中
- [ ] 解锁是否使用 Lua 脚本并校验 requestId
- [ ] 锁过期时间是否大于业务最慢路径的耗时
- [ ] 是否有加锁成功、加锁失败、释放成功、释放跳过的日志
- [ ] 是否对库存扣减、订单创建做了并发压测
- [ ] 是否对幂等表加了唯一索引
- [ ] 是否考虑 Redis 主从切换场景下的兜底策略
- [ ] 是否避免在锁内执行长耗时外部调用
8.5 下一步扩展方向
如果这套锁服务要在团队内长期使用,可以把锁封装成内部 NuGet 包,统一锁的 Key 规范、过期时间策略和日志格式。也可以用 OpenTelemetry 对锁等待时间、获取成功率做埋点,把分布式锁纳入监控体系。
对于更长链路、更复杂的业务流程,锁不应该包住整个流程,而应该尽量缩小临界区。更合理的方式是使用状态机、事件驱动和幂等表来保证最终一致,锁只保护短时间内真正需要互斥的状态流转。对瞬时峰值流量,分布式锁也不是唯一的解决方案,限流、削峰、消息队列都可以配合使用。
如果前端使用 Vue 消费 WebAPI 的文件下载接口,还会涉及 blob 接收二进制流、设置Content-Disposition文件名等问题,这属于另一个工程主题,但和分布式锁一样,都属于 WebAPI 发布后常见的联调细节。建议在项目初期就把这类跨端联调规则写进接口文档。
9. 结合 AI 辅助开发:哪些代码可以交给 AI,哪些必须人工把关
9.1 AI 适合生成什么
在 .NET 10 WebAPI 项目中,AI 比较适合生成 Controller 骨架、DTO、项目初始化、健康检查、Swagger 配置、常规 CRUD 等重复度高的代码。分布式锁、事务、幂等、并发控制这类正确性敏感的代码,AI 生成的结果只能作为初稿,关键路径必须人工理解并审查。
9.2 给 AI 的提示词要包含约束
同样的需求,如果只写“帮我写个分布式锁”,AI 很可能给出KeyDelete直接解锁,或者在每个请求里 new 一个连接对象。正确的做法是在提示词中明确约束条件。下面是一个可以参考的提示词:
请用 .NET 10 生成一个 WebAPI 库存扣减接口。 要求: 1. 使用 Controller 风格,返回统一响应格式。 2. 使用 Redis 分布式锁,锁 Key 按 productId 拆分。 3. 加锁使用 SET NX EX,Value 使用每次请求生成的 requestId。 4. 锁内先查询库存,再创建订单流水,最后扣减库存。 5. 解锁必须使用 Lua 脚本,先比较 Value 再删除。 6. 释放锁必须放在 finally 中。 7. 库存不足时返回业务错误码。AI 生成后,重点审查加锁是否带过期时间、解锁是否用了 Lua、锁是否在 finally 中释放,而不是急着把代码合并到主线。
9.3 AI 生成代码审查清单
| 审查点 | 常见问题 | 通过标准 |
|---|---|---|
| Redis 连接 | 每次请求创建 ConnectionMultiplexer | 单例注册,复用连接 |
| 加锁 | 没有设置过期时间 | 必须带 EX/PX 过期时间 |
| 解锁 | 直接删除 Key | Lua 比较 Value 后删除 |
| 异常处理 | 锁释放不在 finally 中 | finally 中释放锁 |
| 配置管理 | 密码硬编码 | 使用环境变量或配置中心 |
| 日志 | 加锁失败无日志 | 有 requestId、key、成功失败状态 |
| 幂等 | 只靠锁不写幂等表 | 锁加唯一键双保险 |
分布式锁的核心其实很小:一个原子加锁、一个安全释放、一个合理的过期时间。把它封装成一个稳定的锁服务后,再往上叠加库存防超卖、重复请求拦截,整套 WebAPI 在高并发场景下会清晰很多。对后端工程师来说,在 .NET 10 项目里完整走一遍 Redis 分布式锁的接入和排错,比零散地看 Redis 命令要有价值得多。