# bing-cache
**Repository Path**: bingyulei007/bing-cache
## Basic Information
- **Project Name**: bing-cache
- **Description**: 基于 Spring AOP 的方法级缓存组件,通过注解实现透明的数据缓存,支持 L1 本地缓存(Caffeine)和 L2 分布式缓存(Redis)两级架构。
- **Primary Language**: Unknown
- **License**: Apache-2.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 8
- **Forks**: 1
- **Created**: 2026-06-17
- **Last Updated**: 2026-07-02
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# Bing Cache
基于 Spring AOP 的方法级缓存组件,通过注解实现透明的数据缓存,支持 L1 本地缓存(Caffeine)和 L2 分布式缓存(Redis)两级架构。
## 特性
- **注解驱动**:`@BingCache` 缓存读取、`@BingCacheEvict` 缓存清除(支持 `@Repeatable` 多缓存协同失效),零侵入业务代码
- **两级缓存**:L1(Caffeine) + L2(Redis) 组合,L1 未命中自动回填并携带 L2 剩余 TTL
- **跨实例失效**:基于 Redis Pub/Sub 广播缓存失效消息,多实例部署时 L1 缓存自动同步
- **版本对账**:定时检查 Redis 版本号变化,补偿 Pub/Sub 消息丢失,确保最终一致性
- **自动降级**:Redis 不可用时自动回退纯 L1 本地缓存模式,恢复后按对账配置处理 L1 脏数据
- **L1 存活限制**:`l1-max-ttl` 限制 L1 条目最大存活时间,作为 Pub/Sub 丢失的兜底保障
- **null 值防穿透**:`cacheNullValue` 属性支持缓存 null 结果,防止缓存穿透
- **SpEL Key 表达式**:`argSpel` 属性支持 SpEL 表达式从参数中选取值生成 key(如 `#user.id`),支持类似 Spring `@Cacheable` 的参数变量
- **确定性 Key**:基于 Jackson 序列化生成 key,不依赖 `toString()`,重启后保持一致
- **自动装配**:Spring Boot Starter 一键引入,根据 classpath 和配置自动选择缓存模式
## 快速开始
### 1. 引入依赖
在项目的 `pom.xml` 中添加:
```xml
cn.com.bingbing
bing-cache
1.1-SNAPSHOT
```
组件通过 `AutoConfiguration.imports` 自动装配,无需手动配置。
### 2. 使用缓存注解
```java
@Service
public class DictService {
// 缓存查询结果,1 小时过期
@BingCache(cacheName = "dict", expireTime = 3600)
public List getDictList(String dictType) {
return dictMapper.selectByType(dictType);
}
// 使用 SpEL 表达式从对象中取字段作为 key
@BingCache(cacheName = "user", argSpel = "#user.id")
public UserVO getUser(UserVO user) {
return userMapper.selectById(user.getId());
}
// 更新数据后清除缓存
@BingCacheEvict(cacheName = "dict", argIndexes = {0})
public void updateDict(String dictType, DictVO vo) {
dictMapper.update(vo);
}
}
```
## 注解详解
### @BingCache — 缓存读取
标注在查询方法上,方法首次执行后缓存结果,后续调用直接返回缓存值。
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `group` | String | `""` | 缓存分组,用于将多个 cacheName 归类到同一命名空间,支持按 group 批量清除(`@BingCacheEvict(group=..., allEntries=true)`)。设置后 key 格式为 `group:cacheName(args)` |
| `cacheName` | String | `""` | 缓存名称,用于与 `@BingCacheEvict` 共享同一前缀,优先级最高 |
| `keyPrefix` | String | `""` | 缓存 key 前缀,为空时使用"类全限定名.方法名(参数类型签名)";`cacheName` 不为空时忽略 |
| `expireTime` | int | `0` | 过期时间(秒),`0` 表示不过期 |
| `argIndexes` | int[] | `{}` | 参与 key 生成的参数索引,空数组表示全部参数参与;`argSpel` 非空时忽略 |
| `argSpel` | String | `""` | SpEL 表达式,从参数中选取值参与 key 生成(如 `#user.id`);非空时优先于 `argIndexes` |
| `cacheNullValue` | boolean | `false` | 是否缓存 null 结果,设为 `true` 可防止缓存穿透 |
#### cacheName 与 keyPrefix 怎么选?
两者功能上都能自定义缓存 key 前缀,区别在于**语义和使用场景**:
| | cacheName | keyPrefix |
|---|---|---|
| **语义** | "我的缓存叫什么名字" | "我的 key 前缀长什么样" |
| **适用场景** | 需要 `@BingCacheEvict` 配对清除的缓存 | 只需自定义前缀、不需要配对清除的缓存 |
| **配对清除** | `@BingCacheEvict(cacheName = "user")` 天然配对 | 也能配对,但语义不明确 |
| **优先级** | 高(cacheName 不为空时 keyPrefix 被忽略) | 低 |
**简单原则:**
- **需要缓存清除**(读 + 写/删配对)→ 用 `cacheName`
- **只需要缓存、不需要清除** → 用 `keyPrefix` 缩短前缀,或不设置用默认前缀
> 注意:`cacheName` 和 `keyPrefix` 同时设置时,只有 `cacheName` 生效。
#### cacheName 命名约束
**不推荐 `cacheName` 含冒号(`:`)**,建议使用单词或驼峰命名(如 `userDetail`、`userList`)。
原因:`@BingCache` / `@BingCacheEvict` 的 `group` 分组属性使用冒号作为 group 与 cacheName 的层级分隔符,缓存 key 格式为 `group:cacheName(args)`。若 `cacheName` 本身含冒号(如 `cacheName = "user:detail"`),其 key 前缀会与 `group = "user"` + `cacheName = "detail"` 产生的前缀完全相同。此时执行 `@BingCacheEvict(group = "user", allEntries = true)` 触发的 `clearByGroup("user")` 会按 `user:` 前缀匹配清除,**误清那些并未声明属于 `user` 组、只是 cacheName 恰好含冒号的缓存**。
`keyPrefix` 含冒号存在同样的碰撞风险,使用 group 时同样应避免。
若需要"分组"语义,使用 `group` 属性而非在 `cacheName` 中拼接冒号。
#### argSpel SpEL 表达式
`argSpel` 接受 SpEL 表达式,从方法参数中选取值参与 key 生成。表达式中可用的变量(类似 Spring `@Cacheable` 的参数变量):
| 变量 | 说明 | 示例 |
|------|------|------|
| `#参数名` | 按名称引用方法参数 | `#id`、`#user.id` |
| `#p0` / `#a0` | 按索引引用方法参数(从 0 开始) | `#p0` |
| `#root.method` | 当前方法(`Method` 对象) | `#root.method.name` |
| `#root.methodName` | 方法名 | `#root.methodName` |
| `#root.args` | 参数数组 | `#root.args[0]` |
| `#root.target` | 目标对象 | `#root.target.getClass()` |
> 注意:不支持 `#root.targetClass`、`#caches` 等 Spring Cache 特有变量。
**单值与多值**:
- **单值**:普通表达式、拼接表达式或单元素花括号表达式,输出 `Sg[...]`。
- `#user.id` → `Sg[N:1]`
- `#userId + ':' + #type` → `Sg[S:1:normal]`
- `{#list}` → 去壳为 `#list`,等价于单值 → `Sg[[N:1, N:2]]`
- **多值**:使用 SpEL 列表字面量 `{expr1, expr2, ...}`,顶层逗号分隔,嵌套 `()`/`[]`/`{}` 和字符串里的逗号被忽略,输出 `[...]`。
- `{#a, #b}` → `[N:1, N:2]`
- `{new int[]{#a, #b}, #c}` → `[[N:1, N:2], N:3]`(两个顶层参数,数组内逗号不影响切分)
SpEL 表达式求值结果通过 Jackson 序列化为字符串(非基本类型时),null 结果序列化为 `"null"`。
最终 key 格式为 `前缀(spelResult)`,前缀仍由 `cacheName` / `keyPrefix` 决定。
#### null 值处理
**默认行为(不缓存 null)**:`cacheNullValue = false`,方法返回 null 时不缓存,每次调用都会重新执行方法。
**缓存 null(防缓存穿透)**:设置 `cacheNullValue = true` 可以缓存 null 结果,防止大量请求查询不存在的数据时穿透到数据库。
> 内部使用 `BingCacheNullValue.INSTANCE` 占位符存储,因为 Caffeine 不支持缓存 null 值。读取时自动还原为 null 返回给调用方。NullValue 只存 L1 不存 L2(Jackson 无法反序列化包私有类)。
>
> **⚠️ 跨实例限制**:由于 NullValue 不写入 L2(Redis),`cacheNullValue = true` 只能在**本实例**缓存 null 结果防穿透。多实例部署下,**每个实例对同一个不存在的 id 会各自回源一次**,之后该实例即命中本地 L1,不会持续穿透(除非 L1 条目过期或被 LRU 驱逐后重新回源一次)。也就是说 N 个实例对同一个不存在的 id 总共回源 N 次(而非 1 次),之后各实例稳定命中 L1。
>
> 如果希望跨实例共享 null 缓存(每个 id 全集群只回源一次),建议:
> - 在业务层用布隆过滤器拦截不存在的 id
> - 或显式缓存一个"空对象"占位符(如空 `UserVO`,public 类可被 Jackson 序列化写入 L2),而非依赖 null 缓存
>
> 注意:上述限制针对的是**正常业务场景**(少量不存在的 id 被反复查询)。若面临**恶意穿透攻击**(海量不同 id 各查一次),L1 的 `max-size` 容量限制会导致 NullValue 来不及生效,此时必须用布隆过滤器在入口拦截,无论单实例还是多实例、是否写 L2 都无法仅靠缓存解决。
#### 使用示例
```java
// ========== cacheName 场景:需要配对清除 ==========
// 查询 — 缓存结果
@BingCache(cacheName = "user", expireTime = 300)
public UserVO getUserById(Long id) { ... }
// key: user(Sg[N:1])
// 更新 — 清除对应缓存(cacheName 相同且参数部分一致即可匹配)
@BingCacheEvict(cacheName = "user", argIndexes = {0})
public void updateUser(Long id, UserVO vo) { ... }
// evict key: user(Sg[N:1]) ✓ 匹配
// ========== keyPrefix 场景:只缓存不清除 ==========
// 默认前缀太长(com.example.DictService.getDictList),缩短一下
@BingCache(keyPrefix = "dict", expireTime = 3600)
public List getDictList(String dictType) { ... }
// key: dict(Sg[S:sys_config])
// 不过期的静态数据,只需缓存,不需要清除
@BingCache(keyPrefix = "configSys")
public SystemConfigVO getSystemConfig() { ... }
// ========== 其他用法 ==========
// 基础用法 — 不设置前缀,key 前缀为类名.方法名
@BingCache(expireTime = 3600)
public List getDictList(String dictType) { ... }
// 缓存 null 结果,防止缓存穿透
@BingCache(cacheName = "user", expireTime = 60, cacheNullValue = true)
public UserVO getUserById(Long id) { ... }
// ========== argSpel 场景:SpEL 表达式选取参数 ==========
// 从对象中取字段作为 key(只用 id,不用整个对象)
@BingCache(cacheName = "user", argSpel = "#user.id", expireTime = 300)
public UserVO getUser(UserVO user) { ... }
// key: user(Sg[N:1])
// 多参数拼接
@BingCache(cacheName = "order", argSpel = "#userId + ':' + #type")
public Order getOrder(Long userId, String type) { ... }
// key: order(Sg[S:1:normal])
// 按索引引用参数(#p0 = 第一个参数,#a0 同义)
@BingCache(cacheName = "user", argSpel = "#p0")
public UserVO getUserById(Long id) { ... }
// key: user(Sg[N:1])
// 访问对象方法
@BingCache(cacheName = "user", argSpel = "#user.getName().toLowerCase()")
public UserVO getUser(UserVO user) { ... }
// key: user(Sg[S:alice])
// 多值选取:方法前两个参数都参与 key 生成
@BingCache(cacheName = "order", argSpel = "{#userId, #type}")
public Order getOrder(Long userId, String type) { ... }
// key: order([N:1, S:normal])
```
### @BingCacheEvict — 缓存清除
标注在更新/删除方法上,方法执行后(或执行前)自动清除对应的缓存条目。支持在同一方法上重复使用(`@Repeatable`),用于一个写操作需要清除多个缓存的场景。
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `group` | String | `""` | 缓存分组,需与 `@BingCache` 的 `group` 一致才能匹配。`allEntries=true` 且仅设置 `group`(无 cacheName/keyPrefix)时,清除整个 group 下的缓存 |
| `cacheName` | String | `""` | 缓存名称,需与 `@BingCache` 的 `cacheName` 一致才能匹配 |
| `keyPrefix` | String | `""` | 缓存 key 前缀,同 `@BingCache`;`cacheName` 不为空时忽略 |
| `argIndexes` | int[] | `{}` | 参与 key 生成的参数索引,需与 `@BingCache` 的 `argIndexes` 对应;`argSpel` 非空时忽略 |
| `argSpel` | String | `""` | SpEL 表达式,需与 `@BingCache` 的 `argSpel` 一致才能匹配;非空时优先于 `argIndexes`;`allEntries=true` 时不生效 |
| `allEntries` | boolean | `false` | `true` 时清除所有缓存:仅 `group` 时清整个 group;有 `cacheName`/`keyPrefix` 时清该前缀;都没有时清空全部 |
| `beforeInvocation` | boolean | `false` | `true` 时在方法执行前清除缓存;默认方法成功后才清除 |
> **cacheName 与 keyPrefix 选择原则同 `@BingCache`**:推荐用 `cacheName` 配对,语义更明确。`cacheName` 不为空时 `keyPrefix` 被忽略。
#### 使用示例
下面所有清除方法都配对同一个查询方法,其 key 为基准:
```java
// 查询 — 基准 key: userDetail(Sg[N:1])
@BingCache(cacheName = "userDetail", expireTime = 300)
public UserVO getUserById(Long id) { ... }
```
各 `@BingCacheEvict` 用法及生成的 key(注意 key 必须与查询方法匹配,否则清不到):
```java
// 单参数:默认用全部参数生成 key → userDetail(Sg[N:1]) ✓ 匹配
@BingCacheEvict(cacheName = "userDetail")
public void deleteById(Long id) { ... }
// 多参数:用 argIndexes 只取 id 生成 key → userDetail(Sg[N:1]) ✓ 匹配
// (不加 argIndexes 时两参数都会参与,key 变成 [N:1, {...}] 不匹配)
@BingCacheEvict(cacheName = "userDetail", argIndexes = {0})
public void updateUser(Long id, UserVO vo) { ... }
// 使用 argSpel 取 id 生成 key → userDetail(Sg[N:1]) ✓ 匹配
// (表达式需与 @BingCache 的 argSpel 完全一致)
@BingCacheEvict(cacheName = "userDetail", argSpel = "#vo.id")
public void deleteUser(UserVO vo) { ... }
// 方法执行前清除缓存(即使方法抛异常,缓存也会被清除)
@BingCacheEvict(cacheName = "userDetail", argIndexes = {0}, beforeInvocation = true)
public void forceUpdateUser(Long id, UserVO vo) { ... }
// 清空指定 cacheName 下的所有缓存(忽略参数,清整个 userDetail 前缀)
@BingCacheEvict(cacheName = "userDetail", allEntries = true)
public void refreshAllUsers() { ... }
// 清空全部缓存(不指定 cacheName/keyPrefix)
@BingCacheEvict(allEntries = true)
public void clearAllCache() { ... }
```
> **关键**:`argIndexes` / `argSpel` 决定 evict 的 key 是否与查询 key 匹配,与 `@BingCache` 必须对应。`allEntries=true` 时不按参数清,而是清整个前缀,参数配置被忽略。
### 配对使用
`cacheName` 是两个注解之间的桥梁,用来让读写注解共享同一个缓存前缀。
**⚠️ 重要:参数部分也必须一致。** `cacheName` 相同只保证前缀一致,参数部分(`argIndexes` 或 `argSpel`)也必须对应,否则生成的 key 不匹配,evict 清不到缓存;不会自动降级为按 `cacheName` 批量清除。
```java
@Service
public class UserService {
// 查询 — 缓存结果,key: user(Sg[N:1])
@BingCache(cacheName = "user", expireTime = 300)
public UserVO getUserById(Long id) {
return userMapper.selectById(id);
}
// 更新 — 清除缓存,argIndexes={0} 只用 id 生成 key → user(Sg[N:1]) ✓ 匹配
@BingCacheEvict(cacheName = "user", argIndexes = {0})
public void updateUser(Long id, UserVO vo) {
userMapper.updateById(vo);
}
// 删除 — 只有一个参数,不需要 argIndexes,key 自然匹配 → user(Sg[N:1]) ✓
@BingCacheEvict(cacheName = "user")
public void deleteUser(Long id) {
userMapper.deleteById(id);
}
// 批量刷新 — 只清空 user 缓存,不影响其他 cacheName 的缓存
@BingCacheEvict(cacheName = "user", allEntries = true)
public void refreshAllUsers() {
// 批量操作后,所有 user 前缀的缓存统一失效,dict 等其他缓存不受影响
}
}
```
> **注意**:查询方法如果使用了 `argIndexes` 或 `argSpel`,清除方法必须设置对应的值。
> 例如查询方法 `@BingCache(cacheName = "user", argSpel = "#user.id")`,清除方法也应为 `@BingCacheEvict(cacheName = "user", argSpel = "#user.id")`。
#### 多缓存协同失效
当一个写操作影响多个缓存时,有两种方式协同清除:
**方式一:使用 `group` 分组(推荐)**
将相关缓存归入同一 group,写操作用 `@BingCacheEvict(group=..., allEntries=true)` 按命名空间批量清除,无需逐个精确声明 cacheName 与参数:
```java
@Service
public class UserService {
// 用户详情 — 缓存到 user 组的 detail
@BingCache(group = "user", cacheName = "detail", expireTime = 300)
public UserVO getUserDetail(Long id) { ... }
// key: user:detail(Sg[N:1])
// 用户列表 — 缓存到 user 组的 list
@BingCache(group = "user", cacheName = "list", argIndexes = {0, 1}, expireTime = 120)
public List queryUsers(String category, int page) { ... }
// key: user:list([S:admin, N:1])
// 用户统计 — 缓存到 user 组的 stats
@BingCache(group = "user", cacheName = "stats", expireTime = 600)
public UserStatsVO getUserStats() { ... }
// key: user:stats()
// 用户订单 — 缓存到 user 组的 orders
@BingCache(group = "user", cacheName = "orders", expireTime = 120)
public List getUserOrders(Long userId) { ... }
// key: user:orders(Sg[N:1])
// 更新用户 — 一个注解清除 user 组下所有缓存(detail/list/stats/orders 全清)
@BingCacheEvict(group = "user", allEntries = true)
public void updateUser(Long id, UserVO vo) { ... }
// 清除范围:user:* 所有 key
// 新增订单 — 只清 user 组的 orders 缓存(不影响 detail/list/stats)
@BingCacheEvict(group = "user", cacheName = "orders", allEntries = true)
public void createOrder(Long userId, String orderId) { ... }
// 清除范围:user:orders* 所有 key
// 刷新统计 — 只清 user 组的 stats 缓存
@BingCacheEvict(group = "user", cacheName = "stats", allEntries = true)
public void refreshUserStats() { ... }
// 清除范围:user:stats* 所有 key
}
```
> **要点**:方式一全程用 `group + allEntries` 按"组 / cacheName"批量清除,不依赖 `argIndexes`/`argSpel` 精确匹配参数。这样即使查询方法的参数选取方式(`argIndexes`/`argSpel`)变化,清除注解也无需同步修改。若只需清单个 key(精确到某用户),见下面"方式二"的 `argIndexes` 用法。
**方式二:使用多个 `@BingCacheEvict`(不使用 group)**
未使用 group 时,需要逐个声明要清除的 cacheName:
```java
@Service
public class UserService {
// 用户详情 — 按 id 缓存
@BingCache(cacheName = "userDetail", expireTime = 300)
public UserVO getUserDetail(Long id) { ... }
// 用户列表 — 按 category + page 缓存
@BingCache(cacheName = "userList", argIndexes = {0, 1}, expireTime = 120)
public List queryUsers(String category, int page) { ... }
// 用户统计 — 独立缓存
@BingCache(cacheName = "userStats", expireTime = 600)
public UserStatsVO getUserStats() { ... }
// 更新用户 — 需要清除所有相关缓存
@BingCacheEvict(cacheName = "userDetail", argIndexes = {0}) // 清除该用户的详情
@BingCacheEvict(cacheName = "userList", allEntries = true) // 清除所有列表(无法确定哪些页包含该用户)
public void updateUser(Long id, UserVO vo) { ... }
// 新增用户 — 只需清除列表,详情是新 key 无需清除
@BingCacheEvict(cacheName = "userList", allEntries = true)
public void createUser(UserVO vo) { ... }
// 修改用户统计相关字段 — 只清除统计缓存
@BingCacheEvict(cacheName = "userStats", allEntries = true)
public void refreshUserStats() { ... }
}
```
> **设计原则**:`group` 适合"一个写操作需清除多个相关 cacheName"的场景,用一个注解替代 N 个 `@BingCacheEvict`;不使用 group 时,不同 cacheName 代表独立缓存空间,需显式声明要清除哪些。两种方式都遵循"既不遗漏也不误伤"的原则——`group` 通过命名空间隔离实现,多注解通过显式列举实现。
#### group 清除层级
`group` 提供三层清除粒度:
| 场景 | 注解 | 行为 |
|------|------|------|
| 清除单个缓存 | `@BingCacheEvict(group="user", cacheName="detail", argIndexes={0})` | 清除 `user:detail(Sg[N:1])` |
| 清除 cacheName 下所有缓存 | `@BingCacheEvict(group="user", cacheName="list", allEntries=true)` | 清除 `user:list(` 开头的所有 key |
| 清除整个 group | `@BingCacheEvict(group="user", allEntries=true)` | 清除 `user:` 开头的所有 key(1 次 SCAN + 1 次 Pub/Sub) |
| 清空全部缓存 | `@BingCacheEvict(allEntries=true)` | 清空全部缓存 |
> **`group` 单独使用限制**:`group` 不能单独用于非 `allEntries` 场景(即 `@BingCacheEvict(group="user")` 不带 `allEntries=true` 也不带 `cacheName`/`keyPrefix` 会抛 `IllegalStateException`),因为没有 `cacheName`/`keyPrefix` 无法生成合法的 key 前缀。
## 缓存 Key 生成规则
格式:`前缀(参数部分)`
### 前缀优先级
1. **`cacheName`**(最高)— 如 `user`
2. **`keyPrefix`** — 如 `userDetail`
3. **默认** — 类全限定名.方法名(参数类型签名),如 `com.example.UserService.getUserById(java.lang.Long)`
> **`group` 是可选的最外层前缀**:设置 `group` 时,key 格式为 `group:prefix(args)`(如 `user:detail(Sg[N:1])`)。`group` 不影响上述优先级,仅作为命名空间前缀拼接在最终 prefix 之前。
### 参数选取方式
优先级:`argSpel` > `argIndexes` > 全量参数
| 方式 | 说明 | 示例 |
|------|------|------|
| `argSpel` | SpEL 表达式,从参数中选取值 | `argSpel = "#user.id"` → `user(Sg[N:1])` |
| `argIndexes` | 按索引选取整个参数 | `argIndexes = {0, 2}` → `prefix([S:a, N:3])` |
| 全量参数(默认) | 所有参数序列化 | `prefix([S:a, N:2, N:3])` |
### 参数序列化
参数部分按"参与 key 生成的参数个数"决定外层形式:
- **单值选取(1 个参数)**:输出 `Sg[...]`,`Sg` 标识 single(单值)
- **多值选取(≥2 个参数)**:输出 `[...]`
- **无参数**:输出空(key 形如 `prefix()`)
这样单值集合参数与多参数不会碰撞:单参数 `List[1,2]` 输出 `Sg[N:1,N:2]`,两参数 `(1,2)` 输出 `[N:1,N:2]`。
| 参数类型 | 元素序列化 | 单值示例 | 多值示例 |
|----------|-----------|---------|---------|
| null | `null` | `user(Sg[null])` | — |
| 整数(Integer/Long/BigInteger 等) | `N:` + 值 | `user(Sg[N:42])` | `user([N:1, N:2])` |
| 字符串 | `S:` + 值 | `user(Sg[S:42])` | `user([S:a, S:b])` |
| Boolean | `B:` + 值 | `user(Sg[B:true])` | — |
| Character | `C:` + 值 | `user(Sg[C:x])` | — |
| 小数(Double/Float/BigDecimal 等) | `D:` + 值 | `user(Sg[D:1.5])` | — |
| 数组 / List | 递归序列化元素 | `user(Sg[[N:1, N:2, N:3]])` | — |
| 自定义对象 | Jackson JSON | `user(Sg[{"id":1,"name":"Alice"}])` | — |
> **数组与 List 在 key 中形式相同**:两者都输出 `[N:1, N:2, N:3]` 形式,业务语义等价。
> 例如 `Long[] {1,2,3}` 与 `List [1,2,3]` 会命中同一 key,无需区分。
>
> **三种参数选取方式产出一致**:
> - 单值场景:`argSpel="#id"`、`argIndexes={0}`、单参数默认,都输出 `prefix(Sg[N:1])`
> - 多值场景:`argSpel="{#a,#b}"`、`argIndexes={0,1}`、多参数默认,都输出 `prefix([N:1,N:2])`
>
> **argSpel 多值语法约定**:使用 SpEL 列表字面量 `{expr1, expr2, ...}` 表示多值选取,
> 顶层逗号才作为参数分隔,嵌套 `()`/`[]`/`{}` 和字符串字面量中的逗号被忽略,
> 如 `{#a, #b}`、`{new int[]{#a, #b}, #c}`。
> 单元素花括号(如 `{#list}`)会被去壳,按单值处理,输出 `Sg[...]`。
>
> 自定义对象使用 Jackson 序列化而非 `toString()`,确保不同实例和 JVM 重启后 key 一致。
> Jackson 序列化失败时直接抛 `IllegalStateException`(而非降级为 `hashCode()`)。
### Key 长度限制
生成的 key 最大长度为 **256 个字符**。超过时自动截断参数部分并追加截断哈希后缀(`...#` + SHA-256 前 16 位十六进制字符),保证截断后的 key 仍然唯一。
### 边界行为说明
| 场景 | 当前行为 |
|------|----------|
| `argSpel` 返回 null | 参数部分序列化为字符串 `"null"`,例如 `@BingCache(keyPrefix = "user", argSpel = "#id")` 且 `id == null` 时,key 为 `user(null)` |
| `argSpel` 非空且同时配置 `argIndexes` | `argSpel` 优先,`argIndexes` 被忽略,并输出一次 WARN 日志 |
| `argSpel` 求值失败或 key 参数 Jackson 序列化失败 | 直接抛出异常,不执行原方法,也不会写缓存 |
| 业务方法抛异常 | 异常直接向外抛出,不写缓存 |
| 业务方法返回 null 且 `cacheNullValue = false` | 不写缓存,后续调用仍会执行原方法 |
| 业务方法返回 null 且 `cacheNullValue = true` | 写入 L1 null 占位符,后续本实例命中后还原为 null 返回;NullValue 不写入 L2 Redis |
| L2 命中但 TTL 查询返回 `-2` 或 `0` | 跳过 L1 回填,避免创建已经过期或即将过期的本地脏数据 |
| 单 key `evict()` / `@BingCacheEvict(allEntries = false)` 或 `@BingCacheEvict(cacheName = "user", allEntries = false)` | 仅清除当前实例 L1 和 Redis L2 中的这个完整 key,并通过 Redis Pub/Sub 通知其他实例清除同一个 key;**即使配置了 `cacheName`,也不递增 cacheName/group/全局版本号,因此不会触发版本对账去清空同 cacheName 下的所有缓存**。若 Pub/Sub 丢失,只能依赖 `l1-max-ttl` 等待其他实例 L1 中该 key 过期 |
| `clear()` / `clearByPrefix()` / `clearByGroup()` / `@BingCacheEvict(allEntries = true)` | 清除当前实例缓存并发布 Pub/Sub;在二级缓存模式下递增版本号(`clear`→全局版本、`clearByPrefix`→cacheName 版本、`clearByGroup`→group 版本),可由版本对账补偿 Pub/Sub 丢失 |
## 缓存架构
### 两种缓存模式
#### L1 仅本地缓存(默认,无需 Redis)
```
请求 → @BingCache → L1(Caffeine) 命中?
├─ 是 → 返回缓存值
└─ 否 → 执行方法 → 写入 L1 → 返回结果
```
适用场景:单实例部署,或对缓存一致性要求不高的场景。
> **重要限制**:纯 L1 模式没有 Redis Pub/Sub,`evict()` / `@BingCacheEvict` 只能清除**当前 JVM 实例**的本地缓存,无法通知其他实例。多实例部署如果依赖缓存清除保持一致,必须启用 Redis 二级缓存模式。
#### L1 + L2 二级缓存(需要 Redis)
```
请求 → @BingCache → L1(Caffeine) 命中?
├─ 是 → 返回缓存值
└─ 否 → L2(Redis) 命中?
├─ 是 → 回填 L1(携带剩余 TTL) → 返回缓存值
└─ 否 → 执行方法 → 写入 L1 + L2 → 返回结果
```
适用场景:多实例部署,需要跨实例共享缓存和缓存一致性。
### L2 回填 L1 的 TTL 策略
L1 未命中但 L2 命中时,L2 的值会回填到 L1。回填时通过 Redis `TTL` 命令获取 L2 的剩余过期时间:
- **remainingTtl > 0**:使用剩余 TTL 回填 L1
- **remainingTtl == -1**:L2 永不过期,L1 也永不过期
- **remainingTtl == -2 或 0**:L2 中 key 已不存在或即将过期,**跳过回填**,避免在 L1 创建永不过期的脏数据
### 跨实例缓存失效(Redis Pub/Sub)
> **前提:必须启用 Redis 二级缓存模式。** Pub/Sub 是 Redis 提供的消息通道能力;没有 Redis 依赖、Redis 连接不可用,或 `bing.cache.redis.enabled=false` 时,组件会退化为纯 L1 模式,此时 `evict()` / `@BingCacheEvict` 只影响当前实例,不具备跨实例失效能力。
多实例部署时,任一实例执行 `@BingCacheEvict` 触发的失效操作会通过 Redis Pub/Sub 广播到其他实例:
```
实例 A: @BingCacheEvict → 清除 L2 + 清除 L1 → 发布 Pub/Sub 消息
实例 B: 收到 Pub/Sub 消息 → 清除本地 L1 缓存
实例 C: 收到 Pub/Sub 消息 → 清除本地 L1 缓存
```
- 消息包含 `instanceId`,各实例自动过滤自己发出的消息(自发自滤)
- Pub/Sub 是 fire-and-forget 模式,不保证消息送达;`RedisMessageListenerContainer` 会自动重连
- 频道名称默认 `bing-cache:invalidation`,可通过配置修改
### 版本对账机制
作为 Pub/Sub 消息丢失的补偿,组件提供版本对账机制:
1. **版本号存储**:Redis 中维护每个 cacheName / group 的版本号
- cacheName 版本:`bing-cache:__version__:{cacheName}`
- 全局版本:`bing-cache:__version__:__all__`
- group 版本:`bing-cache:__version__:__group__:{group}`
- `clear()` 递增全局版本号;`clearByPrefix(prefix)` 递增对应 cacheName 的版本号;`clearByGroup(group)` 递增对应 group 的版本号
- **单 key `evict(key)` 不递增版本号**(见下方"对账范围限制")
2. **定时对账**:`CacheReconciliationService` 每隔 `interval` 秒检查版本号变化
- 发现全局版本变化 → 清空所有 L1 缓存
- 发现 cacheName 版本变化 → 按前缀清空 L1 缓存
- 发现 group 版本变化 → 按 group 清空 L1 缓存
- 版本无变化 → 不做任何操作
- 服务启动后立即执行首次对账(initialDelay=0)
3. **调优建议**:
- `interval` 越小,一致性越好,但 Redis 开销越大(每次对账 N 次 `GET`,N = 活跃 cacheName 数量)
- 默认 30 秒适合大多数场景;一致性要求高可缩短到 10 秒
- 可配合 `l1-max-ttl` 使用,作为双重保障
4. **对账范围限制(重要)**:
- 对账补偿 `clear()`、`clearByPrefix(prefix)` 和 `clearByGroup(group)` 的 Pub/Sub 丢失,因为这三类操作会递增版本号。
- **单 key `evict(key)` 的 Pub/Sub 丢失无法通过对账补偿**。原因:单 key evict 若按 key 写版本号,Redis 中会产生与业务 key 数量等量的 version 键,无限膨胀。
- 因此单 key evict 的跨实例失效完全依赖 Pub/Sub 实时送达;若 Pub/Sub 丢失,受影响实例只能通过 `l1-max-ttl` 自然过期兜底。
- 对一致性要求高的单 key 场景,建议:
- 设置合理的 `l1-max-ttl`(如 300 秒)作为兜底
- 或改用 `@BingCacheEvict(allEntries = true)` 触发 `clearByPrefix` / `clearByGroup`,享受对账补偿
### Redis 降级与恢复
当 Redis 连续操作失败达到 3 次时,输出 WARN 级别降级日志:
```
WARN Bing Cache: Redis L2 cache has failed 3 consecutive times, degraded to L1-only mode. Check Redis connectivity.
```
Redis 恢复正常后:
1. 输出 INFO 级别恢复日志:
```
INFO Bing Cache: Redis L2 cache has recovered from degradation
```
2. **L1 脏数据处理策略**:
- 对账启用(默认):不立即全量清空 L1,由对账服务在下一个周期按 cacheName 粒度清理,避免恢复瞬间大量回源
- 对账禁用:立即全量清空 L1,防止 Redis 恢复后脏数据持续暴露
降级期间,所有 L2 操作静默失败,缓存自动退化为纯 L1 模式,不影响业务正常运行。
**Flapping 保护**:降级状态下需**连续 3 次成功**操作才判定 Redis 真正恢复并触发恢复回调。期间任何一次失败都会重置成功计数器。这避免了 Redis 在可用/不可用之间快速抖动时反复触发 `recoveryCallback` 清空 L1、引发缓存雪崩。与降级阈值的 3 次失败形成对称设计。
## 配置属性
通过 `application.yml` 配置,前缀为 `bing.cache`:
```yaml
bing:
cache:
caffeine:
max-size: 1000 # Caffeine Cache 的最大条目数(默认 1000)
l1-max-ttl: 0 # L1 最大存活秒数,0 表示不限制(默认 0;L1+L2 模式下 0 会自动兜底为 300)
redis:
enabled: true # 是否启用 L2 Redis 缓存(默认 true)
key-prefix: "bing-cache:" # Redis key 前缀(默认 bing-cache:)
channel-name: "bing-cache:invalidation" # Pub/Sub 频道名称(默认 bing-cache:invalidation)
scan-count: 1000 # Redis SCAN count hint(默认 1000)
delete-batch-size: 500 # Redis 批量删除每批 key 数(默认 500)
use-unlink: true # 优先使用 UNLINK 异步删除,失败自动降级 DEL(默认 true)
failure-log-interval: 30 # Redis 降级期间失败日志限流间隔秒数(默认 30)
reconciliation:
enabled: true # 是否启用版本对账(默认 true)
interval: 30 # 对账间隔秒数(默认 30)
```
### 配置说明
| 属性 | 默认值 | 说明 |
|------|--------|------|
| `bing.cache.caffeine.max-size` | `1000` | Caffeine Cache 的最大条目数 |
| `bing.cache.caffeine.l1-max-ttl` | `0` | L1 最大存活秒数,0 表示不限制。设置后所有 L1 条目过期时间不超过该值,作为 Pub/Sub 丢失或 Redis 不可用时的兜底保障。**L1+L2 模式下若保持 0,组件会自动使用 300 秒作为兜底默认值**(因单 key evict 的 Pub/Sub 丢失无法通过对账补偿);纯 L1 模式下 0 即不限制 |
| `bing.cache.redis.enabled` | `true` | 是否启用 L2 Redis 缓存。仅在 classpath 存在 Redis 依赖且连接可用时生效;跨实例 `evict()` / `@BingCacheEvict` 失效通知依赖该模式下的 Redis Pub/Sub |
| `bing.cache.redis.key-prefix` | `bing-cache:` | Redis 中缓存 key 的前缀,用于命名空间隔离 |
| `bing.cache.redis.channel-name` | `bing-cache:invalidation` | 缓存失效通知的 Redis Pub/Sub 频道名称,仅在启用 L2 Redis 缓存时生效 |
| `bing.cache.redis.scan-count` | `1000` | Redis SCAN count hint,用于 `clear()` / `clearByPrefix()` 扫描 key |
| `bing.cache.redis.delete-batch-size` | `500` | Redis 清理时每批删除 key 数量,避免一次性删除过多 key |
| `bing.cache.redis.use-unlink` | `true` | 清理 Redis key 时优先使用 `UNLINK` 异步删除;UNLINK 失败时当前批次及后续批次自动降级为 `DEL`;DEL 失败时清理中断并触发降级记录(与 L1 降级流程一致) |
| `bing.cache.redis.failure-log-interval` | `30` | Redis 降级期间重复失败日志的最小输出间隔,单位秒 |
| `bing.cache.reconciliation.enabled` | `true` | 是否启用版本对账,补偿 Pub/Sub 消息丢失 |
| `bing.cache.reconciliation.interval` | `30` | 版本对账间隔秒数 |
### 启用 L2 Redis 缓存
只需确保项目中引入了 `spring-boot-starter-data-redis` 依赖并配置了 Redis 连接:
```xml
org.springframework.boot
spring-boot-starter-data-redis
```
```yaml
# application.yml
spring:
data:
redis:
host: localhost
port: 6379
```
`bing.cache.redis.enabled` 默认为 `true`,只要 Redis 连接可用,自动启用 L1+L2 二级缓存。
### 禁用 L2 Redis 缓存
即使项目中引入了 Redis 依赖,也可以通过配置显式禁用 L2:
```yaml
bing:
cache:
redis:
enabled: false
```
此时回退为纯 L1 本地缓存模式。
### 无 Redis 的项目
如果项目 classpath 中没有 `spring-boot-starter-data-redis`,组件自动以纯 L1 模式运行,无需任何额外配置。`spring-boot-starter-data-redis` 的 scope 为 `provided`,由使用者按需引入。
## 手动管理缓存
注入 `CacheManager` 接口可手动管理缓存:
```java
@Resource
private CacheManager cacheManager;
// 清除指定 key 的缓存
cacheManager.evict("user(Sg[N:1])");
// 清除指定 cacheName 下的所有缓存(精确匹配 "user(" 前缀,不会误删 "userDetail" 等)
cacheManager.clearByPrefix("user");
// 清空所有缓存
cacheManager.clear();
```
> 手动 evict 时,key 必须与 `CacheKeyGenerator` 生成的 key 完全一致。建议优先使用 `@BingCacheEvict` 注解方式。
### 通过接口手动清缓存
```java
@RestController
@RequestMapping("/cache")
public class CacheController {
@Resource
private CacheManager cacheManager;
@PostMapping("/clear")
public String clear() {
cacheManager.clear();
return "ok";
}
@PostMapping("/evict/{key}")
public String evict(@PathVariable String key) {
cacheManager.evict(key);
return "ok";
}
}
```
## 日志与调试
开启 DEBUG 日志可查看缓存命中情况:
```yaml
logging:
level:
com.bing.cache: DEBUG
```
日志分两层:**切面层**(`CacheAspect` / `CacheEvictAspect`)在纯 L1 和 L1+L2 两种模式下都会输出;**二级缓存层**(`CompositeCacheManager` / `RedisCacheManager`)仅在 L1+L2 模式下额外输出。
切面层日志(两种模式都会输出):
```
DEBUG Cache hit: user(Sg[N:1]) # 缓存命中
DEBUG Cache hit (null sentinel): user(Sg[N:999]) # 命中 null 占位符(cacheNullValue=true)
DEBUG Cache miss: user(Sg[N:1]) # 缓存未命中
DEBUG Cache put: user(Sg[N:1]) # 缓存写入
DEBUG Cache put (null value): user(Sg[N:999]) # null 值缓存写入
DEBUG Cache skip (null result): user(Sg[N:999]) # null 结果跳过缓存
DEBUG Cache evict: user(Sg[N:1]) # 单 key 清除
DEBUG Cache clear by prefix: user # 按前缀清除(allEntries + cacheName/keyPrefix)
DEBUG Cache clear by group: admin # 按 group 清除(allEntries + 仅 group)
DEBUG Cache clear all entries # 全局清空(allEntries,无 cacheName/keyPrefix/group)
```
二级缓存层日志(仅 L1+L2 模式额外输出):
```
DEBUG L1 cache hit: user(Sg[N:1]) # L1 命中
DEBUG L2 cache hit, backfilling L1: user(Sg[N:1]) # L2 命中并回填 L1
DEBUG L1+L2 cache miss: user(Sg[N:1]) # L1 和 L2 均未命中
DEBUG Redis cache hit: bing-cache:user(Sg[N:1]) # Redis 命中
DEBUG Cache put (L1+L2): user(Sg[N:1]) # L1+L2 同时写入
DEBUG Cache evict (L2+L1+pub): user(Sg[N:1]) # L2+L1 清除并发布 Pub/Sub
DEBUG Cache clear by prefix (L2+L1+pub): user # 按前缀清除(L2+L1+Pub/Sub)
DEBUG Cache clear (L2+L1+pub) # 全局清空(L2+L1+Pub/Sub)
```
Redis 降级与恢复:
```
WARN Bing Cache: Redis L2 cache has failed 3 consecutive times, degraded to L1-only mode. Check Redis connectivity. # 连续失败 3 次降级
WARN Bing Cache: Redis L2 cache still degraded. ... # 降级期间按 failure-log-interval 限流的摘要日志
INFO Bing Cache: Redis L2 cache has recovered from degradation # 连续成功 3 次恢复
```
## 注意事项
1. **自调用失效**:同类内部方法调用不会触发 AOP 代理,缓存注解不生效。需通过 Spring 注入的 Bean 调用。
2. **Redis Pub/Sub 依赖 Redis 二级缓存模式**:跨实例缓存失效通知使用 Redis Pub/Sub 实现。没有 Redis 依赖、Redis 连接不可用,或 `bing.cache.redis.enabled=false` 时,组件以纯 L1 模式运行,`evict()` / `@BingCacheEvict` 只能清除当前 JVM 实例的本地缓存,不能通知其他实例。
3. **Redis Pub/Sub 不保证送达**:失效消息基于 Redis Pub/Sub 广播,属于 fire-and-forget 模式。极端情况下(如网络抖动),其他实例可能收不到失效通知,导致短时间内读到旧数据。**注意:版本对账机制只补偿 `clear()`、`clearByPrefix()` 和 `clearByGroup()` 的 Pub/Sub 丢失,单 key `evict()` 的丢失无法补偿**(详见"缓存架构 → 版本对账机制 → 对账范围限制")。建议生产环境设置 `l1-max-ttl` 作为兜底。
4. **适用场景**:本组件适用于读多写少、对缓存一致性要求为最终一致的业务场景(如字典数据、用户信息、配置信息等)。不适合频繁更新且要求强一致性的业务。
5. **多实例部署**:L1 本地缓存各实例独立,必须启用 Redis 二级缓存模式后,`@BingCacheEvict` 才会通过 Pub/Sub 通知其他实例清除本地缓存;通知存在毫秒级延迟。如需强一致,请直接查询数据库。
6. **缓存 key 一致性**:手动 `evict()` 时,key 必须和自动生成的完全一致,可从 DEBUG 日志中获取。推荐使用 `@BingCacheEvict` 注解替代手动操作。
7. **Redis 依赖可选**:`spring-boot-starter-data-redis` 的 scope 为 `provided`,由使用者项目按需引入。没有 Redis 依赖时,组件自动以纯 L1 模式运行。
8. **`allEntries` 清除范围**:`@BingCacheEvict(allEntries = true)` 配合 `cacheName` 或 `keyPrefix` 时,只清除该前缀下的缓存条目;仅指定 `group`(无 cacheName/keyPrefix)时按 group 清除;都不指定时才全局清空。
9. **`clearByPrefix` 精确匹配语义**:`cacheManager.clearByPrefix(prefix)` 内部匹配 `prefix + "("` 开头的 key,确保只清除指定 cacheName 的缓存,不会误删前缀相同的其他 cacheName(如 `clearByPrefix("user")` 不会误删 `userDetail` 的 key)。Redis SCAN 的 glob 结果会通过 `startsWith` 二次过滤,`prefix` 中的 `*`、`?` 等元字符被当作字面字符处理。
10. **`@BingCacheEvict` 未指定 cacheName/keyPrefix 时会输出警告**:当 `@BingCacheEvict` 既没有设置 `cacheName` 也没有设置 `keyPrefix` 时,默认前缀为当前方法名(如 `updateUser`),而对应的 `@BingCache` 方法默认前缀是其方法名(如 `getUserById`),两者不匹配会导致 evict 静默失效。组件会输出 WARN 日志提醒:
```
WARN @BingCacheEvict on method 'updateUser' has no cacheName or keyPrefix set.
The default prefix (this method name) may not match @BingCache's method name,
causing eviction to silently miss the cached key.
Consider setting cacheName to match @BingCache.
```
建议:始终为 `@BingCacheEvict` 指定 `cacheName`,与对应的 `@BingCache` 保持一致。
## 兼容性说明
| 项目 | 支持情况 | 说明 |
|------|----------|------|
| JDK | Java 17+ | 发布产物使用 `--release 17` 编译,可在 JDK 17 及以上版本运行 |
| Spring Boot | 3.x | 当前测试/依赖管理基线为 Spring Boot 3.5.13;面向 Spring Boot 3.x / Spring Framework 6.x / Jakarta 体系 |
| Spring Boot 2.x | 不支持 | Spring Boot 2.x 仍以 `javax.*` 体系为主,与当前模块使用的 Spring Boot 3 / Jakarta 依赖体系不匹配 |
本地可通过 Maven profiles 验证不同 Spring Boot 3.x 基线:
```bash
mvn clean test -Pboot-3.2
mvn clean test -Pboot-3.3
mvn clean test -Pboot-3.5
```
## 技术栈
- Java 17+
- Spring Boot 3.x(当前测试/依赖管理基线:3.5.13)
- Caffeine(由 Spring Boot BOM 管理版本)
- Spring Data Redis(provided scope,使用者提供)
- AspectJ(由 Spring Boot BOM 管理版本)
- Jackson(key 生成 + Redis 序列化)
- JUnit 5 + Mockito(单元测试)
- Testcontainers(集成测试)
## 仓库结构与构建
本仓库使用 Maven 多模块结构:
```text
bing-cache/
├── pom.xml # 父 POM / reactor 聚合工程,统一管理版本、依赖和插件
├── bing-cache-core/ # 核心 starter 源码与单元测试,发布 artifactId 仍为 bing-cache
│ ├── src/main/java/com/bing/cache/
│ └── src/test/java/com/bing/cache/
└── bing-cache-test/ # 集成测试模块,依赖当前 reactor 中的 bing-cache
├── src/main/java/com/example/demo/
└── src/test/java/com/example/demo/
```
对外依赖坐标保持不变:`cn.com.bingbing:bing-cache:1.1-SNAPSHOT`。业务项目继续依赖该坐标即可,不需要依赖 `bing-cache-core` 这个目录名。
常用构建命令:
```bash
# 全量构建
mvn clean verify
# 安装父 POM 和所有模块到本地 Maven 仓库
mvn clean install
# 推荐:只安装业务使用所需的 parent + core 到本地 Maven 仓库
mvn clean install -pl bing-cache-core -am
# 只验证核心模块
mvn -pl bing-cache-core -am verify
# 构建集成测试模块,并自动构建核心依赖
mvn -pl bing-cache-test -am verify
```