缓存(内存 ⇄ Redis,配置即切换)
2026/8/12大约 7 分钟
缓存(内存 ⇄ Redis,配置即切换)
缓存分两层,业务代码一般不直接碰底层:
| 层 | 类 | 职责 |
|---|---|---|
| 基础设施 | CacheStore / ICache(Infrastructure/Cache/CacheStore.cs) | 后端抽象:本地内存 或 Redis,自动选择 |
| 业务门面 | CacheService(ZR.ServiceCore/Services/CacheService.cs) | 统一 key 规范、租户前缀、TTL 语义(会话 / 2FA / 登录锁定 / 状态复核…) |
| 历史实现 | CacheHelper(ZR.Common) | 仅剩权限、数据权限、扫码登录、短信码在用,新代码不要再用 |
一、两种后端 + 两类 Redis 连接
appsettings.json 的 RedisServer 下有两个连接串,分别对应两个缓存库:
| 连接串 | 语义 | 枚举 |
|---|---|---|
RedisServer:Cache | 业务缓存(默认) | CacheBackend.Cache |
RedisServer:Session | 会话态数据 | CacheBackend.Session |
每个库都可在「本地内存 / Redis」之间切换:
| 后端 | 实现类 | 适用 | 跨节点 |
|---|---|---|---|
| 本地内存(默认) | MemoryCacheStore | 单实例 / 测试 | 否 |
| Redis | RedisCacheStore | 多实例 / 负载均衡 | 是 |
二、配置与切换
"RedisServer": {
"open": 1, // 只有等于 1 才初始化 Redis(写 true 不生效)
"dbCache": false, // SqlSugar 二级缓存开关,与 open 相互独立
"Cache": "127.0.0.1:6379,defaultDatabase=0,poolsize=50,ssl=false,writeBuffer=10240,prefix=cache:",
"Session": "127.0.0.1:6379,defaultDatabase=0,poolsize=50,ssl=false,writeBuffer=10240,prefix=session:"
}
Program.cs里判断RedisServer:open == "1"才调用RedisServer.Initalize();为0(或写了true)时RedisServer.Cache/Session都为null,CacheStore全部回退本地内存;- 两个连接串都要配:只配了
Cache而Session为空,会话相关缓存仍走内存; - 连接串写错会直接抛异常导致启动失败(
Initalize()没有容错包装),部署前先用redis-cli -h ... ping验证; - 改动需重启进程,缓存配置不支持热更新。
三、ICache 接口
public enum CacheBackend { Cache, Session }
public interface ICache
{
T Get<T>(string key);
void Set<T>(string key, T value, int expireMinutes); // 过期单位:分钟
bool Exists(string key);
void Remove(string key);
// 原子自增(键不存在按 0 起算,仅首次设置过期)
long Increment(string key, int expireMinutes);
// 原子按 delta 增减(可为负),每次调用刷新过期时间
long IncrementBy(string key, long delta, int expireMinutes);
// 原子「仅当键不存在时写入」,返回 true 表示抢占成功
bool TrySet<T>(string key, T value, int expireMinutes);
}
三个原子方法的语义差异(这是选型的核心):
| 方法 | Redis 实现 | 内存实现 | 跨节点原子 | 典型用途 |
|---|---|---|---|---|
Increment | INCR + 首次 EXPIRE | 进程内锁 + 读改写 | 是 | 登录失败计数、2FA 失败计数、限流、字典版本号 |
IncrementBy | INCRBY + 每次 EXPIRE | 进程内锁 + 读改写 | 是 | AI 并发/在途额度(需要回退与自动回收) |
TrySet | SET key value NX EX seconds | 进程内锁 + Exists 判断 | 是 | 幂等占位、防重复提交、告警节流 |
内存后端的"原子"只在单进程内成立:多实例部署时每个实例各算各的,这也是多实例必须启用 Redis 的根本原因。
门面方法:
CacheStore.Default.Set("key", value, 30); // 等价于 For(CacheBackend.Cache)
var v = CacheStore.Default.Get<MyType>("key");
CacheStore.For(CacheBackend.Session).Set("sid", id, 30);
CacheStore.For 内部用 ConcurrentDictionary 缓存实例,可安全并发调用。
四、业务里怎么用
1. 简单键值:直接用 CacheStore
CacheStore.Default.Set($"my:key:{id}", obj, 30);
var obj = CacheStore.Default.Get<MyType>($"my:key:{id}");
CacheStore.Default.Remove($"my:key:{id}");
2. 会话、2FA、锁定这类:走 CacheService
不要自己拼 key —— CacheService 已经处理了租户前缀、TTL 语义与消费原子性:
CacheService.SetUserSession(userId, sessionId); // Session 库,TTL = JWT 有效期
CacheService.IsSessionRevoked(sessionId); // 注销后 token 立即失效
CacheService.SetTwoFactorTicket(ticket, payload, 5); // 第二步票据
CacheService.ConsumeTwoFactorTicket<T>(ticket, 5); // 原子消费,重复提交只有一个成功
CacheService.IsTwoFactorLocked(userId);
CacheService.SetUserStatus(userId, 0); // 状态复核缓存(60s)
3. Key 规范
- 按租户隔离:
CacheService.BuildTenantKey(key)会拼成{tenantId}:{key},多租户下务必用它; - 全局唯一标识不加前缀:
sessionId、2FA 票据、WebAuthn challenge 本身就是随机 GUID,加租户前缀会在"第二步请求租户上下文尚未建立"时读不到,因此这些 key 刻意不加前缀; - 过期时间统一用分钟,Redis 后端内部换算成秒;
- 命名建议
模块:对象:标识,便于用redis-cli --scan --pattern排查。
五、现存缓存清单
| 用途 | key 前缀 / 方法 | 后端 | 说明 |
|---|---|---|---|
| 用户权限 | CacheService.GetUserPerms / SetUserPerms | 内存(CacheHelper) | 登录时写入,权限变更时清除 |
| 数据权限部门 ID | CACHE-DATASCOPE-DEPTIDS_ | 内存(CacheHelper) | TTL 2 小时 |
| 单设备登录会话 | CACHE-USER-SESSION_ | Session | TTL = JwtSettings.Expire |
| 会话吊销 | CACHE-REVOKED-SESSION_ | Session | TTL = token 剩余有效期 |
| 扫码登录会话 | verifyScan_ | 内存(CacheHelper) | 移动端扫码状态 |
| 短信验证码 | phone_sms_code_ | 内存(CacheHelper) | 校验后删除 |
| 登录失败计数 | CACHE-LOGIN-FAIL_ | Cache | Increment,按 lockTime 过期 |
| 锁定告警节流 | CACHE-LOGIN-LOCK-ALERT_ | Cache | TrySet,防重复发提醒 |
| 用户 / 会员状态复核 | CACHE-USER-STATUS_ / CACHE-MEMBER-STATUS_ | Cache | TTL 1 分钟,禁用时主动清除 |
| 租户状态复核 | CACHE-TENANT-ACTIVE_ | Cache | TTL 1 分钟,停服/到期主动清除 |
| 2FA 票据 / 步骤 / 失败 / 锁定 / 告警 / 新设备 | CACHE-2FA-* | 票据走 Session,计数与锁定走 Cache | 失败计数用 Increment 跨节点累计 |
| 2FA 会话标记 | CACHE-2FA-SESSIONOK_ | Session | 扫码登录的闸门依据 |
| WebAuthn challenge | CACHE-WEBAUTHN-CHALLENGE_ | Session | 一次性消费,绑定用途与票据 |
| 字典缓存 | {tenant}:dict:v{n}:* + {tenant}:dict:cacheVersion | Cache | 写操作 Increment 版本号,旧 key 自然失效,TTL 10 分钟 |
| AI 治理计数 | 并发 / 在途 token / 在途金额 / 聊天频率 | Cache | Increment 与 IncrementBy,无 Redis 时回落内存并自动回收 |
| 第三方登录 state | oauth:state:*(OAuthStateCache 封装) | Cache | 一次性消费,多节点必须共享 |
六、多实例部署要点
必须启用 Redis(open: 1),否则以下能力只在单节点生效:
| 能力 | 无 Redis 的后果 |
|---|---|
| 单设备登录 | 同一账号可在不同节点同时在线 |
| 令牌状态复核(禁用/停服) | 各节点各自缓存 60 秒,最长延迟一分钟才生效(可接受) |
| 登录 / 2FA 失败计数 | 各节点独立累计,锁定阈值被放大 N 倍 |
| 告警节流 | 每个节点各发一次提醒 |
| 字典缓存失效 | 写操作只清当前节点的版本号,其他节点继续读旧字典 |
| 第三方登录 state | A 节点发起、B 节点回调 → 报"授权已失效" |
| AI 并发/额度控制 | 每个节点各自计算,实际并发可达到配额 × 节点数 |
其它要点:
RedisServer:Cache与Session建议用不同defaultDatabase或不同前缀(配置里已分别是cache:/session:),排查时不会混;- Redis 不可用时(
open=0或未初始化)不阻断启动,自动回退内存,但多实例语义降级要心里有数; - 若启动后 Redis 中途不可用,相关读写会抛错(不会自动降级到内存),这类调用点需按业务兜底:例如 2FA 失败计数失败时仍应拒绝登录,而不是放行;
- 权限、数据权限、扫码登录、短信码仍走
CacheHelper(本地内存),不随open切到 Redis:多节点下权限变更只清当前节点缓存,其他节点用户需重新登录(或等缓存过期)才拿到新权限。
七、与 SqlSugar 二级缓存的关系
RedisServer:dbCache 是另一套东西:控制 SqlSugar 的 DataInfoCacheService 用 SqlSugarCache(内存)还是 SqlSugarRedisCache(Redis),作用于 ORM 查询缓存,与 CacheStore 无关。
- 单实例:
dbCache=false足够; - 多实例且用到了二级缓存:置
dbCache=true,否则各节点缓存互不可见,可能出现"更新了但读到旧数据"。
详见数据库操作。
八、验证清单
- 确认真的走了 Redis:
redis-cli --scan --pattern 'cache:*' | head,登录一次后应能看到CACHE-*键;session:前缀下应出现CACHE-USER-SESSION_*; - 单设备登录:开
SingleLogin: true,两个浏览器登录同一账号 → 旧会话请求返回480被踢; - 多节点验证:两个实例 + 同一 Redis,在 A 登录后到 B 请求,会话与锁定状态应一致;
- 回退验证:把
open改回0重启,重复第 2 步,行为退化为单节点内生效; - 字典失效:改一条字典数据 →
dict:cacheVersion自增,旧dict:v{n}:*不再被读取。
排查顺序
读到旧数据 → 先确认 open 是否为 1、两个连接串是否都配了;再确认写操作后有没有清缓存/推进版本号。多实例问题优先怀疑"只有部分节点连了 Redis"。
