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