Blazor Admin 如何使用 Redis?缓存、分布式锁实战

Original 2026-10-03 995 views

后台系统里 Redis 通常承担四件事:缓存、分布式锁、发布订阅、跨实例共享状态。这篇文章讲 EasyAdminBlazor 2.3 分别是怎么做的,以及多实例部署时该注意什么。


一、接入:一行注册

builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
       .AddEasyAdminBlazorRedis(configuration["Redis:ConnectionString"]!);
{
  "Redis": {
    "ConnectionString": "127.0.0.1:6379,poolsize=10"
  }
}

注册实现只有两个服务:

public static WebApplicationBuilder AddEasyAdminBlazorRedis(this WebApplicationBuilder builder, string connectionString)
{
    builder.Services.TryAddSingleton<EasyAdminBlazor.IRedisService>(sp => new RedisService(connectionString));
    builder.Services.TryAddSingleton<IDistributedCache>(sp => new FreeRedisDistributedCache(sp.GetRequiredService<EasyAdminBlazor.IRedisService>()));
    return builder;
}

用 TryAddSingleton 意味着宿主项目自己注册的同类型服务优先。如果你已经有统一的 Redis 封装,可以替换掉它。

不装这个扩展时,DI 里根本没有 IRedisService,依赖它的功能(聊天、消息实时推送)会自动降级——这也是为什么"没配 Redis 也能跑后台"。


二、两条缓存通道,不要用混

ICacheService IRedisService
默认实现 MemoryCacheService(进程内 IMemoryCache) RedisService(FreeRedis)
注册方式 TryAddSingleton<ICacheService, MemoryCacheService>() 装 Redis 扩展后注册
多实例一致性 ❌ 各实例一份 ✅ 共享
适用场景 单机部署、纯读多写少、可容忍短暂不一致 多实例、需要共享状态、分布式锁、发布订阅
可替换 EasyAdminBlazor.FusionCache 扩展 自行实现 IRedisService

一条实用规则:只要部署了多个实例,能被写入的数据就不要依赖 ICacheService。

权限缓存就是典型。它的缓存键里带了"权限版本号",版本号本身存在 ICacheService 里:

private string ScopedPermissionVersionKey => $"{TenantCachePrefix}{PermissionVersionKey}";

private Task<int> GetPermissionVersionAsync()
    => cache.GetOrCreateAsync(ScopedPermissionVersionKey, () => Task.FromResult(0), TimeSpan.FromDays(30));

单实例下没问题;多实例下"管理员改了角色权限,版本号只在 A 实例上加 1"这种情况在多实例部署里需要额外考虑(例如接入 FusionCache + Redis 做二级缓存)。这是部署形态带来的差异,不是权限模型本身的缺陷。


三、IRedisService 的能力地图

接口按用途分了七个区块,先看全貌:

public interface IRedisService : IDisposable
{
    #region 在线状态
    void JoinOnline(long userId);
    void RemoveOnline(long userId);
    IEnumerable<long> GetClientListByOnline();
    bool HasOnline(long clientId);
    #endregion

    #region 发布订阅
    void Publish<T>(SubscribeChannel channel, T message);
    IDisposable Subscribe<T>(SubscribeChannel channel, Action<T> handler);
    void Unsubscribe(SubscribeChannel channel);
    #endregion

    #region 分布式锁
    object? Lock(string key, int expireSeconds);
    void ReleaseLock(object? lockHandle);
    #endregion

    #region Key / String / Hash / List / Set / Sorted Set
    ...
    #endregion
}

对应到后台开发,常见用途是:

区块 典型用途
在线状态 聊天、消息推送的"用户是否在线"
发布订阅 跨实例通知(消息已读、系统广播)
分布式锁 定时任务、缓存落库、一次性初始化
String / Hash 缓存对象、计数器、在线列表
List 聊天消息队列(写入待落库)
Set / Sorted Set 去重集合、排行榜

四、发布订阅:两种频道

public enum SubscribeChannel
{
    MessageNotification,
    ReadNotification
}

发布和订阅直接用泛型方法,序列化用 Newtonsoft.Json:

public void Publish<T>(SubscribeChannel channel, T message)
{
    _redisClient.Publish(channel.ToString(), JsonConvert.SerializeObject(message));
}

public IDisposable Subscribe<T>(SubscribeChannel channel, Action<T> handler)
{
    var subscription = _redisClient.Subscribe(channel.ToString(), (redisChannel, redisValue) =>
    {
        try
        {
            var data = JsonConvert.DeserializeObject<T>(redisValue.ToString());
            if (data != null) handler(data);
        }
        catch
        {
            // 单条消息反序列化/处理失败不影响订阅,跳过该消息
        }
    });

    return new SubscriptionDisposer(() => _redisClient.UnSubscribe(channel.ToString()));
}

两个细节值得学:

  1. 单条消息处理失败被隔离在 try/catch 里。一条脏消息不应该把整个订阅打断。
  2. 返回 IDisposable。SubscriptionDisposer 保证退订只执行一次,调用方可以直接 using。

五、分布式锁:怎么拿、怎么放

public object? Lock(string key, int expireSeconds)
{
    return _redisClient.Lock(key, expireSeconds);
}

public void ReleaseLock(object? lockHandle)
{
    if (lockHandle == null) return;

    if (lockHandle is IDisposable disposable)
    {
        disposable.Dispose();
        return;
    }

    var unlockMethod = lockHandle.GetType().GetMethod("Unlock");
    unlockMethod?.Invoke(lockHandle, null);
}

