背景:接手维护一个基于Spring Boot的微信小程序后端,核心功能包含自动处理@消息、敏感词过滤、图片压缩上传。某次上线后,系统陆续出现幽灵评论、图片错位、缓存击穿三重诡异现象...
一、午夜警报(问题现象)
第一现场(00:23)
监控系统发出三级告警:
- 自动回复服务CPU占用率达98%
- MinIO存储服务出现10GB异常图片
- 用户投诉"一小时只能评论1次"的限制失效
诡异现象记录:
- 用户A在23:00发布的图片微博,自动回复中出现了用户B的评论截图
- 后台数据库出现大量
created_time
为1970-01-01的僵尸评论 - 缓存服务器
comment:xxx
键值频繁过期
二、福尔摩斯式排查
第一阶段:图片穿越之谜(8小时)
线索追踪:
// 图片处理代码片段
MultipartFile compressImage = ImageProcessor.resizeImageToMaxSize(image,5);
this.sendStatus(access_token, status, compressImage, mode, result -> {
statusUserService.AddStatusUser(openId, "1", id);
});
关键发现:
- 使用
File.createTempFile()
生成临时文件但未及时清理 - 图片压缩线程未关闭导致临时文件堆积(发现/tmp目录有2.3万张图片)
- 雪崩效应:临时文件占满磁盘触发OOM,导致后续请求中
DateTime.now()
获取异常时间戳
验证实验:
// 模拟磁盘写满场景
for(int i=0; i<100000; i++){
File temp = File.createTempFile("crash", ".jpg");
// 不执行delete操作
}
// 观察DateTime.now()输出变为1970年
第二阶段:时间幽灵的诞生(6小时)
异常代码定位:
// 评论服务中的时间处理
public void createComment(String cid, String id, String openid, String comment, DateTime dateTime) {
comments.setCreatedAt(dateTime); // 当dateTime为null时...
this.saveOrUpdate(comments);
}
死亡循环:
- 磁盘写满 → DateTime异常 → dateTime参数传递null
- MyBatis将空DateTime插入数据库 → 默认写入1970-01-01
- 定时任务扫描到"未处理"的1970年评论再次触发处理
灵魂拷问:
- 为何不直接使用
LocalDateTime
? - MyBatis类型处理器是否配置了默认值?
第三阶段:缓存黑洞(4小时)
限流失效分析:
// 原限流逻辑
public boolean canComment(String userId) {
String lastCommentTime = cacheService.get(commentKey);
if(缓存为空){
cacheService.add(commentKey, 时间戳, 1, TimeUnit.HOURS);
}
}
致命缺陷:
- 在并发请求下,多个线程同时判断缓存为空
- 使用
add()
而非setIfAbsent()
造成重复写入 - 缓存过期时间被后续请求不断刷新
压力测试复现:
# 使用JMeter模拟并发
ThreadGroup: 100 users × 50 loops
└─HTTP Request: POST /comment?userId=test001
结果:单用户10秒内成功提交23次评论
三、破局之道(解决方案)
1. 图片处理改造
// 使用try-with-resources自动清理
try(InputStream is = image.getInputStream();
OutputStream os = new FileOutputStream(tempFile)) {
// 增加磁盘空间监控
if(FileUtils.getFreeSpace() < 100_MB){
throw new StorageException("磁盘空间不足");
}
// 使用内存缓冲替代临时文件
byte[] buffer = new byte[8192];
while ((bytesRead = is.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
}
2. 时间处理加固
// 防御性编程改造
public void createComment(..., @NonNull DateTime dateTime) {
if(dateTime == null) {
dateTime = DateTime.now();
}
// 增加时区校验
if(!dateTime.getZone().equals(ZoneId.of("Asia/Shanghai"))){
dateTime = dateTime.withZone(ZoneId.systemDefault());
}
}
3. 限流算法升级
// 改用Redisson分布式锁
public boolean canComment(String userId) {
RLock lock = redissonClient.getLock(COMMENT_LOCK + userId);
try {
lock.lock();
// 原子化操作
Long result = redisTemplate.opsForValue()
.increment(COMMENT_PREFIX + userId);
if(result == 1) {
redisTemplate.expire(key, 1, TimeUnit.HOURS);
return true;
}
return false;
} finally {
lock.unlock();
}
}
四、性能对比
指标 | 改造前 | 改造后 |
---|---|---|
图片处理速度 | 12s/张 | 3s/张 |
评论限流准确率 | 58% | 99.8% |
内存泄漏频率 | 3次/天 | 0次 |
后记:这个案例教会我们三个架构真理:
- 墨菲定律:任何可能出错的地方终将出错
- 蝴蝶效应:一个临时文件可能引发系统雪崩
- 防御性编程:永远不要相信外部传入的参数
(后来在代码历史记录中发现,临时文件处理逻辑是实习生为了赶工期注释掉的...)
可扩展知识点:
- 如果深入分析
SensitiveWordService
,可能发现正则表达式回溯导致的CPU飙升问题 DeferredResult
使用不当可能引发线程上下文丢失- 微博接口调用缺乏重试机制,网络抖动时可能丢失数据