主题
Redis 客户端封装与声明式缓存
一句话:把 key 命名、序列化、TTL、空值缓存、锁释放收进一套客户端封装,再用声明式注解和 AOP 把「读缓存—回源—回写」变成默认动作;它换来团队用法统一、故障面收敛和可观测,代价是网络调用与失败语义被藏进注解,排障必须依赖封装层自己的打点。
适用版本:Redis 7.x / 8.x;Spring Boot 2.7+ / 3.x(AOP 与 SpEL 行为以 Spring Framework 5.3+/6.x 为准)。本文的 @Cacheable 是自研注解的示例命名,与 Spring Cache 的同名注解不是同一个东西;@RedisLock 同为示例命名。
分工说明:本文只讲「团队如何把 Redis 用法封装成 SDK 和注解」。穿透、击穿、雪崩的成因与处置见 09-缓存穿透击穿雪崩,锁的协议演进、RedLock 与 fencing 见 12-分布式锁;这里只讨论这些能力在封装层怎么落地、哪些边界会被注解掩盖。
为什么需要
同一个 Redis,不同团队能写出完全不同的故障面:key 有的带环境前缀有的不带,序列化有的用 JDK 有的用 JSON,TTL 有的设一小时有的永不过期,空值有人缓存有人不缓存,解锁有人比对 token 有人直接 DEL。单看每个方法都能跑,放到一起就出现三类问题:key 冲突或无法按环境隔离;类升级后旧值反序列化失败;锁误删别人的,或者业务没跑完锁先过期。
更麻烦的是这些差异不可审计。线上命中率下降时,你不知道是哪个业务、哪个方法、哪个 key 前缀造成的;要统一改序列化格式或加 TTL 兜底,只能全仓搜索 redis.set。封装的目的不是「少写几行」,而是把重复出现的正确性约束变成默认值,让例外必须显式声明。
核心机制
分层:客户端、服务、切面
| 层 | 职责 | 不该做的事 |
|---|---|---|
| 客户端 | 连接池、命令封装、序列化、超时 | 业务语义、key 拼接规则 |
| 服务层 | 缓存读写、空值哨兵、锁、计数器 | 绕过客户端直接拿连接 |
| 切面层 | 解析注解、生成 key、编排命中/回源/加解锁 | 吞掉业务异常、隐式重试 |
分层的价值是让「缓存策略」和「Redis 访问」解耦:换序列化格式只动客户端,调整 TTL 策略只动服务层,业务方法只留一个注解。代价是多一层间接,出问题时必须能顺着调用链定位到具体一层。
注解只是入口,真正的动作都发生在代理里:先由 KeyResolver 把方法参数变成 key,读路径走「查缓存—未命中执行方法—回写」,锁路径走「抢锁—执行—比对 token 释放」。图里所有指向 Redis 的边都是网络调用,命中时省掉的是回源,不是 RTT。
声明式缓存:key、TTL、空值与更新
key 用 SpEL 还是显式拼接。 SpEL 灵活,能写 #user.id、#ids[0],但依赖编译时的 -parameters(否则只能用 #p0/#a0,开启方式以构建插件文档为准),解析有成本,表达式写错往往到运行期才发现。显式拼接啰嗦,但 key 在代码里可见、可 grep、可单测。折中是:前缀由 SDK 统一生成,业务只提供后缀,例如 key = "#userId",SDK 拼成 {env}:{app}:{cacheName}:{suffix};需要复杂结构时再开放 SpEL,并在启动时预解析一次表达式。
TTL 与随机抖动。 TTL 应该必填,而不是默认永不过期;没有 TTL 的 key 需要白名单和过期时间。批量预热或批量写入时,给基础 TTL 加 5%–10% 的随机偏移,避免同一秒集中失效。抖动只解决「同时过期」,不解决「Redis 整体不可用」,后者要靠高可用和降级。
空值缓存防穿透。 不能直接把 null 写进 Redis:读的时候 null 既表示「没命中」也表示「命中了一个空值」,无法区分。常用做法是写一个哨兵值(如 __NULL__)或带标记的信封,TTL 设短(示例 60–300 秒),写操作时主动删除。哨兵要计入容量,别让大量不存在的 ID 把内存吃满。
序列化格式与版本兼容。 值外面加一层信封:魔数 + 版本号 + 序列化类型 + 负载。读取时先看版本,再决定用哪个反序列化器。这样类升级、换 JSON 库、从 JSON 迁到 Protobuf 时可以双读,而不是一次性全量刷新。信封的代价是每条约十几个字节,换来的是回滚和灰度能力。
更新/删除策略。 主流是 Cache-Aside:读时回填,写时先写库、提交后再删缓存。先删缓存再写库并不更安全:删完到提交之间,另一个读请求会把旧值重新回填,脏数据要等 TTL 才消失;先写库再删缓存同样有「读旧值回填」的窗口,只是更短。两种顺序都不能做到强一致,所以关键业务要加延迟双删或订阅 binlog 补偿,最终仍以 TTL 兜底。删除动作要放在事务提交之后:在事务内删缓存,提交前就可能被其他请求回填旧值。
AOP 的边界:注解什么时候不生效
- 自调用不经过代理:
this.getUser()调用同一个类里的注解方法,走的是原始对象,切面不会执行。改成注入自身代理、AopContext.currentProxy()(需exposeProxy=true),或把方法拆到另一个 Bean。 - private / final / static 方法切不到:Spring AOP 基于代理,private 方法不可见,final 方法不能被 CGLIB 重写,static 方法不属于实例。注解放在这些方法上不会报错,只会静默失效。
- 与事务的顺序不确定:缓存切面和事务切面默认都可能是
LOWEST_PRECEDENCE,谁在外层取决于注册顺序。读缓存通常放在事务外没问题;删除缓存必须确保在提交后执行,显式设@Order或注册AFTER_COMMIT回调,别依赖默认顺序。 - 异常时是否写缓存:方法抛异常时默认不写,这是对的;不要把异常包装成
null再缓存,否则一次故障会被固化成几分钟的「正常空值」。缓存读失败时可以选择 fail-open 回源,但要有并发上限和打点,否则 Redis 抖动会直接放大成数据库雪崩。
声明式锁:注解只负责编排,语义仍要自己选
@RedisLock(key, waitTime, leaseTime) 的切面做四件事:解析 key、SET key <token> NX PX leaseTime 抢锁、执行方法、finally 里用 Lua 比对 token 再删。加锁必须是一条命令,SETNX 再 EXPIRE 中间失败会留下永不过期的锁;释放必须先比 value,否则业务超时后可能删掉别人的锁。
续期(watchdog)不是免费的:它要多一套调度任务和周期性网络调用,还要处理「续期时发现锁已不属于自己」的竞态。更关键的是,GC 停顿或容器被挂起超过租期时,watchdog 也救不了锁,另一个客户端已经拿到锁。所以租期优先按业务 P99 的 2–3 倍设置;只有执行时间确实不可预估时才开续期,并接受它带来的复杂度。
可重入要额外设计:非重入实现最简单,同一线程重复加锁会失败或死锁;可重入通常用 Hash 存「持有者 + 计数」,解锁时减计数到零才删 key,同时续期也要按计数协调。跨服务调用不要假设可重入:重入语义只在同一个客户端、同一个线程内成立,换一个实例或线程就是另一把锁。
序列化:兼容性比性能更先决定
| 格式 | 体积 | 可读性 | 兼容性要点 |
|---|---|---|---|
| JDK 序列化 | 大 | 差 | 依赖 serialVersionUID 和类结构;反序列化不可信数据、类路径上又有 gadget 时可执行任意代码 |
| JSON | 中 | 好 | 字段改名/类型变化要容忍;大整数、日期格式要统一 |
| Protobuf | 小 | 差(需 schema) | 字段编号不能复用,删除字段要 reserved;未知字段可保留 |
类升级后旧值怎么办:优先让新代码能读旧格式(新增字段给默认值、枚举加 UNKNOWN、避免改字段类型),再用信封版本做双读;不能兼容时换 key 前缀或 namespace,让旧值自然过期,而不是在线上批量反序列化再回写。永远不要把不可信字节反序列化成任意类:JDK 的 readObject 和 Jackson 的 default typing 都是历史漏洞入口,封装层应固定目标类型或使用 allowlist。
Key 规范:先能看懂,再谈省空间
推荐 {env}:{app}:{tenant}:{biz}:{id},全小写、冒号分隔、不含空格。环境和租户必须进前缀,否则测试流量会写进生产、租户之间会串数据。长度要控制:超长的 URL、邮箱可以做哈希截断,但保留可读的业务段。可读性和唯一性冲突时,优先让排障的人能一眼看出这是哪个业务的 key。
集群模式下,只有确实需要多 key 原子操作时才用 hash tag {...} 把 key 固定到同一 slot;滥用会让所有相关 key 挤到一个节点,制造热点。批量删除用 SCAN MATCH prefix:* COUNT 500 配合 UNLINK(4.0+)分批删,不要用 KEYS:KEYS 是 O(N) 且会阻塞单线程,生产上等同于一次小故障。
可观测:按注解维度打点
至少四类指标:缓存命中/未命中(按 cacheName、方法维度)、回源耗时、序列化失败数、锁等待时长与超时数。命中率要能和 Redis 侧的 keyspace_hits/misses 对上,否则说明 key 前缀漂移或客户端连错了库。命令耗时看 SLOWLOG GET 10 和 INFO commandstats;连接池耗尽看获取连接等待时间,而不是只看 Redis 延迟。日志里只打 key 的模板和参数摘要,不要打完整 key(基数和隐私);排障时先看「注解有没有生效」——如果连一条 Redis 命令都没有,问题在代理或自调用,不在 Redis。
最小上手
下面是一个最小注解和切面骨架(Java 17 / Spring Boot 3.x,省略参数校验、异常处理和降级)。
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Cacheable {
String key(); // 示例命名,支持 SpEL
long ttlSeconds() default 600; // 示例默认值,非通用配置
boolean cacheNull() default true;
}
@Aspect @Component
public class CacheableAspect {
private final CacheService cache; // SDK 内置:get/set + 空值哨兵
private final SpelResolver spel;
@Around("@annotation(c)")
public Object around(ProceedingJoinPoint p, Cacheable c) throws Throwable {
String key = spel.resolve(c.key(), p); // 省略空 key 校验
CacheValue hit = cache.get(key); // 省略读失败降级
if (hit != null) return hit.value(); // 哨兵在这里还原为 null
Object value = p.proceed(); // 省略异常处理
cache.set(key, value, c.ttlSeconds(), c.cacheNull());
return value;
}
}验证方式:用一个正常存在的 ID 调用后,日志应为 cache=product hit=false,随后 redis-cli TTL "dev:order:product:42" 返回接近 600 且逐秒递减;第二次调用日志变成 hit=true,方法内的回源日志不再出现。若要验证空值缓存,用一个不存在的 ID 调用两次,第二次应命中,且 SDK 对空值单独设的短 TTL(示例 120 秒)生效,而不是返回 -2。
生产动作
- TTL 必填:
ttlSeconds无默认值或默认较短;永不过期必须走白名单并写原因。抖动比例示例ttl * 10%,按业务容忍度调。 - 空值 TTL 60–300 秒(示例):写操作后主动删哨兵;监控哨兵占比,超过阈值说明参数校验或布隆过滤器该上了。
- 锁租期 ≥ 业务 P99 × 2–3;
waitTime示例 0–3 秒,等待要有上限和退避,不能无限自旋。开启 watchdog 时明确它不防 GC 停顿。 - 降级开关:缓存读失败默认 fail-open 回源,但必须有并发上限和「Redis 错误率」熔断;锁获取失败默认 fail-closed 并抛明确异常,关键写操作仍要数据库唯一约束或幂等键兜底。
- 封装库升级做兼容性验证:新旧客户端混跑,旧值能否被新版本读取、回滚后新值能否被旧版本读取;序列化信封版本不兼容时走双读或换 namespace。
- 排障路径:命中率下降 → 对比 SDK 的 hit 指标与
INFO stats→ 两边都降查 key 前缀/淘汰,只有 Redis 降查客户端版本或序列化失败;注解不生效 → 看是否有 Redis 命令日志 → 没有则查自调用、private/final、代理类型;锁超时 → 看lock_wait指标与PTTL→ 等待接近waitTime说明热点集中,租期短于 P99 说明续期没生效;反序列化失败 → 看错误指标和样本 → 取一个 key 看信封版本 → 判断是类升级还是格式混用;慢查询 →SLOWLOG GET 10、LATENCY DOCTOR→ 确认是否封装层引入了KEYS、大 value 或批量命令。 - 危险操作:批量删除用
SCAN+UNLINK,禁止KEYS *;禁止无 token 的DEL解锁;FLUSHDB只在隔离环境使用。
边界
- 注解隐藏了网络调用:一个看起来像本地 getter 的方法,可能包含一次 RTT、一次连接池等待和一次反序列化。超时预算要按最坏路径算,不能按命中路径算。
- 缓存做不到强一致:先写库再删缓存仍有回填窗口,延迟双删也只是缩小窗口;金额、库存这类字段要么不用缓存,要么接受 TTL 内的旧值并做业务校验。
- 锁不能替代数据库约束:锁只降低并发概率,主从切换、GC 停顿、网络重试都可能让两个请求同时进入临界区;唯一索引、版本号、幂等键才是最后一道防线。
- 封装过度会失去透明度:注解越多、默认值越隐蔽,排障时越难回答「到底用了哪个 key、TTL 多少、失败走了哪条分支」。必须保留原始客户端逃生口,并在日志里打印解析后的 key 模板。
- AOP 只适合方法级横切:多 key 原子操作、复杂条件更新、跨方法的事务编排,应该写明确的 Lua 或服务层代码,不要硬塞进注解。一次性脚本、极低 QPS 且数据简单的读路径,引入缓存和注解只会增加理解成本。
讲清楚
客户端封装解决的是同一个 Redis 被每个团队用出不同故障面的问题:把 key 前缀、序列化、TTL、空值哨兵、锁的 token 释放收进 SDK,再用注解和 AOP 把读缓存、回源、回写、加解锁变成默认动作。核心取舍是用统一语义换掉重复代码,同时把网络调用藏进注解——命中时像本地调用,未命中和 Redis 抖动时仍要付超时,所以必须有降级、打点和逃生口。强一致、多 key 原子操作、复杂事务不该指望注解;锁也替代不了数据库唯一约束。自调用不经过代理、private/final 方法切不到,是这套方案最常见的失效点。