Redis + .NET 实战:分布式锁、限流、幂等与生产排障(十三)
前两篇已经把 Redis 在 ASP.NET Core 里的接入方式和性能访问模式搭起来了。到了生产环境,真正决定系统是否稳定的,往往不再是“能不能读写 Redis”,而是这些更尖锐的问题:分布式锁应该帮你解决什么,不能替你解决什么?限流逻辑放到 Redis 之后,怎样和 ASP.NET Core 请求管道配合?幂等状态该如何落地,才不会把 Redis 错当成最终事实来源?线上一旦出现 RedisTimeoutException、连接抖动或吞吐掉速,先看应用线程池、序列化和访问模式,还是先看 Redis 实例本身?
本文就从 StackExchange.Redis 的客户端视角,把这些问题集中收束。
系列导航:
- 上一篇:Redis + .NET 实战:缓存、批量、Pipeline 与性能优化(十二)
- 当前篇:Redis + .NET 实战:分布式锁、限流、幂等与生产排障(十三)(本文)
分布式锁:先明确它是“协调工具”,不是最终正确性来源
Redis 锁在 .NET 项目里常见到近乎滥用:防止重复下单、串行执行定时任务、控制某个资源一次只能被一个节点处理。它确实很好用,但必须先明确一个边界:Redis 分布式锁更适合减少并发冲突和重复执行,不适合承担强一致的最终正确性。
也就是说,如果业务错误的代价极高,例如账务扣减、库存最终落账、跨系统唯一状态迁移,真正兜底的仍然应该是数据库约束、唯一索引、版本号或业务事务日志;Redis 锁只是第一层协调,不是最后的真相。
StackExchange.Redis 下的最小可用锁写法
在客户端层面,拿锁的最小正确姿势通常是:
- 使用
SET key value NX PX ttl语义,也就是StringSetAsync(..., When.NotExists, expiry)。 - 锁值使用唯一 token,而不是固定字符串。
- 解锁时先校验 token,再删除键,整个过程通过 Lua 保证原子性。
一个精简的实现如下:
using StackExchange.Redis;
public sealed class RedisDistributedLock
{
private static readonly LuaScript ReleaseScript = LuaScript.Prepare(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end");
private readonly IDatabase _database;
public RedisDistributedLock(IConnectionMultiplexer multiplexer)
{
_database = multiplexer.GetDatabase();
}
public async Task<RedisLockHandle?> TryAcquireAsync(string key, TimeSpan expiry)
{
string token = Guid.NewGuid().ToString("N");
bool acquired = await _database.StringSetAsync(
key,
token,
expiry,
when: When.NotExists);
if (!acquired)
{
return null;
}
return new RedisLockHandle(_database, key, token);
}
public sealed class RedisLockHandle : IAsyncDisposable
{
private readonly IDatabase _database;
private readonly string _key;
private readonly string _token;
public RedisLockHandle(IDatabase database, string key, string token)
{
_database = database;
_key = key;
_token = token;
}
public async ValueTask DisposeAsync()
{
await _database.ScriptEvaluateAsync(
ReleaseScript,
keys: [new RedisKey(_key)],
values: [new RedisValue(_token)]);
}
}
}
使用时可以非常直接:
await using RedisDistributedLock.RedisLockHandle? handle =
await distributedLock.TryAcquireAsync(
key: $"lock:order:{orderId}",
expiry: TimeSpan.FromSeconds(15));
if (handle is null)
{
return Results.Conflict("Order is being processed.");
}
await ProcessOrderAsync(orderId);
return Results.Accepted();
这个实现已经避开了最常见的两类错误:
- 没有过期时间,导致进程异常后永远不释放。
- 用固定值加锁,结果把别人的锁删掉。
但它仍然不是“万无一失锁”。如果业务执行时间可能超过 TTL,就需要续约策略;如果你不能接受锁过期后另一个节点接管同一资源,就不能只靠 Redis 锁维持最终顺序。
限流:用 Redis 做分布式配额,用 ASP.NET Core 管请求入口
Redis 很适合做跨实例共享的速率计数,尤其适合多台 ASP.NET Core 应用共同维护一个用户、租户或 IP 的限额。更推荐的思路通常不是把全部逻辑写进控制器,而是:
- Redis 负责跨节点共享状态。
- ASP.NET Core 的中间件、过滤器或端点过滤器负责在请求入口统一拦截。
最简单的固定窗口限流可以用 Lua 保证 INCR 与首次 PEXPIRE 放在一起:
using StackExchange.Redis;
public sealed class RedisRateLimiter
{
private static readonly LuaScript FixedWindowScript = LuaScript.Prepare(
"local current = redis.call('INCR', KEYS[1]) " +
"if current == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end " +
"return current");
private readonly IDatabase _database;
public RedisRateLimiter(IConnectionMultiplexer multiplexer)
{
_database = multiplexer.GetDatabase();
}
public async Task<long> IncrementAsync(string subject, int windowSeconds)
{
long bucket = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / windowSeconds;
string key = $"ratelimit:{subject}:{bucket}";
RedisResult result = await _database.ScriptEvaluateAsync(
FixedWindowScript,
keys: [new RedisKey(key)],
values: [windowSeconds * 1000]);
return (long)result;
}
}
然后在 Minimal API 里接成一个端点过滤器:
app.MapPost("/payments", HandlePayment)
.AddEndpointFilter(async (context, next) =>
{
var limiter = context.HttpContext.RequestServices.GetRequiredService<RedisRateLimiter>();
string subject = context.HttpContext.User.Identity?.Name
?? context.HttpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
long count = await limiter.IncrementAsync(subject, windowSeconds: 60);
if (count > 30)
{
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
return await next(context);
});
这套写法的重点是职责分层:Redis 只负责记录共享配额状态,HTTP 入口负责决定要不要放行、返回什么状态码、是否补充 Retry-After。
如果业务对边界突刺很敏感,再往滑动窗口或令牌桶升级;但不要因为“看起来高级”就跳过固定窗口这个足够实用的起点。
幂等:Redis 适合做快速拦截层,不适合单独承担最终状态
在支付、订单创建、Webhook 回调、消息消费这类场景里,幂等通常比“只限流”更重要。因为限流解决的是频率,幂等解决的是“同一个请求重复到达时,系统怎样给出一致结果”。
Redis 在这里很适合承担两个职责:
- 用唯一键快速识别重复请求。
- 在短窗口内缓存处理状态或结果,降低数据库与业务逻辑的重复执行。
但必须强调:Redis 里的幂等状态不应该是最终唯一事实来源。 真正决定这笔业务是否已经成功的,仍然应该是数据库记录、唯一约束、事件日志或其他持久化真相。
一个常见的 ASP.NET Core 入口模式是要求客户端提交 Idempotency-Key,服务端先尝试抢占处理权:
public sealed class RedisIdempotencyService
{
private readonly IDatabase _database;
public RedisIdempotencyService(IConnectionMultiplexer multiplexer)
{
_database = multiplexer.GetDatabase();
}
public Task<bool> TryBeginAsync(string key, TimeSpan ttl)
{
return _database.StringSetAsync(
key,
"processing",
expiry: ttl,
when: When.NotExists);
}
public Task CompleteAsync(string key, string responseJson, TimeSpan ttl)
{
return _database.StringSetAsync(key, responseJson, expiry: ttl);
}
public Task<RedisValue> GetAsync(string key)
{
return _database.StringGetAsync(key);
}
}
使用时可以放在应用服务或过滤器里:
string idemKey = $"idem:payments:{request.IdempotencyKey}";
bool started = await idempotency.TryBeginAsync(idemKey, TimeSpan.FromMinutes(5));
if (!started)
{
RedisValue existing = await idempotency.GetAsync(idemKey);
if (!existing.IsNullOrEmpty && existing != "processing")
{
return Results.Content(existing!, "application/json");
}
return Results.Conflict("Request is already being processed.");
}
PaymentResult result = await paymentService.CreatePaymentAsync(request);
string responseJson = JsonSerializer.Serialize(result);
await idempotency.CompleteAsync(idemKey, responseJson, TimeSpan.FromHours(12));
return Results.Ok(result);
这里真正重要的不是模板,而是两条原则:
- Redis 用来挡住重复执行,数据库或最终持久化系统用来证明业务是否真的成功。
processing、completed、缓存结果的 TTL 要和业务窗口匹配,不能无限保留,也不能短到请求重试时刚好失效。
如果你在消息消费端做幂等,这个思路同样成立:Redis 可以先挡住明显重复消息,但真正的“已经消费过”通常还要结合业务表唯一键、Inbox 表或事件日志一起设计。
生产排障:先看应用侧等待方式,再看 Redis 实例指标
线上一旦报出 Redis 相关错误,很多团队会第一时间盯着 CPU、内存或实例配置。但对 ASP.NET Core + StackExchange.Redis 组合来说,更高效的排查顺序通常是:
- 先看应用侧有没有同步阻塞异步:
.Result、.Wait()、线程池饥饿都可能放大超时。 - 再看是否有大值、串行等待、热点过期风暴:很多超时是访问模式造成的,不是实例性能凭空消失。
- 然后看连接与网络事件:是否频繁重连、DNS 抖动、跨可用区访问。
- 最后再看 Redis 实例指标:慢命令、内存压力、持久化抖动、复制延迟等。
从应用层开始,你至少应该把这些事件挂到日志里:
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var multiplexer = ConnectionMultiplexer.Connect("localhost:6379,abortConnect=false");
multiplexer.ConnectionFailed += (_, args) =>
Log.ForContext("Endpoint", args.EndPoint?.ToString())
.Error(args.Exception, "Redis connection failed");
multiplexer.ConnectionRestored += (_, args) =>
Log.ForContext("Endpoint", args.EndPoint?.ToString())
.Information("Redis connection restored");
multiplexer.ErrorMessage += (_, args) =>
Log.Warning("Redis error message: {Message}", args.Message);
return multiplexer;
});
如果项目已经接了健康检查,也建议把 Redis 状态纳入统一探针:
builder.Services
.AddHealthChecks()
.AddRedis("localhost:6379", name: "redis");
这些日志和探针的意义在于:你能先回答“是客户端连接在抖,还是业务负载在抖”,再决定要不要继续深入实例层面。
RedisTimeoutException 最常见的根因,不一定在 Redis 本身
RedisTimeoutException 经常会让人直觉认为“Redis 变慢了”。但在 .NET 服务里,这类异常最常见的根因往往至少有一部分在客户端周边:
- 同步代码阻塞了异步回调,线程池积压。
- 单次请求同时处理过多大对象,序列化和 GC 抬高了延迟。
- 请求代码把多个 Redis 操作串行等待,累计超时。
- 热点 Key 失效,导致大批请求同时回源和重试。
- 应用层、网关层、调用方多重重试叠加,让抖动被放大。
这意味着一个更稳的排障问题顺序应该是:
- 这次超时发生前,请求路径里有没有大量
.Wait()或.Result? - 最近是否上线了更大的缓存对象或新的批量读取逻辑?
- 热点 Key 的 TTL 是否集中到期?
- Redis 连接失败和恢复事件是否同时增多?
SLOWLOG、LATENCY DOCTOR、基础资源指标是否也同步异常?
如果只有应用层超时在涨,但 Redis 侧没有对应慢命令、没有资源压力,那大概率应先回到应用等待模型和数据模型排查,而不是急着改 Redis 参数。
面向 ASP.NET Core 的生产检查清单
把分布式锁、限流、幂等和排障视角收在一起后,Redis 在 ASP.NET Core 项目里的上线前检查,至少应该覆盖下面这些问题:
- 连接生命周期是否正确:
ConnectionMultiplexer是否为长寿命单例,是否存在按请求创建连接的反模式。 - 异步链路是否完整:是否还有
.Result、.Wait()、同步阻塞回调等用法。 - 关键 Key 是否有命名规范与 TTL:锁键、幂等键、限流键、缓存键都应该可解释、可清理。
- 锁是否有唯一 token 与原子释放:不能只靠
SETNX+DEL。 - 幂等是否有最终持久化兜底:Redis 不能替代数据库唯一约束或业务日志。
- 限流是否位于统一请求入口:不要让每个控制器各自实现一套计数规则。
- 热点与批量访问是否经过压测:确认 TTL 抖动、并发回源和批量读取对线程池与下游的影响。
- 日志与健康检查是否接好:至少能观察连接失败、恢复、错误消息和健康状态。
- 实例指标是否有解释链路:出现超时时,团队能区分应用等待问题、网络问题和 Redis 实例问题。
如果这些问题还只能靠“应该没事”来回答,那么 Redis 还没有真正进入生产可维护状态。
本篇小结
作为 dotnet-practice 组的收束篇,真正需要带走的不是几段代码,而是几条生产边界:
- Redis 分布式锁适合做协调,不适合替代最终正确性约束。
- 限流适合用 Redis 共享状态,用 ASP.NET Core 入口统一拦截。
- 幂等可以借助 Redis 快速拦截重复请求,但最终状态必须回到持久化事实来源。
- 线上排障先看应用侧等待模型、热点与序列化,再结合 Redis 指标定位,不要一上来就改实例参数。
本篇解决了什么:
- 明确了分布式锁、限流和幂等分别该由 Redis、ASP.NET Core 与业务持久层承担什么职责
- 给出了一条更贴近生产的
RedisTimeoutException、连接抖动与吞吐下降排查顺序 - 把前三篇
.NET实战文章收束成一套从接入、性能到生产治理的落地路径
系列收束:
- 这 13 篇已经把 Redis 从入门认知、缓存与数据结构、内存与高可用,到
.NET实战完整串起来。 - 后续继续扩展时,优先沉淀公共组件、监控和团队规范,而不是把 Redis 细节散落在单个业务控制器里。