最近的工作都是在公司的旧项目框架上,去开发新的直播小游戏,过程虽然坎坷(别人的旧项目你们都懂),但总算是顺利上线。然而某一天运营反馈,某玩家发了一条"参与"指令,结果扣了两次钱,效果也叠加了。
这里先普及下直播小游戏的玩法(可能很多人未玩过),就是玩家在真播间,像聊天一样发指定格式的消息(指令),直播的游戏就会有不同的效果。例如本次玩家发的指令是:"参与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 落到同一实例。
最后说一句,公用组件不是万能,往往只能解决一小部分问题。当真正的问题产生时,很多时候真正的原因就藏在你觉得"应该没问题"的地方。