先说几个高频误用
业务系统里用 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 异常时主流程要有明确降级路径