前两篇已经把 Redis 在 ASP.NET Core 里的接入方式和性能访问模式搭起来了。到了生产环境,真正决定系统是否稳定的,往往不再是“能不能读写 Redis”,而是这些更尖锐的问题:分布式锁应该帮你解决什么,不能替你解决什么?限流逻辑放到 Redis 之后,怎样和 ASP.NET Core 请求管道配合?幂等状态该如何落地,才不会把 Redis 错当成最终事实来源?线上一旦出现 RedisTimeoutException、连接抖动或吞吐掉速,先看应用线程池、序列化和访问模式,还是先看 Redis 实例本身?

本文就从 StackExchange.Redis 的客户端视角,把这些问题集中收束。

系列导航:

分布式锁:先明确它是“协调工具”,不是最终正确性来源

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);

这里真正重要的不是模板,而是两条原则:

  1. Redis 用来挡住重复执行,数据库或最终持久化系统用来证明业务是否真的成功。
  2. processingcompleted、缓存结果的 TTL 要和业务窗口匹配,不能无限保留,也不能短到请求重试时刚好失效。

如果你在消息消费端做幂等,这个思路同样成立:Redis 可以先挡住明显重复消息,但真正的“已经消费过”通常还要结合业务表唯一键、Inbox 表或事件日志一起设计。

生产排障:先看应用侧等待方式,再看 Redis 实例指标

线上一旦报出 Redis 相关错误,很多团队会第一时间盯着 CPU、内存或实例配置。但对 ASP.NET Core + StackExchange.Redis 组合来说,更高效的排查顺序通常是:

  1. 先看应用侧有没有同步阻塞异步.Result.Wait()、线程池饥饿都可能放大超时。
  2. 再看是否有大值、串行等待、热点过期风暴:很多超时是访问模式造成的,不是实例性能凭空消失。
  3. 然后看连接与网络事件:是否频繁重连、DNS 抖动、跨可用区访问。
  4. 最后再看 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 连接失败和恢复事件是否同时增多?
  • SLOWLOGLATENCY DOCTOR、基础资源指标是否也同步异常?

如果只有应用层超时在涨,但 Redis 侧没有对应慢命令、没有资源压力,那大概率应先回到应用等待模型和数据模型排查,而不是急着改 Redis 参数。

面向 ASP.NET Core 的生产检查清单

把分布式锁、限流、幂等和排障视角收在一起后,Redis 在 ASP.NET Core 项目里的上线前检查,至少应该覆盖下面这些问题:

  1. 连接生命周期是否正确ConnectionMultiplexer 是否为长寿命单例,是否存在按请求创建连接的反模式。
  2. 异步链路是否完整:是否还有 .Result.Wait()、同步阻塞回调等用法。
  3. 关键 Key 是否有命名规范与 TTL:锁键、幂等键、限流键、缓存键都应该可解释、可清理。
  4. 锁是否有唯一 token 与原子释放:不能只靠 SETNX + DEL
  5. 幂等是否有最终持久化兜底:Redis 不能替代数据库唯一约束或业务日志。
  6. 限流是否位于统一请求入口:不要让每个控制器各自实现一套计数规则。
  7. 热点与批量访问是否经过压测:确认 TTL 抖动、并发回源和批量读取对线程池与下游的影响。
  8. 日志与健康检查是否接好:至少能观察连接失败、恢复、错误消息和健康状态。
  9. 实例指标是否有解释链路:出现超时时,团队能区分应用等待问题、网络问题和 Redis 实例问题。

如果这些问题还只能靠“应该没事”来回答,那么 Redis 还没有真正进入生产可维护状态。

本篇小结

作为 dotnet-practice 组的收束篇,真正需要带走的不是几段代码,而是几条生产边界:

  • Redis 分布式锁适合做协调,不适合替代最终正确性约束。
  • 限流适合用 Redis 共享状态,用 ASP.NET Core 入口统一拦截。
  • 幂等可以借助 Redis 快速拦截重复请求,但最终状态必须回到持久化事实来源。
  • 线上排障先看应用侧等待模型、热点与序列化,再结合 Redis 指标定位,不要一上来就改实例参数。

本篇解决了什么:

  • 明确了分布式锁、限流和幂等分别该由 Redis、ASP.NET Core 与业务持久层承担什么职责
  • 给出了一条更贴近生产的 RedisTimeoutException、连接抖动与吞吐下降排查顺序
  • 把前三篇 .NET 实战文章收束成一套从接入、性能到生产治理的落地路径

系列收束:

  • 这 13 篇已经把 Redis 从入门认知、缓存与数据结构、内存与高可用,到 .NET 实战完整串起来。
  • 后续继续扩展时,优先沉淀公共组件、监控和团队规范,而不是把 Redis 细节散落在单个业务控制器里。