后台系统里 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()));
}
两个细节值得学:
- 单条消息处理失败被隔离在 try/catch 里。一条脏消息不应该把整个订阅打断。
- 返回
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 在这里是按过期时长直接设置的(不是每次读续期),如果需要严格的滑动语义要自己扩展。
八、多实例部署的检查清单
- 共享状态不要放进程内缓存。 需要跨实例一致的(权限版本号、在线状态、待落库队列)都走 Redis。
- 锁的粒度和过期时间要设计。 太粗会互相阻塞,太细保护不住;过期时间要大于正常执行时间,同时能兜住进程崩溃。
- 锁保护的操作要幂等。 锁过期后另一个实例可能重入,业务上要能重复执行而不出错。
- 慎用
KEYS。 大数据量下用SCAN或维护索引集合。 - 缓存键统一带租户前缀。 见上一篇。
- 序列化要统一。
RedisService用Newtonsoft.Json,自定义类型要确认能正确序列化/反序列化。 - 连接串加连接池参数。 示例配置是
127.0.0.1:6379,poolsize=10。 - Redis 不可用要有降级预期。 聊天、消息推送会失效,但后台的 CRUD、权限、审批不应因此不可用——这也是框架把 Redis 做成可选扩展的原因。
九、小结
| 需求 | 用哪个 | 关键点 |
|---|---|---|
| 单机缓存 | ICacheService |
简单、快,多实例下不一致 |
| 跨实例共享缓存 | IRedisService + 租户前缀 |
键规范统一 |
| 分布式锁 | Lock / ReleaseLock |
必带过期时间,键带租户前缀,临界区幂等 |
| 跨实例通知 | Publish / Subscribe |
单条消息失败要隔离 |
| 标准分布式缓存接口 | IDistributedCache 适配器 |
可被 Session 等组件复用 |
Redis 在后台系统里不是"加了就快",而是多实例部署时的共享基础设施。用对了是分布式锁和缓存,用错了就是把单机 bug 放大到集群。
如果你正在用 .NET 10 + Blazor 做后台,需要缓存与分布式锁,可以看看 EasyAdminBlazor 的 Redis 扩展:接口分层清晰,锁和缓存都有现成实现,源码可直接对照。