作为一名后端开发,最怕的不是写代码,而是线上突然炸锅 —— 告警群 99+、业务方追着问、老板在群里 @你… 最近我就踩了个巨坑:一段看似平平无奇的 MQ 消费租户菜单更新代码,上线后直接引发并发血案:租户菜单重复创建、部分租户更新完全失效、甚至偶发租户数据串流(A 租户能看到 B 租户的菜单)!
今天就带大家复盘整个排坑过程,把这些藏在代码里的 “隐形炸弹” 揪出来,顺便聊聊 MQ 消费并发场景下的避坑指南~
一、案发现场:MQ 消费的 “诡异现象”
先交代下背景:这段代码是 MQ 消费者的onMessage方法,核心功能是监听 “租户角色菜单更新” 消息,异步更新租户的菜单权限。上线前测试一切正常,结果一上生产,诡异现象接二连三:
- 重复执行:同一租户的菜单更新请求被多次消费,菜单数据重复插入,数据库里一堆重复记录;
- 更新失效:部分租户反馈菜单改了但不生效,查日志发现消息明明消费了,却卡在 Redis 锁判断直接返回;
- 数据串流:最致命的!偶发 A 租户能看到 B 租户的菜单,多租户隔离直接破防,这要是被用户发现,妥妥的生产事故!
先贴出那段 “罪魁祸首” 的核心代码(大家先品品,能看出几个问题?):
java
二、抽丝剥茧:扒开代码里的 “隐形炸弹”
看似简单的几十行代码,藏了 4 个能引爆生产的坑,个个都是 “致命级”:
1. 分布式锁的 “伪实现”—— 没过期时间的 Redis Key 就是定时炸弹
代码里想通过Redis Key=true实现 “防重复消费”,但这波操作纯纯是 “裸奔”:
- 无过期时间:如果程序在
set(key, true)后、finally前崩溃(比如 JVM 宕机、线程被 Kill),这个 Key 会永远留在 Redis 里,后续该租户的所有更新请求都会因为isSync=true直接返回,菜单更新彻底失效; - 非原子性操作:
get(key)+set(key)是两步操作,高并发下多个线程能同时拿到isSync=null,然后都执行set,最终多线程并发处理同一租户的菜单,导致数据重复; - 锁归属权混乱:
finally里直接删 Key,不管这把锁是不是当前线程加的,容易误删其他线程的锁,并发问题雪上加霜。
👉 生动比喻:这就像你给房门装了把锁,但既没配钥匙,也没设自动开锁时间 —— 一旦锁上,这扇门就永远打不开了;更坑的是,多个人同时来开锁,还能一起把门撞开,屋里的东西被翻得乱七八糟。
2. 租户上下文的 “漏网之鱼”—— 线程池复用导致的 “串台”
多租户系统的核心是 “租户上下文隔离”,但代码里只做了setTenantId,却没在finally里清理:
TenantContextHolder.setTenantId(xxx); // 只加不删!
MQ 消费者一般运行在线程池里,线程执行完任务不会销毁,会复用给下一个任务。如果线程 A 处理完租户 1 的请求,上下文没清,线程 A 再处理租户 2 的请求时,就会带着租户 1 的 ID 跑,直接导致数据串流(A 租户看到 B 租户的菜单)。
👉 生动比喻:这就像餐厅服务员用完 A 桌的餐具,没收拾就直接端给 B 桌用 —— 不仅不卫生,还会让 B 桌顾客吃到 A 桌的剩菜,体验直接拉满(负的)。
3. 异常处理的 “摆烂操作”—— 吞掉所有异常 + 日志缺堆栈
代码里catch (Exception e)把所有异常都吞了,还只打印了异常消息,没打印堆栈:
log.error("upd-app-role-menu-topic exception is :{}",e); // 错误示范!
这会导致两个问题:
- 异常被 “吞”:即使核心业务逻辑(比如菜单插入数据库)失败,程序也不会报错,监控系统感知不到,问题藏在暗处;
- 日志 “残废”:只有异常消息(比如
NullPointerException),没有堆栈,根本定位不到是哪一行代码出问题,排障时只能瞎猜。
👉 生动比喻:这就像医生看病只看病人说 “不舒服”,既不问哪里不舒服,也不做检查,还不写病历 —— 等病人病情恶化,连怎么治都不知道。
4. Redis 操作的 “粗心大意”—— 空 Key + 反序列化隐患
代码里拼接 Redis Key 时直接用cacheKey,没做非空校验:
java
运行
"tenant_menu:"+updAppTenantRoleMenuDTO.getCacheKey() // cacheKey可能为null
如果cacheKey是null,最终 Key 会变成tenant_menu:null,不仅不符合 Redis Key 规范,还拿不到任何数据;更坑的是,直接把 Redis 里的 Object 强转成List<AppMenuEntity>,如果redisTemplate没配置 JSON 序列化器,会因为 JDK 序列化问题导致反序列化失败,直接抛异常。
👉 生动比喻:这就像寄快递没写清楚收件地址,还把包裹缠了十几层胶带 —— 快递员找不到人,就算找到人,也打不开包裹。
三、对症下药:把 BUG 按在地上摩擦的优化方案
找到问题后,针对性优化,每一步都直击痛点:
1. 升级分布式锁:用 Redisson 替代手动实现(别自己造轮子!)
手动实现分布式锁容易踩坑,直接用成熟的 Redisson,原子性 + 过期时间 + 锁归属权校验全搞定:
@Resource
private RedissonClient redissonClient;
@SneakyThrows
@Override
public void onMessage(UpdAppTenantRoleMenuDTO updAppTenantRoleMenuDTO) {
String tenantId = updAppTenantRoleMenuDTO.getTenantId();
String lockKey = "upd-app-role-menu-topic:" + tenantId;
RLock lock = redissonClient.getLock(lockKey);
// 5秒内拿不到锁就放弃,锁30秒自动过期(避免死锁)
if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
log.info("租户{}菜单更新任务正在执行,本次请求放弃", tenantId);
return;
}
try {
// 核心业务逻辑(下文继续优化)
} catch (Exception e) {
log.error("租户{}菜单更新异常", tenantId, e); // 打印完整堆栈
throw new BusinessException("租户菜单更新失败", e); // 抛异常触发告警
} finally {
// 只释放当前线程持有的锁,避免误删
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
2. 管好租户上下文:try-finally 必清理(用完即清!)
多租户上下文必须 “加了就清”,哪怕抛异常也要清:
java
运行
try {
// 设置租户上下文
TenantContextHolder.setTenantId(tenantId);
// 核心业务逻辑...
} finally {
// 无论是否异常,都清理上下文!
TenantContextHolder.clear();
// 释放锁等其他操作...
}
3. 规范异常处理:精准捕获 + 完整日志
别再无脑catch (Exception e),精准捕获业务异常 + 打印完整堆栈:
try {
// 核心业务逻辑
} catch (RedisException e) {
log.error("租户{}菜单更新-Redis操作异常", tenantId, e);
throw new BusinessException("Redis操作失败,菜单更新终止", e);
} catch (ServiceException e) {
log.error("租户{}菜单更新-业务服务异常", tenantId, e);
throw new BusinessException("业务服务调用失败,菜单更新终止", e);
} catch (Exception e) {
log.error("租户{}菜单更新-未知异常", tenantId, e);
throw new BusinessException("未知异常,菜单更新终止", e);
}
4. Redis 操作 “加防护”:空值校验 + 正确序列化
先校验 Key 的合法性,再配置 Redis 序列化器(避免反序列化失败): 运行
// 1. 空值校验
String cacheKey = updAppTenantRoleMenuDTO.getCacheKey();
if (StringUtils.isBlank(cacheKey)) {
log.warn("租户{}的cacheKey为空,菜单更新终止", tenantId);
return;
}
String menuRedisKey = "tenant_menu:" + cacheKey;
// 2. 安全获取数据(先确保redisTemplate配置了Jackson序列化)
List<AppMenuEntity> sysMenus = (List<AppMenuEntity>) redisTemplate.opsForValue().get(menuRedisKey);
if (CollectionUtil.isEmpty(sysMenus)) {
log.warn("租户{}的菜单列表为空,更新终止", tenantId);
return;
}
Redis 序列化配置示例(关键!):
java
运行
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 使用Jackson2JsonRedisSerializer序列化
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
// 设置Key和Value的序列化器
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
四、避坑指南:MQ 消费并发场景的 “保命法则”
踩完这些坑,我总结了 4 条 “保命法则”,分享给大家:
- 分布式锁别自己造轮子:Redisson/Redlock 才是 YYDS,手动实现容易漏原子性、过期时间、锁归属权这些关键点;
- 多租户上下文 =“用完即清” :线程池环境下,租户 ID、用户 ID 等上下文信息,必须在 finally 里清理,否则必串流;
- 异常别 “吞”,日志别 “缺” :异常要抛出去(触发监控告警),日志要打印完整堆栈(方便定位问题),别做 “甩手掌柜”;
- Redis 操作先校验:拼接 Key 前先校验非空,序列化配置要到位,避免反序列化失败 / 非法 Key。
五、最后唠两句
这段代码的问题,其实都不是 “高深的技术难题”,而是基础规范没做好 —— 分布式锁的基础、线程安全的基础、异常处理的基础。但恰恰是这些 “基础坑”,在生产环境下会被放大成致命故障。
踩坑不可怕,可怕的是踩了不总结。希望这篇复盘能帮大家少走弯路,也欢迎评论区聊聊你踩过的 MQ 并发坑~
关键点回顾
- 手动实现的 Redis 分布式锁若无过期时间和原子性保障,易引发死锁和重复执行,优先用 Redisson;
- 多租户系统中,租户上下文必须在 finally 块清理,避免线程池复用导致数据串流;
- MQ 消费异常不能 “吞”,日志要打印完整堆栈,关键异常需抛出触发告警;
- Redis 操作前要校验 Key 合法性,配置正确的序列化器避免反序列化失败。