背景:接手维护一个基于Spring Boot的微信小程序后端,核心功能包含自动处理@消息、敏感词过滤、图片压缩上传。某次上线后,系统陆续出现幽灵评论图片错位缓存击穿三重诡异现象...


一、午夜警报(问题现象)

第一现场(00:23)
监控系统发出三级告警:

  • 自动回复服务CPU占用率达98%
  • MinIO存储服务出现10GB异常图片
  • 用户投诉"一小时只能评论1次"的限制失效

诡异现象记录

  1. 用户A在23:00发布的图片微博,自动回复中出现了用户B的评论截图
  2. 后台数据库出现大量created_time为1970-01-01的僵尸评论
  3. 缓存服务器comment:xxx键值频繁过期

二、福尔摩斯式排查

第一阶段:图片穿越之谜(8小时)

线索追踪

// 图片处理代码片段
MultipartFile compressImage = ImageProcessor.resizeImageToMaxSize(image,5);
this.sendStatus(access_token, status, compressImage, mode, result -> {
    statusUserService.AddStatusUser(openId, "1", id); 
});

关键发现

  1. 使用File.createTempFile()生成临时文件但未及时清理
  2. 图片压缩线程未关闭导致临时文件堆积(发现/tmp目录有2.3万张图片)
  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);
}

死亡循环

  1. 磁盘写满 → DateTime异常 → dateTime参数传递null
  2. MyBatis将空DateTime插入数据库 → 默认写入1970-01-01
  3. 定时任务扫描到"未处理"的1970年评论再次触发处理

灵魂拷问

  • 为何不直接使用LocalDateTime
  • MyBatis类型处理器是否配置了默认值?

第三阶段:缓存黑洞(4小时)

限流失效分析

// 原限流逻辑
public boolean canComment(String userId) {
    String lastCommentTime = cacheService.get(commentKey);
    if(缓存为空){
        cacheService.add(commentKey, 时间戳, 1, TimeUnit.HOURS);
    }
}

致命缺陷

  1. 在并发请求下,多个线程同时判断缓存为空
  2. 使用add()而非setIfAbsent()造成重复写入
  3. 缓存过期时间被后续请求不断刷新

压力测试复现

# 使用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次

后记:这个案例教会我们三个架构真理:

  1. 墨菲定律:任何可能出错的地方终将出错
  2. 蝴蝶效应:一个临时文件可能引发系统雪崩
  3. 防御性编程:永远不要相信外部传入的参数

(后来在代码历史记录中发现,临时文件处理逻辑是实习生为了赶工期注释掉的...)


可扩展知识点

  1. 如果深入分析SensitiveWordService,可能发现正则表达式回溯导致的CPU飙升问题
  2. DeferredResult使用不当可能引发线程上下文丢失
  3. 微博接口调用缺乏重试机制,网络抖动时可能丢失数据
最后修改:2025 年 04 月 13 日
如果觉得我的文章对你有用,请随意赞赏