凌晨2:18,钉钉炸出灵魂拷问

我猛灌半杯冰美式,盯着屏幕:
数据库查证:用户B头像URL未变
前端Network:返回的base64头像数据确是用户A的
服务日志:无异常堆栈,无SQL报错

最魔幻的是

  • 刷新3次,头像在“用户B原图”“用户A旧图”“空白”间随机切换
  • 重启服务瞬间恢复正常,10分钟后复现
  • 仅发生在用户修改资料后

我后背发凉:这哪是bug,这是缓存成精了啊!


三小时硬核排查(附真实命令)

第一回合:甩锅Redis?

redis-cli> KEYS user:avatar:*  
# 结果:空!项目根本没接Redis缓存!

测试小王弱弱补刀:“哥...你上周说‘简单功能用MyBatis二级缓存就行’..."
我:???(记忆碎片开始闪回)

第二回合:Arthas锁死缓存轨迹

# 监控Mapper方法返回值
watch com.xxx.mapper.UserMapper selectAvatarByUid '{returnObj}' -x 3 -n 5

关键输出

[第1次调用] Avatar{id=1002, url="b_old.jpg"}  ← 用户B的头像  
[第2次调用] Avatar{id=1001, url="a_old.jpg"}  ← 竟是用户A的旧头像!  
[第3次调用] null  

突破口:返回对象的id字段都错乱了!缓存污染实锤!

第三回合:翻出“罪证”XML

<!-- UserMapper.xml -->
<mapper namespace="com.xxx.mapper.UserMapper">
    <cache eviction="LRU" size="1024" readOnly="false"/>
    <select id="selectAvatarByUid" resultType="Avatar">
        SELECT id, url FROM avatar WHERE user_id = #{uid}
    </select>
</mapper>

<!-- ProfileMapper.xml(致命复制粘贴) -->
<mapper namespace="com.xxx.mapper.UserMapper"> <!-- ️ 和上面一模一样! -->
    <cache eviction="LRU" size="512" readOnly="true"/>
    <update id="updateNickname">
        UPDATE user SET nickname=#{name} WHERE id=#{id}
    </update>
</mapper>

瞳孔地震
两个Mapper共用同一个namespace
MyBatis二级缓存以namespace为隔离单位 → 所有操作共享同一块缓存区域 → 头像数据被昵称更新操作污染


深扒MyBatis缓存源码(3.5.13版)

缓存key生成逻辑(CacheKey.java

// 拼接缓存key的核心逻辑
public void update(Object object) {
    if (object != null && object.getClass().isArray()) {
        // 按参数、SQL、offset等生成唯一key
        hashCode = hashCode * 31 + ArrayUtil.hashCode(object);
    }
}

关键真相

  • 缓存key = namespace + sql + params + offset...
  • 但namespace相同时,不同Mapper的SQL会混用同一缓存池
  • ProfileMapper.updateNickname执行时,触发缓存清空(因readOnly=false)
  • 但清空的是整个namespace的缓存 → 头像查询缓存被误删 → 下次查询时,因缓存miss+并发,脏数据混入

为什么重启能暂时恢复?

// CachingExecutor.java
public <E> List<E> query(...) {
    if (ms.getCache() != null) {
        flushCacheIfRequired(ms); // 更新操作会清空整个namespace缓存
        ...
    }
}

重启 → JVM内存清空 → 缓存重建 → 短暂“干净” → 随着操作累积,污染循环开始


️ 三招根治(已上线30天零复发)

方案1:紧急止血(10分钟上线)

<!-- 所有Mapper.xml 删除 <cache> 标签 -->
<!-- 或全局关闭(mybatis-config.xml) -->
<settings>
    <setting name="cacheEnabled" value="false"/>
</settings>

适用场景:分布式环境、数据强一致性要求高、缓存收益低

方案2:规范namespace(治本之策)

<!-- ProfileMapper.xml -->
<mapper namespace="com.xxx.mapper.ProfileMapper"> <!-- 唯一且语义清晰 -->
    <!-- 移除<cache>,交由业务层控制 -->
</mapper>

团队公约

  • namespace = Mapper接口全限定名(IDEA自动生成)
  • 禁止手动修改namespace
  • Code Review必查项:grep "<mapper namespace" *.xml | sort | uniq -d

方案3:用Redis替代(高阶方案)

// 自定义Cache实现,接入Redis
public class RedisCache implements Cache {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private String id;
    
    @Override
    public void putObject(Object key, Object value) {
        // 序列化存入Redis,key=namespace:md5(sql+params)
        redisTemplate.opsForValue().set(buildKey(key), value, 10, TimeUnit.MINUTES);
    }
    // ... 其他方法实现
}

优势

  • 多节点共享缓存
  • 精细化过期策略
  • 避免JVM内存压力

血泪避坑清单(打印贴工位!)

表格

误区真相行动指南
“二级缓存开箱即用”分布式环境必翻车单机只读场景慎用,分布式直接关
“readOnly=true很安全”更新操作仍会清空整个namespace缓存避免在含写操作的Mapper开缓存
“namespace随便起”缓存隔离的唯一依据严格等于Mapper接口全路径
“缓存能提升性能”小数据量场景,序列化开销>收益压测验证:QPS提升<5%?不如关掉
“MyBatis缓存很智能”无分布式锁、无穿透保护高并发场景必接Redis+本地缓存

灵魂三问(上线前必答)
1️⃣ 项目是单机还是集群?→ 集群?二级缓存退退退!
2️⃣ 数据允许短暂不一致吗?→ 用户资料?必须强一致!
3️⃣ 缓存命中率实测多少?→ 用Arthas统计:monitor -c 5 com.xxx.mapper.XxxMapper selectXxx


写在晨光微露时

天快亮时,我给团队Wiki加了一页:

测试小王发来新奶茶:“哥,这次排查笔记能发我学习吗?”
我笑着回:“下次上线前,咱俩一起过缓存设计。”

技术没有“小配置”,只有“大敬畏”。
那些深夜翻源码的狼狈,终会沉淀为代码里的从容。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com