缓存规范
TIP
规定后端缓存的使用方式,适用于 Redis + Caffeine 二级缓存 架构。
设计原则
| 原则 | 说明 |
|---|---|
| 二级缓存 | Caffeine(一级/本地缓存)→ Redis(二级/分布式缓存)→ DB |
| 缓存 Key 统一 | Key 前缀集中管理,避免散落在业务代码中 |
| 过期策略 | 不同业务数据设置合理过期时间,避免缓存永久有效 |
| 一致性 | 数据变更时主动清除/更新缓存,非仅依赖过期 |
缓存架构
二级缓存流程
请求 → Caffeine(一级/本地缓存) → Redis(二级/分布式缓存) → DB- Caffeine:本地缓存,JVM 内存级,访问速度最快,但服务间不共享
- Redis:分布式缓存,多服务共享,网络开销但数据一致性好
- DB:MySQL 持久化存储
缓存层级选择
| 层级 | 适用场景 | 特点 |
|---|---|---|
| Caffeine | 高频读取、低频变更、数据量小 | 本地访问、速度快、服务内有效 |
| Redis | 多服务共享、数据量较大 | 网络开销、服务间共享、持久化 |
| DB | 数据持久化、强一致性 | 磁盘存储、最慢、最终一致 |
Redis Key 规范
Key 格式
{业务前缀}:{模块}:{标识}| 组成部分 | 说明 | 示例 |
|---|---|---|
| 业务前缀 | 区分业务域 | user、dept、menu、dict |
| 模块 | 具体功能 | info、list、tree |
| 标识 | 唯一标识(ID/编码) | 1001、admin |
Key 示例
text
✅ user:info:1001 # 用户信息,ID=1001
✅ dept:tree:100 # 部门树,根部门 ID=100
✅ menu:user:admin # 用户菜单,账号=admin
✅ dict:zzlx # 字典-组织类型
❌ user_1001 # 缺少业务前缀分隔
❌ 1001 # 无业务语义
❌ user_info_admin_extra_long_key_name # 过长Key 常量管理
缓存 Key 前缀统一定义在常量类中:
java
public class RedisAdminConstants {
/** 用户信息缓存前缀 */
public static final String USER_INFO_PREFIX = "user:info:";
/** 部门树缓存前缀 */
public static final String DEPT_TREE_PREFIX = "dept:tree:";
/** 用户菜单缓存前缀 */
public static final String USER_MENU_PREFIX = "menu:user:";
/** 字典缓存前缀 */
public static final String DICT_PREFIX = "dict:";
}Redis 客户端使用
RedisAdminClient
通过 RedisAdminClient 封装 Redis 操作:
java
@Component
public class RedisAdminClient {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 设置缓存
*/
public void set(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
/**
* 获取缓存
*/
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
/**
* 删除缓存
*/
public void delete(String key) {
redisTemplate.delete(key);
}
/**
* 设置过期时间
*/
public void expire(String key, long timeout, TimeUnit unit) {
redisTemplate.expire(key, timeout, unit);
}
}调用示例
java
@Service
public class SysUserServiceImpl implements SysUserService {
@Resource
private RedisAdminClient redisAdminClient;
/**
* 获取用户信息(先查缓存,再查 DB)
*/
public Map<String, Object> getUserInfoById(String userId) {
String key = RedisAdminConstants.USER_INFO_PREFIX + userId;
// 1. 查缓存
Object cached = redisAdminClient.get(key);
if (cached != null) {
return (Map<String, Object>) cached;
}
// 2. 查 DB
Map<String, Object> userInfo = sysUserMapper.selectUserInfoById(userId);
// 3. 写入缓存(过期时间 30 分钟)
if (userInfo != null) {
redisAdminClient.set(key, userInfo, 30, TimeUnit.MINUTES);
}
return userInfo;
}
/**
* 更新用户信息(更新 DB + 清除缓存)
*/
public Map<String, Object> updateUser(Long id, Map<String, Object> param) {
// 1. 更新 DB
sysUserMapper.updateById(buildUserPO(param));
// 2. 清除缓存
String key = RedisAdminConstants.USER_INFO_PREFIX + id;
redisAdminClient.delete(key);
return RestResult.success();
}
}过期时间规范
| 数据类型 | 建议过期时间 | 说明 |
|---|---|---|
| 用户信息 | 30 分钟 | 变更频率低,可适当延长 |
| 字典数据 | 60 分钟 | 几乎不变,可长过期 |
| 菜单/权限 | 30 分钟 | 变更需及时生效 |
| 部门树 | 30 分钟 | 组织架构变动少 |
| 验证码 | 5 分钟 | 短时效,安全要求 |
| Token | 按业务 | 根据登录态有效期 |
缓存同步
服务间缓存同步
通过 Feign 调用 guava-redis 服务同步缓存:
java
@FeignClient(name = "guava-redis", path = "/user")
public interface RedisUserFeignService {
@PostMapping("/updateUser")
void updateUser(@RequestBody SysUserPO sysUserPO);
@PostMapping("/deleteUser")
void deleteUser(@RequestParam Long userId);
}数据变更时的缓存处理
| 操作 | 缓存处理 |
|---|---|
| 新增 | 无需处理(首次查询时写入缓存) |
| 更新 | 删除缓存(下次查询时重新加载) |
| 删除 | 删除缓存 |
java
@Service
public class SysUserServiceImpl implements SysUserService {
@Resource
private RedisUserFeignService redisUserFeignService;
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> updateUser(Long id, Map<String, Object> param) {
// 1. 更新 DB
sysUserMapper.updateById(buildUserPO(param));
// 2. 通过 Feign 通知 Redis 服务清除/更新缓存
SysUserPO updatedUser = sysUserMapper.selectById(id);
redisUserFeignService.updateUser(updatedUser);
return RestResult.success();
}
}缓存穿透/击穿/雪崩
缓存穿透(查询不存在的数据)
问题:请求的数据在 DB 中也不存在,每次都会打到 DB。
解决方案:对空结果也缓存较短时间。
java
public Map<String, Object> getUserInfoById(String userId) {
String key = RedisAdminConstants.USER_INFO_PREFIX + userId;
// 查缓存
Object cached = redisAdminClient.get(key);
if (cached != null) {
// 空值标记
if ("NULL".equals(cached)) {
return null;
}
return (Map<String, Object>) cached;
}
// 查 DB
Map<String, Object> userInfo = sysUserMapper.selectUserInfoById(userId);
if (userInfo == null) {
// 空值缓存 5 分钟,避免穿透
redisAdminClient.set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
redisAdminClient.set(key, userInfo, 30, TimeUnit.MINUTES);
}
return userInfo;
}缓存击穿(热点 Key 过期)
问题:某个热点 Key 过期,大量请求同时打到 DB。
解决方案:加互斥锁,只允许一个线程回源。
java
public Map<String, Object> getUserInfoById(String userId) {
String key = RedisAdminConstants.USER_INFO_PREFIX + userId;
Object cached = redisAdminClient.get(key);
if (cached != null) {
return (Map<String, Object>) cached;
}
// 加锁回源
String lockKey = "lock:" + key;
boolean locked = tryLock(lockKey);
try {
if (locked) {
// 二次检查
cached = redisAdminClient.get(key);
if (cached != null) {
return (Map<String, Object>) cached;
}
Map<String, Object> userInfo = sysUserMapper.selectUserInfoById(userId);
redisAdminClient.set(key, userInfo, 30, TimeUnit.MINUTES);
return userInfo;
}
} finally {
if (locked) {
unlock(lockKey);
}
}
return null;
}缓存雪崩(大量 Key 同时过期)
问题:大量缓存 Key 在同一时间过期,请求全部打到 DB。
解决方案:过期时间加随机值。
java
// 基础过期时间 30 分钟 + 随机 0~10 分钟
long expireTime = 30 + ThreadLocalRandom.current().nextInt(10);
redisAdminClient.set(key, value, expireTime, TimeUnit.MINUTES);Caffeine 本地缓存
配置
java
@Configuration
public class CaffeineConfig {
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
}
}使用
java
@Service
public class DictServiceImpl implements DictService {
@Resource
private Cache<String, Object> caffeineCache;
/**
* 获取字典数据(Caffeine → Redis → DB)
*/
public List<Map<String, Object>> getDictItems(String dictType) {
String key = RedisAdminConstants.DICT_PREFIX + dictType;
// 1. 查 Caffeine
Object localCached = caffeineCache.getIfPresent(key);
if (localCached != null) {
return (List<Map<String, Object>>) localCached;
}
// 2. 查 Redis
Object redisCached = redisAdminClient.get(key);
if (redisCached != null) {
// 回填 Caffeine
caffeineCache.put(key, redisCached);
return (List<Map<String, Object>>) redisCached;
}
// 3. 查 DB
List<Map<String, Object>> items = sysDictMapper.selectDictItems(dictType);
// 4. 写入 Redis + Caffeine
redisAdminClient.set(key, items, 60, TimeUnit.MINUTES);
caffeineCache.put(key, items);
return items;
}
}反例与正例
Key 散落在业务代码
java
// ❌ Key 硬编码在业务逻辑中
redisTemplate.opsForValue().set("user_" + userId, userInfo, 30, TimeUnit.MINUTES);
// ✅ Key 前缀统一定义在常量类中
redisTemplate.opsForValue().set(RedisAdminConstants.USER_INFO_PREFIX + userId, userInfo, 30, TimeUnit.MINUTES);数据变更不清缓存
java
// ❌ 更新 DB 后未清除缓存
public void updateUser(Long id, Map<String, Object> param) {
sysUserMapper.updateById(buildUserPO(param));
// 缓存中的数据仍是旧的
}
// ✅ 更新 DB 后清除缓存
public void updateUser(Long id, Map<String, Object> param) {
sysUserMapper.updateById(buildUserPO(param));
redisAdminClient.delete(RedisAdminConstants.USER_INFO_PREFIX + id);
}过期时间过长
java
// ❌ 字典数据缓存 24 小时,变更后无法及时生效
redisAdminClient.set(key, items, 24, TimeUnit.HOURS);
// ✅ 合理过期时间 + 变更时主动清除
redisAdminClient.set(key, items, 60, TimeUnit.MINUTES);缓存 Checklist
- Key 格式统一(
{业务}:{模块}:{标识}) - Key 前缀定义在常量类中
- 过期时间设置合理(非永久)
- 数据变更时清除/更新缓存
- 空值缓存处理(防穿透)
- 过期时间加随机值(防雪崩)
- 热点数据加锁回源(防击穿)