ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

用了 Caffeine,消息为什么还是被处理了两次? 最近的工作都是在公司的旧项目框架上去开发新的直播小游戏过程虽然坎坷别人的旧项目你们都懂但总算是顺利上线。然而某一天运营反馈某玩家发了一条参与指令结果扣了两次钱效果也叠加了。这里先普及下直播小游戏的玩法可能很多人未玩过就是玩家在真播间像聊天一样发指定格式的消息指令直播的游戏就会有不同的效果。例如本次玩家发的指令是参与C300表示使用300金币参与C玩法。当平播这边在收到这些弹幕指令后会转到游戏服务器经过游戏服处理后再发给客户端体现。这里也说明一下很多直播平台或支付中心在消息推送时都有个特点同一条消息可能重发。原因很多失败重发、推送地址检查等都会导致同一个消息的IDmessageId被推送多次。针对这种情况一般后端都会对消息做去重处理。从运营反馈的情况看大概率问题也是出在这个去重上。排查开始先简单看一眼代码去重逻辑确实写了用的是 Caffeine。当时第一反应是应该没问题吧Caffeine 挺成熟的。再检查相关日志。同一个 messageId两次进入推送流程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[参与C300] msgchat-7673064607611507746 has process, ignored但翻遍日志这条 WARN 根本没出现。也就是说第二条消息进来的时候去重检查返回的是没处理过。奇怪的是同一时间点其他玩家的消息去重是正常的16:49:44 WARN [参与A1200] msgchat-7673064610798359587 has process, ignored 16:49:46 WARN [参与A1200] msgchat-7673064610798359587 has process, ignored同样是重复消息为什么有的能过滤、有的过滤不了再重新检查代码来事了Caffeine 是用了但用法有问题相关的去重处理是这样的privatefinalCacheString,StringMSG_CACHECaffeine.newBuilder().maximumSize(2048).expireAfterAccess(30,TimeUnit.MINUTES).build();privatebooleanisDuplicate(Stringlabel,StringmessageId){try{StringexistedMSG_CACHE.getIfPresent(messageId);if(existed!null){log.warn([{}] msg{} has process, ignored,label,messageId);returntrue;}}catch(Exceptione){}returnfalse;}调用处StringcacheKeychat-chatMsg.getMessageId();if(isDuplicate(chatMsg.getComment(),cacheKey)){continue;}MSG_CACHE.put(cacheKey,1);// 写入标记// ... 后续处理发现问题没有查和写是分开两步getIfPresent是查询put是写入而且isDuplicate后就已经马上put了中间应该不会有问题吧。但调用处实际是一个处理平台推送消息的Controller内这意味着是并发环境下的调用。所以真实情况下的执行时序会是时刻 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 表示已存在重复跳过。具体实现privatebooleanisDuplicate(Stringlabel,StringmessageId){try{StringprevMSG_CACHE.asMap().putIfAbsent(messageId,1);if(prev!null){log.warn([{}] msg{} has process, ignored,label,messageId);returntrue;}}catch(Exceptione){log.error(isDuplicate error msg{},messageId,e);}returnfalse;}调用处去掉多余的 putStringcacheKeychat-chatMsg.getMessageId();if(isDuplicate(chatMsg.getComment(),cacheKey)){continue;}// 不需要再 putisDuplicate 里已经写入了使用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 落到同一实例。最后说一句公用组件不是万能往往只能解决一小部分问题。当真正的问题产生时很多时候真正的原因就藏在你觉得应该没问题的地方。本文首发于【掘金】作者【码路漫漫】
返回列表