Redis实战踩坑:那些本地测不出,生产才爆发的代码陷阱

Redis实战踩坑:那些本地测不出,生产才爆发的代码陷阱
在后端项目里Redis几乎是标配缓存组件。很多开发者本地写Demo,简单set、get跑一遍,功能一切正常,就直接照搬到业务代码。可一旦上生产,伴随着真实流量,就会出现各种匪夷所思的问题:接口随机超时、数据和数据库不一致、CPU飙升、偶现业务错乱,甚至把整个Redis实例打垮。
QQ20260808-172002.png
这些故障,很多并不是Redis本身的bug,而是我们业务代码写法不当,忽略了网络、并发、内存、命令特性带来的边界问题。下面全部来自项目真实线上故障复盘,包含错误代码、故障现象、根因拆解、生产修复方案,和上一篇Java基础踩坑内容完全不重复,适合日常开发自查。

坑点一:循环内多次执行Redis网络命令,大量小命令引发RT暴涨
故障现象
本地测试数据少,接口响应很快。上线之后,当批量处理几十上百条业务数据的时候,接口耗时急剧上涨,从几毫秒飙升到几百毫秒甚至秒级。CPU指标看不出明显异常,但是Redis网络IO持续走高,大量请求堆积。

错误示范代码
java
// 批量查询用户信息,循环内一次次调用Redis get
public List batchGetUser(List userIdList){
List result = new ArrayList<>();
for (Long userId : userIdList) {
// 循环里面每一次都是一次完整网络往返
String json = redisTemplate.opsForValue().get("user:info:" + userId);
if(StringUtils.isNotEmpty(json)){
result.add(JSON.parseObject(json, UserInfo.class));
}
}
return result;
}

根因分析:每一次get都是一次完整网络请求。循环100次,就会产生100次TCP往返。本地开发时,Redis和应用都在本机,网络延迟几乎可以忽略,感受不到性能损耗。生产环境Redis独立部署,存在网络RTT,多次网络往返时间会累积叠加。批量数据越大,接口耗时就越严重。大量小命令同时涌入,也会加重Redis的网络事件循环压力。

修复方案
使用Redis的mget批量命令,一次网络请求拿到多条key的数据,减少网络交互次数。
java
public List batchGetUser(List userIdList){
List keyList = userIdList.stream()
.map(id -> "user:info:" + id)
.collect(Collectors.toList());
// 一次网络交互,批量获取多个key
List valueList = redisTemplate.opsForValue().multiGet(keyList);

List<UserInfo> result = new ArrayList<>();
for (int i = 0; i < valueList.size(); i++) {
    String json = valueList.get(i);
    if(StringUtils.isNotEmpty(json)){
        result.add(JSON.parseObject(json, UserInfo.class));
    }
}
return result;

}

实操补充:

  1. mget不要一次性传入成千上万个key,key数量过大也会造成Redis单命令耗时变长,可以做分片,比如每200个key做一次mget;
  2. 写操作同理,循环set改成mset,减少网络往返;
  3. 如果是复杂混合命令,考虑使用Pipeline管道,把多条命令打包一次性发送。

    坑点二:直接使用keys做key检索,生产环境直接阻塞Redis主线程
    故障现象
    后台管理需要查询一批业务缓存key,开发直接写keys命令。测试环境key数量少,执行飞快。生产库几十万上百万key,一旦代码执行keys命令,Redis主线程直接阻塞,所有读写请求全部卡住,大量接口超时,严重直接造成服务雪崩。

    错误示范代码
    java
    // 高危!禁止线上使用keys
    Set keys = redisTemplate.keys("order:");

根因:Redis是单线程模型。keys命令会遍历全量所有key,没有任何偏移分页。当实例key数量巨大,这条命令执行期间,Redis无法处理任何其他请求。测试库数据少感受不到危害,生产环境一旦触发就是重大故障。很多人图简单写在后台工具,定时任务里面,上线之后偶然触发,直接炸库。

修复方案
使用scan迭代器渐进式遍历,不会阻塞主线程,分批获取匹配key。
java
public Set scanKeys(String pattern) {
Set keySet = new HashSet<>();
ScanOptions options = ScanOptions.scanOptions()
.match(pattern)
.count(1000).build();
try (Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection(
(RedisConnectionCallback<Cursor<byte[]>>) conn -> conn.scan(options))) {
while (cursor.hasNext()) {
keySet.add(new String(cursor.next(), StandardCharsets.UTF_8));
}
}
return keySet;
}

