Redis 分布式锁 + GZIP 压缩缓存踩坑|高并发缓存真实线上复盘

大家好,本篇属于Java 后端踩坑实录,来自项目大数据量缓存业务真实开发复盘。

大数据缓存系统会遇到典型痛点:热点大缓存 key 过期瞬间,上百个请求同时涌入,如果全部查询数据库执行业务计算,直接打垮 DB。 于是我们采用两套方案:

  1. Redis 分布式锁:缓存失效时只放行一个线程执行业务计算构建缓存,其余请求等待,防护缓存击穿。
  2. GZIP 压缩缓存:业务返回 JSON 体积大,使用 GZIP 压缩降低 Redis 内存占用。

看似非常标准的组合方案,但在落地时,手写分布式锁、压缩存储处理不当,引出一连串隐蔽问题。很多 AI 生成代码也会完全忽略这些边界陷阱。

一、 生活化类比

  • 分布式锁防缓存击穿:食堂窗口排队,只允许一个人进去处理做饭的这个任务,其他人在外面等待,处理完成之后所有人直接吃现成的,避免所有人同时开火。如果做饭耗时超过窗口自动关门时间,门提前打开,多个人同时冲进厨房,就会乱套。
  • GZIP+Base64 缓存:把大件快递打包压缩,为了放进 "只能存文本的箱子",又把二进制转成 Base64 字符串。箱子明明可以直接放包裹本体,多做一层转码,包裹体积反而又变大 1/3,白白浪费空间和打包时间。

二、业务原始实现简述

业务场景:多个请求并发访问刚刚过期的同一份热点大缓存数据。 预期效果:仅 1 个线程执行 SQL 与业务计算构建缓存,其余请求等待缓存生成后直接返回,保护数据库。

核心两段原始逻辑:

  1. 使用setIfAbsent(等价SET key value EX NX)实现分布式锁,控制业务计算并发;获取锁失败,短暂等待,尝试复用别人构建好的缓存。
  2. 业务 JSON 字符串 → GZIP 压缩 → Base64 编码转字符串存入 Redis;读取时 Base64 解码再 GZIP 解压拿到原始 JSON。
  3. 缓存 TTL 增加随机时间抖动,规避缓存雪崩。

原始关键代码片段(有坑):

复制代码
// ❌ 加锁:原子加锁+设置过期时间,但是无看门狗、解锁直接delete风险
Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, lockValue, CACHE_LOCK_SECONDS, TimeUnit.SECONDS);

// ❌ 缓存写入:JSON → GZIP → Base64再存入Redis
private void cacheBizJson(String key, String json) {
    try (ByteArrayOutputStream bos = new ByteArrayOutputStream(4096);
         GZIPOutputStream gzipOut = new GZIPOutputStream(bos)) {
        gzipOut.write(json.getBytes(StandardCharsets.UTF_8));
        gzipOut.finish();
        // GZIP二进制再做Base64编码,额外膨胀约1/3体积
        String data = Base64.getEncoder().encodeToString(bos.toByteArray());
        int timeout = CACHE_SECONDS + ThreadLocalRandom.current().nextInt(CACHE_TTL_JITTER_SECONDS + 1);
        stringRedisTemplate.opsForValue().set(key, data, timeout, TimeUnit.SECONDS);
    } catch (Exception e) {
        log.warn("业务缓存写入失败,key={}", key, e);
    }
}

故障现象

  1. 热点缓存过期瞬间,偶发多线程同时计算同一份业务数据,重复执行 SQL,DB 压力莫名升高;业务计算耗时久的时候概率更高。
  2. Redis 内存占用高于预期,明明做了 GZIP 压缩,内存下降效果不明显,没有达到预期。
  3. 线上版本迭代更新或某些特殊情况下的时候,会随机出现读取缓存报错,部分用户请求失败。
  4. 存在风险:任务执行超时锁自动释放之后,旧线程执行完毕直接删除锁,误删除其他线程当前持有的锁。

坑 1:手写 setIfAbsent 分布式锁的多重隐患

✅优点:setIfAbsent(key,val,expire,unit)底层是SET ... EX NX,加锁和设置过期是原子操作 ,不会出现 "锁加上了,但是忘记设置过期时间导致死锁" 的老问题(规避老版本SETNX + EXPIRE分开执行宕机死锁问题)。