要点:

  • 锁对象是 object?,null 表示"没抢到锁"。调用方必须先判空。
  • 必须传过期时间(expireSeconds)。持锁进程崩溃时,锁会到期自动释放,不会永久堵塞。
  • 释放方式做了兼容:实现 IDisposable 的直接 Dispose(),否则反射调用 Unlock()。

用起来是这样:

var lockObj = redis.Lock($"{admin.TenantCachePrefix}save_messages_lock", 30);
if (lockObj == null)
{
    // 没抢到锁:说明别的实例正在处理,直接跳过
    return;
}

try
{
    // 临界区:只允许一个实例执行
}
finally
{
    redis.ReleaseLock(lockObj);
}

锁键要带租户前缀,原因上一篇讲过:全局锁会让一个租户的操作阻塞另一个租户。


六、实战:聊天消息的"缓存 + 批量落库"

这是仓库里分布式锁最完整的一个用例,位于 AdminMessageService:

private const string LockKey = "save_messages_lock";
private const int LockExpireSeconds = 30;

public async Task SaveMessagesToDatabase()
{
    if (_redis == null) return;

    var lockObj = _redis.Lock($"{_admin.TenantCachePrefix}{LockKey}", LockExpireSeconds);
    if (lockObj != null)
    {
        var keys = _redis.Keys($"{_admin.TenantCachePrefix}chat_messages:*");
        foreach (var key in keys)
        {
            var messagesJson = _redis.LRange(key, 0, -1);
            if (messagesJson != null && messagesJson.Length > 0)
            {
                var messages = messagesJson.Select(json => JsonConvert.DeserializeObject<SysMessage>(json))
                    .Where(m => m != null)
                    .ToList();
                if (messages.Count > 0) await _repo.InsertAsync(messages);
                _redis.Del(key);
            }
        }

        _redis.ReleaseLock(lockObj);
    }
}

这个设计解决了三个问题:

问题 做法
聊天消息量大,逐条写库压力大 先 LPush 到 Redis 列表,定时批量落库
多实例同时落库会重复插入 分布式锁 + 30 秒过期,只有一个实例执行
落库后重复消费 落库成功后 Del 掉 key

对应的写入端(Chat.razor):

_redisService?.LPush($"{admin.TenantCachePrefix}chat_messages:{receiverId}", JsonConvert.SerializeObject(newMessage));

读写两端用同一个前缀规则,是这套机制能成立的前提。

需要提醒一点:Keys(pattern) 在 Redis 上是 KEYS 命令,数据量大时会阻塞单线程。示例里配合了前缀和较短的消息列表生命周期,生产环境如果消息量很大,建议改成 SCAN 或维护一个"待落库 key 集合"(例如用 Set 记录活跃的会话 key)。


七、IDistributedCache 适配器

如果宿主项目里有依赖 ASP.NET Core 标准 IDistributedCache 的组件(比如 Session、Data Protection),可以直接复用:

public class FreeRedisDistributedCache : IDistributedCache
{
    private readonly EasyAdminBlazor.IRedisService _redisService;

    public byte[]? Get(string key) => _redisService.GetBytes(key);
    public async Task<byte[]?> GetAsync(string key, CancellationToken token = default)
        => await _redisService.GetBytesAsync(key, token);

    ...
    private TimeSpan? GetExpiry(DistributedCacheEntryOptions options)
    {
        if (options.AbsoluteExpiration.HasValue)
            return options.AbsoluteExpiration.Value - DateTimeOffset.Now;
        else if (options.AbsoluteExpirationRelativeToNow.HasValue)
            return options.AbsoluteExpirationRelativeToNow.Value;
        else if (options.SlidingExpiration.HasValue)
            return options.SlidingExpiration.Value;

        return null;
    }
}

三种过期策略都做了映射:绝对时间、相对时间、滑动过期。注意 SlidingExpiration 在这里是按过期时长直接设置的(不是每次读续期),如果需要严格的滑动语义要自己扩展。


八、多实例部署的检查清单

  1. 共享状态不要放进程内缓存。 需要跨实例一致的(权限版本号、在线状态、待落库队列)都走 Redis。
  2. 锁的粒度和过期时间要设计。 太粗会互相阻塞,太细保护不住;过期时间要大于正常执行时间,同时能兜住进程崩溃。
  3. 锁保护的操作要幂等。 锁过期后另一个实例可能重入,业务上要能重复执行而不出错。
  4. 慎用 KEYS。 大数据量下用 SCAN 或维护索引集合。
  5. 缓存键统一带租户前缀。 见上一篇。
  6. 序列化要统一。 RedisService 用 Newtonsoft.Json,自定义类型要确认能正确序列化/反序列化。
  7. 连接串加连接池参数。 示例配置是 127.0.0.1:6379,poolsize=10。
  8. Redis 不可用要有降级预期。 聊天、消息推送会失效,但后台的 CRUD、权限、审批不应因此不可用——这也是框架把 Redis 做成可选扩展的原因。

九、小结

需求 用哪个 关键点
单机缓存 ICacheService 简单、快,多实例下不一致
跨实例共享缓存 IRedisService + 租户前缀 键规范统一
分布式锁 Lock / ReleaseLock 必带过期时间,键带租户前缀,临界区幂等
跨实例通知 Publish / Subscribe 单条消息失败要隔离
标准分布式缓存接口 IDistributedCache 适配器 可被 Session 等组件复用

Redis 在后台系统里不是"加了就快",而是多实例部署时的共享基础设施。用对了是分布式锁和缓存,用错了就是把单机 bug 放大到集群。


如果你正在用 .NET 10 + Blazor 做后台,需要缓存与分布式锁,可以看看 EasyAdminBlazor 的 Redis 扩展:接口分层清晰,锁和缓存都有现成实现,源码可直接对照。