重要提醒:scan不是完美的,迭代期间有key增减会出现漏key、重复key,适合后台管理、清理缓存,绝对不能用于业务核心逻辑。业务尽量设计key可以直接拼接,不要依赖模糊检索。

坑点三:缓存更新直接del删除,高并发下出现数据库与缓存数据永久不一致
故障现象
更新数据库业务数据之后,代码直接删除Redis缓存。高并发场景下偶现:数据库已经更新成功,但是缓存里面依旧是旧数据,用户长期读到过期缓存,必须人工手动删除缓存才能恢复。本地单线程测试完全复现不了。

错误示范代码
java
// 更新数据库,直接删除缓存
@Transactional
public void updateUserInfo(User user){
userMapper.updateById(user);
// 删除缓存
redisTemplate.delete("user:info:" + user.getId());
}

根因:并发时序问题。

  1. 线程A读取缓存,缓存刚好失效,没拿到数据,准备去查数据库旧数据;
  2. 线程B执行更新逻辑:更新数据库,执行delete删除缓存;
  3. 线程A拿到数据库旧数据,写回Redis缓存;
    这个时候数据库已经是新数据,Redis被写入旧值,缓存和数据库永久不一致,直到key过期。低并发几乎碰不到这个时序,线上流量上来就会偶现数据错乱。

    修复方案:延时双删策略
    java
    @Transactional
    public void updateUserInfo(User user){
    Long userId = user.getId();
    // 第一步删除缓存
    redisTemplate.delete("user:info:" + userId);
    // 更新数据库
    userMapper.updateById(user);
    // 延时二次删除,等待旧数据读请求完成,异步执行
    new Thread(() -> {
    try {
    // 休眠时间大于业务读请求耗时,根据业务调整,一般500‑1000ms
    Thread.sleep(800);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    redisTemplate.delete("user:info:" + userId);
    }).start();
    }

生产实践补充:

  1. 不要直接new Thread,业务项目交给线程池执行延时删除;
  2. 延时双删只能降低不一致概率,不能100%杜绝;对数据一致性要求极高场景,放弃缓存直连数据库;
  3. 也可以给缓存设置较短过期时间,就算出现不一致,到期自动失效兜底。

    坑点四:存入超大Value大key,引发Redis卡顿、接口超时
    故障现象
    某个接口间歇性超时,大部分请求正常,一部分请求很慢。Redis的CPU不高,但是命令响应时间偶尔飙升。排查发现有单个key存储几十KB甚至几MB的数据。本地测试小数据看不出问题。

    错误示范代码
    java
    // 将整个大列表全部序列化存入一个redis key
    List allProductList = productMapper.selectAll();
    String json = JSON.toJSONString(allProductList);
    redisTemplate.opsForValue().set("product:all", json, 1, TimeUnit.HOURS);

根因:Redis单线程。读写大key的时候,序列化、网络传输、内存分配都会占用主线程时间。当一个key几MB,读取这个key的时候,整个Redis实例所有请求全部阻塞。集群环境下,大key迁移的时候,也会造成节点卡顿。很多人把全量列表直接丢进缓存,本地测试数据量小,毫无问题,线上数据膨胀之后故障爆发。

修复方案

  1. 拆分大key,大列表拆分成多个小key;
  2. 列表类数据,优先使用Redis list/hash结构,不要序列化为一个完整JSON字符串;
  3. 如果数据量实在巨大,不适合放Redis,考虑走数据库分页查询;
  4. 定期巡检大key,线上定时扫描,发现超过阈值的key告警。

java
// 拆分示例,hash结构,字段分散存储
// hset product:list 1 "{json}"
for (Product product : allProductList) {
redisTemplate.opsForHash().put("product:list",product.getId().toString(),JSON.toJSONString(product));
}

坑点五:不设置过期时间,缓存无限堆积,Redis内存持续占满
故障现象
Redis内存占用一天天上涨,没有下降趋势。运行一段时间后触发内存淘汰策略,部分key被随机清理,业务出现莫名其妙缓存丢失。很多缓存key业务早已废弃,却一直驻留内存。

错误示范代码
java
// 只set,不设置过期时间,key永久有效
redisTemplate.opsForValue().set("hot:data:" + id, jsonStr);

根因:很多开发写缓存只写set,忘记expire过期时间。这些key永久驻留内存。随着业务运行,缓存key越来越多,内存被占满。当达到maxmemory上限,Redis执行内存淘汰策略,随机淘汰key,业务会出现不可预期的缓存失效。本地测试经常重启Redis,内存不会累积,很难发现这个隐患。

修复方案
所有业务缓存key必须强制设置过期时间
java
// 设置过期时间,根据业务场景合理设置
redisTemplate.opsForValue().set("hot:data:" + id, jsonStr,2, TimeUnit.HOURS);

拓展知识点:

  1. 热点永不过期数据,不要不设过期,改用逻辑过期:存一份过期时间字段,业务代码判断时间,异步更新缓存;
  2. 区分永久数据和缓存数据,用户配置、元数据这类持久数据不要放到普通缓存,避免和缓存混在一起。

    坑点六:Redis分布式锁,锁超时未处理,导致锁误释放,并发安全失效
    故障现象
    使用Redis实现简易分布式锁,线上偶现多线程同时拿到同一把锁,发生超卖、重复执行业务。本地很难复现,只有业务执行耗时超过锁过期时间才会触发。

    错误示范代码
    java
    // 简易分布式锁错误写法
    public void doBusiness(Long id){
    Boolean lockOk = redisTemplate.opsForValue()
    .setIfAbsent("lock:order:"+id,"1",30, TimeUnit.SECONDS);
    if(Boolean.TRUE.equals(lockOk)){
    try{
    // 执行业务,业务执行时间超过30秒!
    heavyBusiness();
    }finally {
    // 直接删除锁,会误删其他线程的锁
    redisTemplate.delete("lock:order:"+id);
    }
    }
    }

根因分析:
锁过期时间设置30秒,如果业务逻辑执行耗时超过30s,Redis锁自动过期释放。此时第二个线程获取到锁开始执行业务。第一个线程执行完毕进入finally,直接delete把第二个线程持有的锁删掉。第三个线程又可以拿到锁,多个业务同时执行,并发保护完全失效。

修复方案
生产不要手写简易分布式锁,直接使用Redisson,自带看门狗自动续期。
java
RLock lock = redissonClient.getLock("lock:order:" + id);
try {
// 加锁,看门狗自动续期,业务没跑完锁不会过期
boolean getLock = lock.tryLock(10,30,TimeUnit.SECONDS);
if(getLock){
heavyBusiness();
}
}finally {
if(lock.isHeldByCurrentThread()){
lock.unlock();
}
}

避坑提醒:网上很多手写lua脚本实现分布式锁,依然无法解决业务执行超时锁过期问题,需要续期逻辑,手写续期很容易出错,业务项目优先Redisson。

坑点七:忽略Redis序列化问题,存入乱码前缀,key无法匹配
故障现象
代码使用RedisTemplate,set存入数据,但是命令行redis‑cli查看key带着\xac\xed\x00\x05这类乱码前缀。代码里面get可以拿到,但是手动写命令删除key失效;或者升级环境之后,代码读取不到历史缓存。

错误原因
RedisTemplate默认使用JDK序列化。key和value都会带上JDK序列化二进制前缀。适合Java程序内部读取,但是不适合手动运维、其他语言客户端交互。很多新手不知道序列化机制,线上运维清理缓存踩坑。

修复方案
生产环境,缓存业务优先使用String序列化。
java
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
// key全部使用字符串序列化
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// value序列化为JSON字符串
Jackson2JsonRedisSerializer jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}

