面试官:ThreadLocal不清理会导致串号?这3个分布式大坑谁踩谁死!
昨天去面了一家做高并发电商的公司,P7岗。面试官没问那些背背八股文就能过的API,反而盯着 分布式环境下的边缘Case穷追猛打。
特别是ThreadLocal和缓存同步这几个点,要是没实战过,真能被问得当场自闭。
好在我复盘过项目的这些深坑,这波极限对答,建议大家反复研读!

第一回合:ThreadLocal 里的“幽灵”数据
面试官看着我的鉴权代码,眉头一皱:“你用 ThreadLocal 存用户信息,方便是方便,但你想过线程复用的问题吗?”
(心里一惊) 来了,这是在考Tomcat线程池机制!
我立刻坐直:“务必在拦截器的 afterCompletion 方法中强制调用 remove(),这是防止内存泄漏和脏读的最终防线。”
Tomcat 处理请求是基于线程池的。
如果线程 1 处理完用户 A 的请求,没清理 ThreadLocal,紧接着复用去处理用户 B 的请求。
这时候用户 B 就能读到用户 A 的 Session 信息,这就是严重的数据串扰(串号)!
面试官:“只在 postHandle 清理行不行?”
❌ 绝对不行!
如果 Controller 抛异常了,postHandle 根本不会执行,只有 afterCompletion 是类似 finally 块的存在。
🎯 标准代码示范
// 拦截器实现 HandlerInterceptor
public class LoginTicketInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(...) {
// 1. 获取用户信息存入 ThreadLocal
User user = userService.getUser(ticket);
HostHolder.setUser(user);
return true;
}
@Override
public void afterCompletion(...) {
// 🔥 关键点:请求结束必须清理!
// 否则Tomcat线程池复用时,下个请求会读取到脏数据
HostHolder.clear();
}
}
// HostHolder 工具类
public class HostHolder {
private static ThreadLocal<User> users = new ThreadLocal<>();
public static void clear() {
users.remove(); // 防止内存泄漏的核心
}
}
面试官微微点头:“算你基础扎实,那我们聊聊分布式的。”
第二回合:本地缓存怎么这就“裂开”了?
面试官继续追问:“我看你用了 Caffeine 做本地缓存。如果节点 A 更新了数据,节点 B 的缓存还是旧的,用户看到的界面不就乱套了?”
这就问到了分布式缓存的一致性痛点。
我:“利用 Redis 的 Pub/Sub(发布订阅)机制!当某节点修改数据时发布广播,通知集群内所有其他节点。”
单机缓存快是快,但没有上帝视角。
我们要构建一个简单的消息总线,一旦有数据变更,大吼一声“删数据啦”,大家一起动手。
面试官:“具体流程是怎样的?”
💡 实现技巧:广播清除
其他节点监听到消息后,立即清除各自 Caffeine 中的对应 Key,从而保证最终一致性。
// 🎯 更新数据的业务逻辑
public void updatePost(int postId) {
// 1. 更新数据库
postMapper.update(postId);
// 2. 清除Redis(二级缓存)
redisTemplate.delete(getKey(postId));
// 3. 🔥 发送广播通知其他节点清除本地缓存
// Channel: "cache_evict_topic"
redisTemplate.convertAndSend("cache_evict_topic", postId);
}
// 🎯 消息监听器 (运行在所有节点)
public class CacheMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
// 收到通知,解析postId
int postId = Integer.parseInt(message.toString());
// 🧹 清除当前节点的本地缓存
caffeineCache.invalidate(postId);
log.info("节点收到广播,已清除本地缓存: " + postId);
}
}
这种方案比引入 ZooKeeper 轻量得多,对于读多写少的社区场景简直完美。
第三回合:ES挂了,数据就丢了?
面试官:“最后一个问题。Kafka 消费消息去写入 Elasticsearch 建索引,如果 ES 突然挂了,或者网络抖动,你的消息怎么办?”
(推眼镜) 这是考系统容灾和降级思维。
我:“写入侧走死信队列(DLQ)补偿,读取侧实施 MySQL 降级策略,优先保障核心业务可用性。”
绝对不能直接 catch 异常打印个日志就完了,那是对用户数据的不负责。
面试官:“如果 ES 宕机一整天呢?”
🔥 进阶方案:双重保障
- 写不进去? 扔进死信队列,等 ES 恢复了写脚本批量重放。
- 搜不出来? 自动切换回 MySQL 模糊查询(虽然慢点,但比报错强)。
// 🎯 Kafka消费者:写入ES
@KafkaListener(topics = TOPIC_PUBLISH)
public void handlePublishMessage(ConsumerRecord record) {
try {
// 尝试写入ES
elasticService.savePost(post);
} catch (Exception e) {
// ❌ ES挂了,不要无限重试阻塞消费者
log.error("ES写入失败,转入死信队列");
// 🔥 发送到死信Topic,后续人工或脚本补偿
kafkaTemplate.send(TOPIC_DLQ, record.value());
}
}
// 🎯 搜索业务层:降级查询
public Page<Post> searchPosts(String keyword) {
try {
// 1. 优先查ES (高性能)
return elasticService.search(keyword);
} catch (Exception e) {
// 💡 降级策略:ES挂了查DB (保可用)
log.warn("ES服务不可用,降级为数据库模糊查询");
return postMapper.selectByTitleLike("%" + keyword + "%");
}
}
面试官:“可以,既考虑了数据不丢失,又考虑了用户体验。明天来谈薪资吧。”
📚 知识点复盘

这场面试其实就盯着**“不一致”和“不可靠”**这两个点。
大家在做项目时,千万别只顾着堆中间件,要多想想这些极端情况:
- 📚 ThreadLocal:一定要在
afterCompletion里remove(),否则 Tomcat 线程池会教你做人。 - 📚 本地缓存同步:Redis
Pub/Sub是最轻量的集群通知方案,别搞太复杂。 - 📚 ES可靠性:写入靠 死信队列 兜底,读取靠 DB降级 续命。
💡 最后一句
技术没有完美的架构,只有最适合业务的权衡。能把这些“坑”填平,你就是资深开发!
兄弟们,这波干货怎么样?评论区懂的打个 666! 👇
更多推荐
所有评论(0)