别再用 try-catch 吞掉 Redis 的异常了:从一段促销代码聊起

30 阅读10分钟

栏目:老炮踩坑录 · 翻车现场

项目:商品促销(企业真实项目)

编号:PROMO-F03

素材:PromoCacheServiceImpl.java L556-560、L998-1008

👋 欢迎阅读

promo-01.jpg 🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:Redis 更新失败

这条坑是我在读促销缓存代码的时候发现的,没有线上事故,没有报警,没有任何动静。它就安安静静地躺在一个 private 方法的 catch 块里,只要 Redis 出现一次抖动,问题就会暴露。

方法叫 refreshPromotionGoods,功能一句话总结:把某个促销活动的参与商品列表全量刷进 Redis。营销价的命中、价签的计算,都依赖这份缓存。

现场

先看方法本体:

/**
 * 全量覆盖更新Redis中的数据(存储所有数据)
 */
private void refreshPromotionGoods(List<Object> goodsList, Long promotionId) {
    try {
        String redisKey = RedisKeysConstant.getSkuIncludeList(promotionId);
        // 1. 删除旧数据
        redisCacheManager.delete(redisKey);
        // 2. 存储所有数据到List
        redisCacheManager.opsForList().rightPushAll(redisKey, goodsList);
    } catch (Exception e) {
        log.error("营销活动参与商品Redis数据更新失败", Throwables.getStackTraceAsString(e));
    }
}

try 里两步:删旧 key,压新列表。catch 里一行:log.error。

没有重试,没有告警,没有兜底标记,没有把异常往上抛。日志打完,方法正常返回,调用方拿到的就是执行"成功"。

如果这个 catch 只是孤立存在,我可能就扫一眼放过去了。真正让我停下来的是它的调用点:

// 满件数促销缓存指定件数
if (generate && !CollUtil.isEmpty(specNumIncludeList)) {
    redisCacheManager.opsForValue().set(RedisKeysConstant.getSpecNum(promotionId), ...);
} else {
    redisCacheManager.delete(RedisKeysConstant.getSpecNum(promotionId));
}
if (generate && !CollUtil.isEmpty(promotionJoinGoodsCacheList)) {
    refreshPromotionGoods(promotionJoinGoodsCacheList, promotionId);
} else {
    redisCacheManager.delete(RedisKeysConstant.getSkuIncludeList(promotionId));
}

同一个代码块,四步 Redis 操作:

  1. L552 set 满件数缓存——没套 try-catch,失败会抛出来
  2. L554 delete 满件数缓存——没套 try-catch,失败会抛出来
  3. L557 refreshPromotionGoods 参与商品缓存——套了 try-catch,失败被吞
  4. L559 delete 参与商品缓存——没套 try-catch,失败会抛出来

四步里只有第三步被特殊照顾。

写代码的人对这一步有过担心,但选的处理方式是最省事的那种。担心的理由我能猜到——参与商品列表数据量大,rightPushAll 比单 key set 容易出问题。可担心完之后,他给的就一行日志。

这四步在业务上是同一个语义:一次促销缓存的整体刷新。要么都成,要么都不成。现在中间那步被允许失败,前后三步照常跑,缓存就处在半新半旧的状态——满件数缓存是新的,参与商品缓存还是上一轮的。

失败之后,问题是怎么一步步扩大的

这条链路得说清楚,不然体会不到那行 log.error 有多重要。

促销项目里,sku_include_list 这份缓存是营销价判定的核心输入。一个商品进店,要不要走促销价,先查这份缓存里有没有它。

价签服务(promotion-price-tag)依赖这份缓存做门店价签的批量重算。促销一改动,价签服务收到 MQ 通知,按缓存里的参与商品清单逐个重算门店价格,刷新到电子价签。

现在代入失败场景:

某次促销缓存重建,refreshPromotionGoods 执行时 Redis 抖动,delete 成功,rightPushAll 抛了异常。catch 吞掉,方法返回。

此刻 Redis 里的状态:这个 promotionId 对应的 sku_include_list 是空的(delete 已经执行了),但满件数缓存是新的,其他维度缓存也是新的。

价签服务拿到的清单是空的,认为这个促销没有参与商品,跳过重算。门店价签维持上一轮的价格。

数据库里促销明明生效着,运营后台看着也一切正常。直到顾客在门店结账,发现货架价签和收银价对不上,客诉进来。

排查的人第一反应是查价签服务,价签服务日志干净。查促销配置,配置正确。查数据库,数据没问题。最后得有人想起来翻 application log,在几千行 INFO 里找到那一行 ERROR,时间戳对上的那一刻,才能拼出整条链。

这个排查路径有多长,干过运维的人都清楚。

为什么只记日志

这行 catch 不可能是疏忽。private 方法、显式 try-catch、log.error 带堆栈,写得规规矩矩。作者知道这里会失败,他只是选择了一种最轻的处理。

我能想到的动机有三个。

动机一:Redis 很稳的假设

写这段代码的时候,Redis 在团队心智里大概率是个"不会挂"的组件。挂是小概率事件,为小概率事件写一套重试机制,不划算。一行日志,出了事再说。

这个假设在大多数时候成立。Redis 确实稳,一年也抖不了几次。但缓存重建这种写操作,失败一次就够出一次客诉。概率低不等于代价低。

动机二:不想阻塞主流程

refreshPromotionGoods 跑在缓存重建的主链路上,调用它的方法还管着其他几个维度的缓存刷新。如果这一步抛出异常,整个重建流程中断,后面几个维度的缓存都没法刷。

作者的取舍可能是:宁可这一步失败,也不要拖累整体。日志记下来,事后补偿。

这个取舍本身有合理性,问题在于"事后补偿"这件事根本没建。没有定时对账任务,没有失败 key 的死信表,没有人工介入的入口。日志打完,这件事就结束了。

动机三:缺一条团队共识

最根本的原因,我觉得是团队里没有把"缓存写失败"定义成数据事故。

数据库写失败,所有人都会紧张,因为那是数据源。缓存写失败,很多人的直觉是"缓存嘛,下次重建就好了"。但在这个系统里,价签服务是把缓存当数据源用的——它不去查数据库,它信缓存。

缓存是不是数据源,看下游怎么用。下游把它当数据源,它的写失败就是数据事故。

这种写法在项目里的分布

我顺手在项目里 grep 了一下 catch (Exception,看同类姿势还有多少。

PriceTagTask.java 一个类里就有三处,套路一模一样:

} catch (Exception e) {
    log.error("PriceTagTask.calculatePriceTag batchId={} tenantId 【{}】 Exception 【{}】", batchId, TenantContext.get(), Throwables.getStackTraceAsString(e));
} finally {
    if (RedisLockUtils.isLock(key)) {
        RedisLockUtils.unlock(key);
        log.info("PriceTagTask.calculatePriceTag batchId={} tenantId 【{}】,锁释放成功", batchId, TenantContext.get());
    }
}

L62、L103、L141、L178,价签计算的四个入口,全是 catch-记日志-finally 放锁。计算失败这件事,除了日志,没有任何出口。

价签批量任务失败一次,意味着这一批门店的价签没刷新。批任务一般凌晨跑,跑完没人看日志,等门店开门营业,价签就是错的。

有意思的是对照组。同一个 PromoCacheServiceImpl.java 文件里,其他几十处 Redis 操作都没套 try-catch。redisCacheManager.opsForValue().set(...)、redisCacheManager.delete(...)、opsForList(),失败就抛,由上层兜底。

说明团队默认姿势是让异常抛出来。只在少数几个"看起来可能失败"的点,作者单独加了 catch。加的时候大概觉得是防御性编程,实际效果是制造了几个静默失败点。

防御性编程四个字,害过不少代码。

自查打卡:在你的项目里全局搜一下 catch (Exception e)。 看看里面是不是只有一行 log.error 或者 e.printStackTrace(),连个重试和告警都没有? 打在评论区,我看看谁的“静默雷”最多。

怎么办

按改动成本从低到高,我给三档方案。

短期:把静默失败变成有声失败

catch 块保留,但加两件事:

} catch (Exception e) {
    log.error("营销活动参与商品Redis数据更新失败 promotionId={}", promotionId, e);
    sysAlarmRobot.asyncSysAlarm("促销参与商品缓存更新失败 promotionId=" + promotionId);
    failedCacheKeys.record(RedisKeysConstant.getSkuIncludeList(promotionId));
}

这个项目里本来就有 sysAlarmRobot 在发大 key 告警(L987 那段),告警通道是现成的,接上就是。

再加一个失败 key 的记录表,把没写进去的 redisKey 存下来,给补偿任务用。这一步做完,失败至少是有痕迹的,排查的人不用再去翻几千行日志。

中期:缓存写操作走异步重试

refreshPromotionGoods 这种全量覆盖写,天然适合做异步。失败时不阻塞主流程,把 key 和数据扔到 MQ,由消费者负责重试,重试到死信再告警。

} catch (Exception e) {
    log.error("营销活动参与商品Redis数据更新失败 promotionId={}", promotionId, e);
    cacheRetryProducer.send(new CacheRetryMessage(redisKey, goodsList, 0));
}

消费者侧做指数退避,三次失败落死信表,触发告警。这样"不想阻塞主流程"的初衷保住了,"失败要有补偿"的缺口也补上了。

有个细节我没完全确认:这里建议用 MQ 异步重试,但如果同一个促销在短时间内被改了两次,两次重试消息到了 MQ,怎么保证最终写入 Redis 的顺序?是用版本号还是时间戳卡?这块我只在简单的重试场景跑过,复杂并发下没试过,有踩过坑的兄弟指点一下。

长期:缓存即数据副本,纳入数据质量监控

档期内促销的参与商品清单,数据库里有,Redis 里也有一份。两份数据的对账应该是个常规动作——定时任务比对两边的差异,超过阈值就告警。

这件事听起来重,但在促销这种对价格敏感的场景,它是兜底。前面两层都漏了,至少对账能在天亮之前把问题捞出来,赶上门店开门之前修。

写在后面

我又回去看了一眼 L1006 那行日志。

log.error("营销活动参与商品Redis数据更新失败", Throwables.getStackTraceAsString(e));

连 promotionId 都没打。真出事那天,排查的人得拿着堆栈时间戳去反查是哪个促销,再手工对齐门店和商品。

一行日志能做到的事,作者都做到了。一行日志做不到的事,这段代码一件也没做。

缓存写失败这件事,从它发生的那一刻起,就已经是数据漂移的起点。后面的问题,都是从这个起点一步步扩大的。

最后问一句:写代码时遇到 catch,你是坚决不吞异常、必须往上抛的“暴躁老哥”,还是喜欢自己内部消化、记个日志就算了的“防御型选手”? 有没有因为某个不起眼的 catch,导致线上排查查了三天三夜的惨痛经历?评论区聊聊,我看看有多少人吃过“静默失败”的亏。


下期预告:《改一条促销,价签全城重算:一次配置引发的雪崩半径》

刚才是缓存没写进去,下一篇讲缓存写进去了引发的灾难。业务人员在后台点了一下“保存”,一条 MQ 广播飞向了全城几百家门店。如果 MQ 发生堆积,或者价签服务扛不住,这背后到底是谁的锅?

下期我们去看看,促销异动是怎么顺着链路一路传导到电子价签的。

如果本文对你有帮助,欢迎:

👍点赞 | ⭐收藏 | 👤关注 | 💬留言

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。


关联阅读:

  • 《单据转换那段代码,为什么自己管事务》——同样是手工八股,同样是异常路径靠自觉
  • 《改一条促销,价签全城重算》——这条链路的另一端,促销异动怎么传到价签