线上开发总结:Redis编码必须守住的几条原则

  1. 杜绝线上使用keys命令,遍历key一律用scan;
  2. 批量操作尽量mget/mset/pipeline,减少循环单次网络交互;
  3. 所有缓存key必须设置过期时间,避免内存无限膨胀;
  4. 警惕大key,不要把海量数据塞进单个key;
  5. 手写分布式锁风险极高,正式业务优先Redisson;
  6. 重视序列化配置,避免key乱码,造成运维和跨语言兼容问题;
  7. 缓存更新要思考并发时序,简单del删除会有数据不一致风险;
  8. 本地测试数据量小,并发低,很多Redis问题不会暴露,上线前压测模拟真实流量。

很多人觉得Redis上手简单,就是set和get。真正跑生产环境才会发现,很多故障不是命令不会用,而是没有理解Redis单线程模型、网络交互、并发时序、内存淘汰这些底层特性。出现线上诡异故障,不要盲目重启实例,先看慢日志、大key、网络耗时,定位根因,而不是简单掩盖问题。

凡尘版权

友情链接:凡尘博客凡尘影院凡尘乡音|凡尘街坊凡尘博客|雨落凡尘博客|羽落凡尘博客凡尘博客|雨落凡尘博客|羽落凡尘博客

本文为个人博客原创技术文章,记录Redis线上实战踩坑经验,转载请保留完整版权与友链信息。

标签: none

添加新评论

  • 上一篇:
  • 下一篇: