用了 Caffeine,消息为什么还是被处理了两次?

最近的工作都是在公司的旧项目框架上,去开发新的直播小游戏,过程虽然坎坷(别人的旧项目你们都懂),但总算是顺利上线。然而某一天运营反馈,某玩家发了一条"参与"指令,结果扣了两次钱,效果也叠加了。

这里先普及下直播小游戏的玩法(可能很多人未玩过),就是玩家在真播间,像聊天一样发指定格式的消息(指令),直播的游戏就会有不同的效果。例如本次玩家发的指令是:"参与C300"表示使用300金币参与C玩法。

当平播这边在收到这些弹幕指令后,会转到游戏服务器,经过游戏服处理后,再发给客户端体现。这里也说明一下,很多直播平台或支付中心,在消息推送时都有个特点:同一条消息可能重发。原因很多,失败重发、推送地址检查等,都会导致同一个消息的ID(messageId)被推送多次。针对这种情况,一般后端都会对消息做去重处理。从运营反馈的情况看,大概率问题也是出在这个去重上。

排查开始,先简单看一眼代码,去重逻辑确实写了,用的是 Caffeine。当时第一反应是"应该没问题吧,Caffeine 挺成熟的"。

再检查相关日志。同一个 messageId,两次进入推送流程:

css 复制代码
16:49:44 INFO  pushChat - {"comment":"参与C300","msgId":"7673064607611507746","createTime":1786524582560}
16:49:46 INFO  pushChat - {"comment":"参与C300","msgId":"7673064607611507746","createTime":1786524583645}

两条日志间隔 1.6 秒。如果是正常的去重命中,应该会打一条 WARN:

ini 复制代码
[参与C300] msg=chat-7673064607611507746 has process, ignored

但翻遍日志,这条 WARN 根本没出现。也就是说,第二条消息进来的时候,去重检查返回的是"没处理过"。

奇怪的是,同一时间点其他玩家的消息去重是正常的:

ini 复制代码
16:49:44 WARN  [参与A1200] msg=chat-7673064610798359587 has process, ignored
16:49:46 WARN  [参与A1200] msg=chat-7673064610798359587 has process, ignored

同样是重复消息,为什么有的能过滤、有的过滤不了?再重新检查代码,来事了,Caffeine 是用了,但用法有问题,相关的去重处理是这样的:

java 复制代码
private final Cache<String, String> MSG_CACHE = Caffeine.newBuilder()
        .maximumSize(2048).expireAfterAccess(30, TimeUnit.MINUTES).build();

private boolean isDuplicate(String label, String messageId) {
    try {
        String existed = MSG_CACHE.getIfPresent(messageId);
        if (existed != null) {
            log.warn("[{}] msg={} has process, ignored", label, messageId);
            return true;
        }
    } catch (Exception e) {}
    return false;
}

调用处:

java 复制代码
String cacheKey = "chat-" + chatMsg.getMessageId();
if (isDuplicate(chatMsg.getComment(), cacheKey)) {
    continue;
}
MSG_CACHE.put(cacheKey, "1");  // 写入标记
// ... 后续处理

发现问题没有?查和写是分开两步,getIfPresent 是查询,put 是写入,而且 isDuplicate 后就已经马上 put 了,中间应该不会有问题吧。但调用处实际是一个处理平台推送消息的 Controller 内,这意味着是并发环境下的调用。

所以真实情况下的执行时序会是:

arduino 复制代码
时刻 T1:请求A 进入 isDuplicate → 查缓存 MISS → 返回 false
时刻 T2:请求B 进入 isDuplicate → 查缓存 MISS → 返回 false   ← 缓存里还没写入
时刻 T3:请求A 执行 MSG_CACHE.put → 写入缓存
时刻 T4:请求B 执行 MSG_CACHE.put → 写入缓存(重复写入,但已经晚了)

请求 A 和请求 B 都认为自己没处理过,于是都走完了整个流程。"参与"扣了两次钱。

解决方法其实挺简单的,对于我们的这个情况,Caffeine 已经提供了对应的处理方案:使用 asMap().putIfAbsent。通过 asMap() 视图,返回的是一个 ConcurrentMap。putIfAbsent 是原子操作------返回 null 表示之前没有(首次写入成功),返回非 null 表示已存在(重复,跳过)。具体实现:

java 复制代码
private boolean isDuplicate(String label, String messageId) {
    try {
        String prev = MSG_CACHE.asMap().putIfAbsent(messageId, "1");
        if (prev != null) {
            log.warn("[{}] msg={} has process, ignored", label, messageId);
            return true;
        }
    } catch (Exception e) {
        log.error("isDuplicate error msg={}", messageId, e);
    }
    return false;
}

调用处去掉多余的 put:

java 复制代码
String cacheKey = "chat-" + chatMsg.getMessageId();
if (isDuplicate(chatMsg.getComment(), cacheKey)) {
    continue;
}
// 不需要再 put,isDuplicate 里已经写入了

使用 putIfAbsent 代替原来 getIfPresent + put 的原因:

  • 原子操作:一步完成查和写,没有窗口
  • 返回值即判定:一步到位
  • 性能更好:asMap() 不是数据拷贝,就是内部 Cache 的 ConcurrentMap 视图,putIfAbsent 内部基于 key hash 分区加锁,不同 key 完全无竞争,同 key 串行但只覆盖 CAS 级别的临界区,比两次操作(查+写)还快。

问题是解决了,但我更想讲的是

对于成熟组件(如本次的 Caffeine),很多人觉得:我用了 Caffeine,它内部是线程安全,那肯定没问题的。没错,Caffeine 单个操作是线程安全的。getIfPresent 是线程安全的,put 也是线程安全的。但你的业务逻辑是两个操作组合,组合起来就不安全了。注意我说的这句:

组件的线程安全不等于你业务逻辑的线程安全。

组件自身是线程安全的,但是保存这个组件的空间本身,并不一定线程安全。这种代码 review 的时候很容易放过,逻辑看起来是对的:先查、有就跳过、没有就处理然后写入,但如果把眼光拉出来,放在上一层去看,就能发现问题。

最后想强调的点

第一,公用组件不等于自动正确。 以这次的 Caffeine 为例,"用了 Caffeine" 和 "用对了 Caffeine" 是两回事。这次问题最隐蔽的地方在于,代码看起来是对的,逻辑是完整的,甚至 review 的时候都觉得没问题,但直到日志打脸,才意识到两步操作之间有窗口,执行存在并发。

第二,多实例部署下本地缓存的天花板。 putIfAbsent 只能解决了单进程下的并发。但如果部署了多个实例,本地缓存不共享,同一个 messageId 打到不同实例上,还是会重复处理。这种场景要么上 Redis SETNX,要么在接入层做一致性 hash 让同 messageId 落到同一实例。

最后说一句,公用组件不是万能,往往只能解决一小部分问题。当真正的问题产生时,很多时候真正的原因就藏在你觉得"应该没问题"的地方。

相关推荐
花间相见22 分钟前
【LangChain组件03】—— LangChain Agents 执行与状态:工作流程与状态管理实战
java·前端·langchain
敲代码的嘎仔25 分钟前
28届后端开发-海康威视日常实习一面(已OC)
java·开发语言·后端·面试·海康威视·实习·大厂
程序员黎剑2 小时前
Spring-Bean生命周期-构造器访问Autowired字段为null
java·后端·spring
古法安卓2 小时前
Android-重启流程源码解析
android·java·android studio
清水白石0082 小时前
Python 类型设计深度解析:TypedDict 能否替代 dataclass?从 JSON 数据边界到 API 设计的最佳实践
java·python·json
晊晌_h3 小时前
嵌入式从0到精通——线程
java·开发语言·jvm
一木 之林3 小时前
五、C++ 新特性、关键字与编译原理(进阶)(二)
java·开发语言·c++
洋不写bug3 小时前
绕过权限检查,访问修改私有属性,反射,枚举,lambda
java·枚举·lambda·反射
evans在进步3 小时前
Java 常用设计模式入门:建造者、工厂、单例、外观与代理
java·python·设计模式
lisin-lee-cooper3 小时前
【leetcode658】有序数组找出k个最接近x的数
java·数据结构·算法