Redis 分布式锁在 .NET 10 高并发接口中的实战解析
2026/9/8 13:26:56 网站建设 项目流程

集群环境下的高并发接口,最常见的风险不是一台服务器扛不住,而是多实例之间互相拆台。库存扣减前先检查库存,检查完还没更新,另一个实例已经拿走最后一件商品;前端用户手滑点了两次提交,两个请求同时落在不同实例上,订单被重复创建。这些问题在单机时代可以用locksynchronized或数据库事务压住,但部署成集群后,进程内的锁互相看不到,就必须引入分布式锁。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,但订单却生成了两份。单机部署时,把这段代码放进locksynchronized可以让同一进程内的线程串行执行;一旦服务部署成两个实例,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 下载当前官方正式版或最新预览版
IDEVisual Studio 2022,需勾选“ASP.NET 和 Web 开发”工作负载
Redis7.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 ping

redis-cli ping返回PONG表示 Redis 可用。Linux 下可以使用包管理器安装,也可以源码编译;对于版本受限的场景,源码编译更可控,但需要额外安装编译工具链。

启动 Redis 后,检查版本和基本状态:

redis-cli INFO server | grep redis_version

2.3 Redis 基本配置和可视化客户端

本地学习阶段,Redis 配置文件可以保持最简单的状态,但有两个点要提前注意:端口不要和已有服务冲突,生产环境不要裸奔。

bind 127.0.0.1 protected-mode yes port 6379 # 本地学习可以先不设密码,生产环境必须设置 # requirepass your-strong-password

bind 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.Redis

StackExchange.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 连接失败不阻止应用启动学习阶段方便;生产环境要配合健康检查,不能长期掩盖连接问题
defaultDatabaseRedis 逻辑库编号默认 0 即可,不要通过多库强行隔离不同业务
passwordRedis 密码生产环境必填
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 过期毫秒 NXWhen.NotExists就是 NX,第三个参数 TimeSpan 就是过期时间。

三个核心参数的含义:

参数含义
lockKey锁的标识,按业务维度设计,例如 order:stock:lock:1001
requestId当前请求唯一标识,用于解锁时校验持有者
expiry锁自动过期时间,防御持有者崩溃后锁永久不释放

如果没有过期时间,持有者进程崩溃后锁永远存在,其他请求全部拿不到锁,这就是死锁;有了过期时间,崩溃后最多等待一个过期周期。所以过期时间不是可选项,而是分布式锁的保底机制。

4.2 第二版:解锁必须用 Lua,不能直接删除 Key

第一版加锁看起来已经完整,但解锁如果写成下面这样,会在高并发下埋雷:

await _db.KeyDeleteAsync(lockKey);

为什么不能直接删?考虑这个时间线:

  1. 请求 A 获取锁,Value = A。
  2. A 业务执行时间超过锁过期时间,锁在 Redis 中自动消失。
  3. 请求 B 获取同一把锁,Value = B。
  4. A 业务执行完,执行KeyDelete(lockKey),直接把 B 刚获取的锁删掉了。
  5. 请求 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 end

C# 调用代码:

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 EXKeyDelete互斥、防死锁可能误删他人锁
第二版SET NX EXLua 比较后删除误删他人锁锁过期后业务未完成
第三版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 章的TryLockAsyncAcquireLockWithRetryAsyncReleaseLockAsync和 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 秒,生产环境要结合业务最慢路径重新评估。
  • 实际项目中,CreateOrderAsyncDecreaseStockAsync应包在同一个数据库事务中,这里为了突出锁的逻辑做了简化。

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 = 100failCount = 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 的命名空间没有隔离,或者有人执行了FLUSHDBKEYS *删除操作。

解决:锁 Key 统一使用lock:前缀,缓存 Key 使用cache:前缀;生产环境禁止执行FLUSHDBFLUSHALLKEYS命令。

7.6 从现象到根因的排查链路

排查分布式锁问题,可以按下面的顺序走:

  1. 确认现象:是库存超卖、订单重复,还是接口大面积失败。
  2. 看日志:检查加锁、解锁日志是否输出了 requestId、lockKey、成功状态,统计加锁失败率。
  3. 查 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:1001

TTL 为 -1 表示锁没有过期时间,需要检查加锁代码是否漏掉了 expiry;TTL 为 -2 表示 Key 不存在。GET 返回的 Value 与当前请求 requestId 不一致时,可能是误删或锁已过期被重新获取。

  1. 看主从:执行redis-cli INFO replication检查主从状态,确认事故时间是否和主从切换时间吻合。
  2. 看业务耗时:通过日志或链路追踪统计临界区内耗时,判断是否超过锁过期时间。
  3. 看数据库:检查订单表、幂等表中是否存在相同 requestId 的重复数据。

常见问题的汇总表:

现象常见原因检查方式处理建议
多个请求同时进入临界区锁过期时间比业务耗时短对比业务耗时和锁 TTL调大过期时间或增加续期
偶发误删锁后并发进入解锁未校验 Value看日志是否出现释放锁跳过改用 Lua 脚本解锁
库存不为负但订单重复只防了扣减没防创建查订单表 requestId 重复数锁内加幂等表或唯一键
锁 Key 还在但拿不到锁锁过期时间过长查 TTL 和请求等待时间缩短过期时间,增加续期
主从切换后锁丢失主从异步复制丢锁核对切换时间和锁 Key 状态数据库兜底或按一致性要求评估 RedLock

8. 生产落地:参数、监控、检查清单和扩展方向

8.1 Key 设计与命名规范

分布式锁的 Key 建议按业务:领域:lock:业务ID格式设计,例如:

  • order:stock:lock:1001
  • pay:refund:lock:order_20250101
  • biz: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 过期时间
解锁直接删除 KeyLua 比较 Value 后删除
异常处理锁释放不在 finally 中finally 中释放锁
配置管理密码硬编码使用环境变量或配置中心
日志加锁失败无日志有 requestId、key、成功失败状态
幂等只靠锁不写幂等表锁加唯一键双保险

分布式锁的核心其实很小:一个原子加锁、一个安全释放、一个合理的过期时间。把它封装成一个稳定的锁服务后,再往上叠加库存防超卖、重复请求拦截,整套 WebAPI 在高并发场景下会清晰很多。对后端工程师来说,在 .NET 10 项目里完整走一遍 Redis 分布式锁的接入和排错,比零散地看 Redis 命令要有价值得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询