❌现存风险点:

  1. 没有自动续期(看门狗) 业务计算是长耗时任务,如果业务执行时间 > 锁过期时间,锁会提前自动释放。其他线程拿到锁开始执行业务计算,出现多线程重复计算,数据库压力上升。原生setIfAbsent没有续期能力。
  2. 解锁直接 delete,会误删别人的锁 场景:A 线程干活慢任务耗时久,锁超时释放;B 线程成功拿到锁开始工作;这时候 A 线程业务跑完,直接执行delete(lockKey),把 B 线程正在持有的锁删掉,锁彻底失效。

✅修复:删除锁不能直接粗暴删掉,必须用 Lua 脚本,校验锁的唯一标识 value(UUID),只有自己持有的锁才允许删除。

复制代码
// ✅安全解锁Lua脚本
String unlockScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
  1. 锁不可重入 同一个线程嵌套重复获取同一把锁会直接失败;原生实现不支持重入。
  2. Redis 主从切换会有极小概率丢锁 主节点写入锁,还未同步到从节点,主节点宕机,从节点晋升新主节点,锁直接消失,集群哨兵环境存在极小概率并发风险。

💡生产建议:长耗时业务计算场景,尽量不要自己手写锁,优先使用Redisson,自带看门狗自动续期、可重入锁,把底层风险封装好。

坑 2:GZIP+Base64 缓存,做了无用功,白白消耗 CPU 与内存

原始链路:JSON字符串 → GZIP压缩二进制 → Base64编码 → Redis存字符串

❌核心问题:

  1. Base64 带来约 33% 体积膨胀 Redis String 本身支持存储二进制字节数组,完全不需要 Base64 转字符串。GZIP 压缩已经把体积压小,Base64 又把体积放大,压缩收益被大量抵消,同时额外增加编码、解码 CPU 开销。
  2. 不分大小全部强制压缩 小体积 JSON,GZIP 压缩之后体积反而变大;不加阈值全部压缩,白白消耗 CPU。
  3. 缺少压缩标记,灰度发布兼容问题 升级缓存逻辑时,Redis 中同时存在「未压缩旧数据」「GZIP 压缩新数据」。读取时不知道当前 value 是否经过压缩,随机出现解压异常。
  4. 异常捕获粒度太粗 catch(Exception e)捕获全部异常,无法区分 Redis 异常、IO 异常、解压异常,线上排查问题很困难。

✅优化方案:

  1. P0:去掉 Base64,直接存原始 gzip byte \[\] ,使用RedisTemplate<byte[],byte[]>直接写入二进制,省去编解码开销,消除 33% 体积膨胀。
  2. 增加压缩阈值:例如原始字节大于 2KB 才执行 GZIP 压缩;小 JSON 直接存储原始文本。
  3. 增加压缩标记位 :value 头部增加标记,0=原始文本,1=GZIP压缩;读取时根据标记判断要不要解压,解决新旧缓存灰度兼容。
  4. 细分异常类型,增加监控指标:观察统计缓存命中率、压缩耗时、压缩率,方便发现线上异常。

坑 3:分布式锁 + 压缩缓存组合带来的特有问题

分布式锁、缓存压缩分开测试都没问题,组合到业务里,会出现新风险,这也是最容易被忽略的部分。

  1. 锁过期时间 和 缓存过期时间,是两回事,不能混用 锁的过期时间:控制 "最多允许多久完成数据计算"。 缓存过期时间:控制 "这份数据多久之后需要更新"。 二者代表完全不同的含义,不要直接复制同一套时间参数。
  2. 无论缓存读写成功还是失败,锁一定要释放 必须等数据写入缓存之后再释放锁;哪怕写入缓存过程抛出异常,finally也必须解锁。不然锁卡死,后续所有请求全部拿不到锁。
  3. 拿到锁之后,一定要再查一次缓存!非常关键 场景:几十条请求同时过来,缓存已经过期。请求 A 抢到锁,开始处理数据;其他请求排队等待。 在排队等待的这段时间,A 已经处理完成并且写入缓存。 如果后面某个请求抢到锁,不先查缓存,会又重复做一遍完整计算,白白浪费资源。

