先说几个高频误用

业务系统里用 Redis 很常见,但很多问题不是 Redis 不好用,而是用法太顺手,最后把它当成了万能胶。

最常见的误用通常是下面这几类:

1、把 Redis 当数据库底线,只存不落库

2、缓存键没有边界,TTL 也靠拍脑袋

3、分布式锁只管加锁,不管释放和续期

4、热点 key、BigKey、批量 key 清理没人管

5、Redis 异常时没有降级,主流程一起跟着抖

这一篇只讲业务里高频踩坑点和修正方式,不展开 Redis 基础原理。

别把 Redis 当唯一真相源

很多业务一开始只是想提速,后面慢慢演变成:核心状态只写 Redis,不落库。

1
2
3
4
public void markUserCoupon(Long userId, Long couponId) {
String key = "coupon:receive:" + userId + ":" + couponId;
redisTemplate.opsForValue().set(key, "1", Duration.ofDays(30));
}

这种写法的问题不是不能用,而是它只能算一层临时状态。

如果这就是唯一记录,一旦发生下面这些情况,业务就会很难收:

1、Redis key 过期了,但业务实际还有效

2、主从切换或异常丢失,状态找不回来

3、排查用户投诉时,没有持久化依据

更稳的修正方式是:Redis 负责提速,数据库负责底线。

1
2
3
4
5
6
@Transactional(rollbackFor = Exception.class)
public void receiveCoupon(Long userId, Long couponId) {
couponReceiveMapper.insert(userId, couponId);
String key = "coupon:receive:" + userId + ":" + couponId;
redisTemplate.opsForValue().set(key, "1", Duration.ofDays(7));
}

然后查询时优先 Redis,未命中再回库:

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean hasReceived(Long userId, Long couponId) {
String key = "coupon:receive:" + userId + ":" + couponId;
String cached = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(cached)) {
return true;
}

boolean exists = couponReceiveMapper.exists(userId, couponId);
if (exists) {
redisTemplate.opsForValue().set(key, "1", Duration.ofDays(7));
}
return exists;
}

key 和 TTL 先定规矩

很多线上问题不是 Redis 挂了,而是 key 命名混乱、过期时间随意,最后没人知道哪些 key 能删、哪些 key 不能删。

坏味道一般长这样:

1
2
3
redisTemplate.opsForValue().set("order" + orderId, JSON.toJSONString(order));
redisTemplate.opsForValue().set("user_info_" + userId, userJson);
redisTemplate.opsForValue().set("a:b:c:" + id, value, 1234, TimeUnit.SECONDS);

更稳的做法是统一 key 规范和过期策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class RedisKeys {

private RedisKeys() {
}

public static String orderDetail(Long orderId) {
return "order:detail:" + orderId;
}

public static String userProfile(Long userId) {
return "user:profile:" + userId;
}
}
1
2
3
4
5
6
7
8
public final class RedisExpire {

public static final Duration ORDER_DETAIL = Duration.ofMinutes(10);
public static final Duration USER_PROFILE = Duration.ofHours(2);

private RedisExpire() {
}
}

这样做的价值很直接:

1、key 含义明确,出问题时能快速定位

2、过期时间可统一审视,不会满地魔法数字

3、后续迁移、清理和监控都更容易做

击穿不只是缓存空值

不少接口第一次遇到缓存击穿时,修法只有一句“空值也缓存一下”。

这当然有用,但它只能挡住一部分穿透,挡不住热点并发回源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public ProductDTO queryProduct(Long productId) {
String key = RedisKeys.productDetail(productId);
String cached = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(cached)) {
return JsonUtils.parseObject(cached, ProductDTO.class);
}

ProductDTO dto = productMapper.queryDetail(productId);
if (dto == null) {
redisTemplate.opsForValue().set(key, "NULL", Duration.ofMinutes(2));
return null;
}

redisTemplate.opsForValue().set(key, JsonUtils.toJSONString(dto), Duration.ofMinutes(20));
return dto;
}

更稳的修正方式一般是:

1、空值缓存挡穿透

2、热点回源时做互斥控制

