Redis 分布式锁 + GZIP 压缩缓存踩坑|高并发缓存真实线上复盘
大家好本篇属于Java 后端踩坑实录来自项目大数据量缓存业务真实开发复盘。大数据缓存系统会遇到典型痛点热点大缓存 key 过期瞬间上百个请求同时涌入如果全部查询数据库执行业务计算直接打垮 DB。 于是我们采用两套方案Redis 分布式锁缓存失效时只放行一个线程执行业务计算构建缓存其余请求等待防护缓存击穿。GZIP 压缩缓存业务返回 JSON 体积大使用 GZIP 压缩降低 Redis 内存占用。看似非常标准的组合方案但在落地时手写分布式锁、压缩存储处理不当引出一连串隐蔽问题。很多 AI 生成代码也会完全忽略这些边界陷阱。一、 生活化类比分布式锁防缓存击穿食堂窗口排队只允许一个人进去处理做饭的这个任务其他人在外面等待处理完成之后所有人直接吃现成的避免所有人同时开火。如果做饭耗时超过窗口自动关门时间门提前打开多个人同时冲进厨房就会乱套。GZIPBase64 缓存把大件快递打包压缩为了放进 “只能存文本的箱子”又把二进制转成 Base64 字符串。箱子明明可以直接放包裹本体多做一层转码包裹体积反而又变大 1/3白白浪费空间和打包时间。二、业务原始实现简述业务场景多个请求并发访问刚刚过期的同一份热点大缓存数据。 预期效果仅 1 个线程执行 SQL 与业务计算构建缓存其余请求等待缓存生成后直接返回保护数据库。核心两段原始逻辑使用setIfAbsent等价SET key value EX NX实现分布式锁控制业务计算并发获取锁失败短暂等待尝试复用别人构建好的缓存。业务 JSON 字符串 → GZIP 压缩 →Base64 编码转字符串存入 Redis读取时 Base64 解码再 GZIP 解压拿到原始 JSON。缓存 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); } }故障现象热点缓存过期瞬间偶发多线程同时计算同一份业务数据重复执行 SQLDB 压力莫名升高业务计算耗时久的时候概率更高。Redis 内存占用高于预期明明做了 GZIP 压缩内存下降效果不明显没有达到预期。线上版本迭代更新或某些特殊情况下的时候会随机出现读取缓存报错部分用户请求失败。存在风险任务执行超时锁自动释放之后旧线程执行完毕直接删除锁误删除其他线程当前持有的锁。坑 1手写 setIfAbsent 分布式锁的多重隐患✅优点setIfAbsent(key,val,expire,unit)底层是SET ... EX NX加锁和设置过期是原子操作不会出现 “锁加上了但是忘记设置过期时间导致死锁” 的老问题规避老版本SETNX EXPIRE分开执行宕机死锁问题。❌现存风险点没有自动续期看门狗业务计算是长耗时任务如果业务执行时间 锁过期时间锁会提前自动释放。其他线程拿到锁开始执行业务计算出现多线程重复计算数据库压力上升。原生setIfAbsent没有续期能力。解锁直接 delete会误删别人的锁场景A 线程干活慢任务耗时久锁超时释放B 线程成功拿到锁开始工作这时候 A 线程业务跑完直接执行delete(lockKey)把 B 线程正在持有的锁删掉锁彻底失效。✅修复删除锁不能直接粗暴删掉必须用 Lua 脚本校验锁的唯一标识 valueUUID只有自己持有的锁才允许删除。// ✅安全解锁Lua脚本 String unlockScript if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end;锁不可重入同一个线程嵌套重复获取同一把锁会直接失败原生实现不支持重入。Redis 主从切换会有极小概率丢锁主节点写入锁还未同步到从节点主节点宕机从节点晋升新主节点锁直接消失集群哨兵环境存在极小概率并发风险。生产建议长耗时业务计算场景尽量不要自己手写锁优先使用Redisson自带看门狗自动续期、可重入锁把底层风险封装好。坑 2GZIPBase64 缓存做了无用功白白消耗 CPU 与内存原始链路JSON字符串 → GZIP压缩二进制 → Base64编码 → Redis存字符串❌核心问题Base64 带来约 33% 体积膨胀Redis String 本身支持存储二进制字节数组完全不需要 Base64 转字符串。GZIP 压缩已经把体积压小Base64 又把体积放大压缩收益被大量抵消同时额外增加编码、解码 CPU 开销。不分大小全部强制压缩小体积 JSONGZIP 压缩之后体积反而变大不加阈值全部压缩白白消耗 CPU。缺少压缩标记灰度发布兼容问题升级缓存逻辑时Redis 中同时存在「未压缩旧数据」「GZIP 压缩新数据」。读取时不知道当前 value 是否经过压缩随机出现解压异常。异常捕获粒度太粗catch(Exception e)捕获全部异常无法区分 Redis 异常、IO 异常、解压异常线上排查问题很困难。✅优化方案P0去掉 Base64直接存原始 gzip byte []使用RedisTemplatebyte[],byte[]直接写入二进制省去编解码开销消除 33% 体积膨胀。增加压缩阈值例如原始字节大于 2KB 才执行 GZIP 压缩小 JSON 直接存储原始文本。增加压缩标记位value 头部增加标记0原始文本1GZIP压缩读取时根据标记判断要不要解压解决新旧缓存灰度兼容。细分异常类型增加监控指标观察统计缓存命中率、压缩耗时、压缩率方便发现线上异常。坑 3分布式锁 压缩缓存组合带来的特有问题分布式锁、缓存压缩分开测试都没问题组合到业务里会出现新风险这也是最容易被忽略的部分。锁过期时间 和 缓存过期时间是两回事不能混用锁的过期时间控制 “最多允许多久完成数据计算”。 缓存过期时间控制 “这份数据多久之后需要更新”。 二者代表完全不同的含义不要直接复制同一套时间参数。无论缓存读写成功还是失败锁一定要释放必须等数据写入缓存之后再释放锁哪怕写入缓存过程抛出异常finally也必须解锁。不然锁卡死后续所有请求全部拿不到锁。拿到锁之后一定要再查一次缓存非常关键场景几十条请求同时过来缓存已经过期。请求 A 抢到锁开始处理数据其他请求排队等待。 在排队等待的这段时间A 已经处理完成并且写入缓存。 如果后面某个请求抢到锁不先查缓存会又重复做一遍完整计算白白浪费资源。✅标准安全流程查询缓存 → 读到数据直接返回 没有读到缓存 → 尝试获取分布式锁拿到锁再次读取一遍缓存如果已经有数据直接返回没有数据再去计算组装 → 写入缓存 → 释放锁拿不到锁短暂等待再次尝试读取缓存复用别人已经生成好的数据缓存更新一致性源业务数据变更Redis不会主动清理缓存只能等待 TTL 过期被动更新要么业务数据变更时主动删除对应缓存 key。坑 4业务体验欠缺需要增加补偿机制✅优化方案获取锁失败做短时间等待重试合理应对高并发场景。if (!Boolean.TRUE.equals(locked)) { // 没拿到锁短暂休眠让出CPU try { TimeUnit.MILLISECONDS.sleep(SLEEP_PERIOD_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }源头数据变更主动清理对应缓存。增加简单监控锁争抢次数、缓存命中率、压缩耗时方便感知线上状态。三、 面试高频考点Q1setIfAbsent已经可以原子加锁设置过期还有什么不足A解决了加锁宕机死锁但是不会自动续期、不能重入、删除锁容易误删别人锁集群环境主从切换有丢锁风险耗时业务不建议手写实现。Q2缓存做 GZIP 压缩为什么不建议再加一层 Base64ARedis 原生支持二进制存储。Base64 会增大数据体积还额外增加 CPU 编解码消耗只有存储容器只允许存文本的时候才需要 Base64。Q3用锁防缓存高并发击穿抢到锁之后为什么还要再查一遍缓存A排队抢锁的间隙别的请求可能已经把缓存生成完毕。二次读缓存避免重复执行耗时的查询计算。✅总结避坑清单手写分布式锁坑很多处理耗时任务优先 Redisson自动续期解决任务超时锁提前释放。删除锁必须校验归属者使用 Lua 脚本原子执行不要直接删除 key。Redis 可以直接存二进制不要无脑 “压缩 Base64”避免做无效转换。缓存压缩设置大小门槛打上标记处理好版本迭代新旧数据兼容。使用锁防护高并发拿到锁务必二次检查缓存。锁过期时间、缓存过期时间业务意义不一样不要直接混用。多个组件组合使用要考虑叠加风险不能只单独测试每一段代码。系列专栏Java 后端踩坑实录持续更新 Java 面试、源码、线上故障复盘。欢迎评论区交流踩坑经历。