✅标准安全流程

查询缓存 → 读到数据直接返回 没有读到缓存 → 尝试获取分布式锁

 拿到锁:再次读取一遍缓存 ,如果已经有数据直接返回;没有数据再去计算组装 → 写入缓存 → 释放锁

 拿不到锁:短暂等待,再次尝试读取缓存,复用别人已经生成好的数据

  1. 缓存更新一致性 源业务数据变更,Redis不会主动清理缓存;只能等待 TTL 过期被动更新,要么业务数据变更时主动删除对应缓存 key。

坑 4:业务体验欠缺,需要增加补偿机制

✅优化方案:

  1. 获取锁失败做短时间等待重试,合理应对高并发场景。

    if (!Boolean.TRUE.equals(locked)) {
    // 没拿到锁,短暂休眠,让出CPU
    try {
    TimeUnit.MILLISECONDS.sleep(SLEEP_PERIOD_MS);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    }

  2. 源头数据变更,主动清理对应缓存。

  3. 增加简单监控:锁争抢次数、缓存命中率、压缩耗时,方便感知线上状态。

三、 🧪面试高频考点

Q1:setIfAbsent已经可以原子加锁设置过期,还有什么不足?

A:解决了加锁宕机死锁,但是不会自动续期、不能重入、删除锁容易误删别人锁;集群环境主从切换有丢锁风险,耗时业务不建议手写实现。

Q2:缓存做 GZIP 压缩,为什么不建议再加一层 Base64?

A:Redis 原生支持二进制存储。Base64 会增大数据体积,还额外增加 CPU 编解码消耗,只有存储容器只允许存文本的时候才需要 Base64。

Q3:用锁防缓存高并发击穿,抢到锁之后,为什么还要再查一遍缓存?

A:排队抢锁的间隙,别的请求可能已经把缓存生成完毕。二次读缓存,避免重复执行耗时的查询计算。

✅总结避坑清单

  1. 手写分布式锁坑很多,处理耗时任务优先 Redisson,自动续期解决任务超时锁提前释放。
  2. 删除锁必须校验归属者,使用 Lua 脚本原子执行,不要直接删除 key。
  3. Redis 可以直接存二进制,不要无脑 "压缩 + Base64",避免做无效转换。
  4. 缓存压缩设置大小门槛,打上标记,处理好版本迭代新旧数据兼容。
  5. 使用锁防护高并发,拿到锁务必二次检查缓存。
  6. 锁过期时间、缓存过期时间业务意义不一样,不要直接混用。
  7. 多个组件组合使用,要考虑叠加风险,不能只单独测试每一段代码。

📚系列专栏:Java 后端踩坑实录,持续更新 Java 面试、源码、线上故障复盘。欢迎评论区交流踩坑经历。

相关推荐
低频电磁之道1 小时前
C++ 中有符号与无符号类型混用可能导致的问题
开发语言·c++
凤山老林1 小时前
Spring Boot 集成 Redis ZSet 实现实时排行榜、动态排名、同分加权与多维度榜单实战
spring boot·redis·后端
for_ever_love__2 小时前
LangChain Token 计数与成本控制:先知道钱花在哪
开发语言·langchain
m0_547486662 小时前
《数据结构 (C语言)》期末试卷及答案(10套)2026版
c语言·开发语言·数据结构
顽疲2 小时前
Java vue 养老系统源码详解:入住核算单 live-check 缴费入账与预交金收据实践
java·vue.js·python·java 养老·springboot 养老·vue养老
Sand(ContextGate)2 小时前
从开发者调试工具到消费级 App:一个 AI 电商导购项目的 C 端改造与 6 个连环踩坑实录
开发语言·人工智能·python·ai·langchain
共饮一杯无2 小时前
LiveKit本地录制落盘路径排查与修复实践
java·go·livekit
白杨尚青2 小时前
C++入门篇(十七):priority_queue 完整模拟实现——仿函数、区间建堆与指针特化(附完整代码仓库)
开发语言·c++·stl·仿函数
StevenLdh2 小时前
第八期 · 情绪闯关挑战盒:一个「无惩罚」成就系统的数据建模
java·微信小程序