3、必要时对回源做限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public ProductDTO queryProduct(Long productId) {
String key = RedisKeys.productDetail(productId);
String cached = redisTemplate.opsForValue().get(key);
if ("NULL".equals(cached)) {
return null;
}
if (StringUtils.isNotBlank(cached)) {
return JsonUtils.parseObject(cached, ProductDTO.class);
}

String lockKey = "lock:" + key;
boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3)));
if (!locked) {
throw new BizException("当前查询繁忙,请稍后再试");
}

try {
ProductDTO dto = productMapper.queryDetail(productId);
if (dto == null) {
redisTemplate.opsForValue().set(key, "NULL", Duration.ofMinutes(2));
return null;
}
redisTemplate.opsForValue().set(key, JsonUtils.toJSONString(dto), Duration.ofMinutes(20));
return dto;
} finally {
redisTemplate.delete(lockKey);
}
}

释放锁前先认人

这是 Redis 锁里最容易出现的老问题。

1
2
3
public void unlock(String lockKey) {
redisTemplate.delete(lockKey);
}

这类写法的问题是:

1、当前线程锁超时后,别的线程可能已经拿到新锁

2、你再 delete,就把别人的锁删掉了

至少要做到“谁加的锁,谁来释放”。

1
2
3
4
5
6
7
8
9
10
11
public boolean lock(String lockKey, String requestId, Duration duration) {
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, duration));
}

public void unlock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId);
}

如果锁持有时间可能超过过期时间,还要考虑续期,不然业务没跑完锁就掉了。

BigKey 和热点 key 迟早会炸

不少项目 Redis 一开始只存几类轻量数据,后来越堆越多:

1、一个 key 塞几千上万条对象

2、热门活动所有流量都打同一个 key

3、排行榜、用户画像、配置快照全放一个大 Hash

这种问题平时不一定爆,但一到高峰期就容易出现:

1、单 key 操作耗时抖动

2、网络包过大

3、内存碎片和迁移压力变高

更常见的修正方式是拆 key、拆分片、拆维度。

1
2
3
public String stockBucketKey(Long skuId, int bucket) {
return "stock:bucket:" + skuId + ":" + bucket;
}
1
2
3
public int chooseBucket(Long userId, int bucketCount) {
return Math.abs(userId.hashCode()) % bucketCount;
}

如果一个热点 key 无法拆,还要单独对读取接口做本地缓存、限流或异步化处理。

Redis 出问题要有降级

很多业务默认 Redis 一定可用,所以代码里完全没有兜底。

1
2
3
4
public boolean checkDuplicate(String bizNo) {
String key = "biz:dup:" + bizNo;
return StringUtils.isNotBlank(redisTemplate.opsForValue().get(key));
}

如果 Redis 此时超时或连接异常,整个主流程就会被拖下去。

更稳的做法是按场景决定降级路径。

1
2
3
4
5
6
7
8
9
10
11
12
public boolean checkDuplicate(String bizNo) {
String key = "biz:dup:" + bizNo;
try {
String cached = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(cached)) {
return true;
}
} catch (Exception e) {
log.warn("redis duplicate check failed, bizNo={}", bizNo, e);
}
return orderMapper.existsByBizNo(bizNo);
}

不是所有场景都适合回库,但至少要先想清楚:Redis 不可用时,主业务是降级、回退、限流,还是直接失败。

常见坑

1、缓存和数据库双写没有顺序策略

如果既写库又删缓存,但没有统一时序,脏数据非常容易出现。

2、TTL 全靠拍脑袋

过期太短命中率低,过期太长脏数据窗口大,不能随手写。

3、把 Redis 锁当成绝对正确性保障

锁只能缩小并发窗口,很多场景最终还是要靠数据库约束或状态机兜底。

4、批量 key 操作没有边界

一口气 scan、mget、delete 大批 key,线上很容易抖。

5、Redis 报错没有单独监控

很多业务问题看起来像接口超时,根因其实是 Redis 连接池先顶不住了。

收一下

Redis 在业务系统里最容易出问题的地方,不是命令不会写,而是边界没想清楚。

真正值得保留的常用修正思路,核心就这几类:

1、Redis 负责提速,数据库负责底线

2、key 和 TTL 要有统一规范

3、缓存击穿要同时考虑空值缓存、互斥和限流

4、分布式锁要校验 value,再决定释放

5、热点 key 和 BigKey 要持续治理

6、Redis 异常时主流程要有明确降级路径