国庆某子平台数据拉取服务-凌晨崩溃复盘

73 阅读5分钟

20251007国庆某子平台数据拉取服务-凌晨崩溃复盘

一、现象与时间线

国庆节放假

  • • 03:00 左右开始:拉取订单明细量级激增 → 频繁 GC、堆内外内存占用飙升,最终 OOM(“内存溢出”)。
  • • 其后:重启并增大 JVM 内存后短暂恢复。
  • • 随后告警再次出现:数据库连接被打满,读写线程排队等待,服务健康检查失败 → “服务运行异常”。
  • • 临时处置:将连接池从 20 调到 50(“观察一下”),压力有所缓解。
  • • 技术人员共识:需要分页/分批限并发固定上限、避免全量堆进内存,并对连接占用情况做可观测。

二、What happened

  1. 1. 数据拉取与入库未设上限/背压:一次性拉取规模偏大,导致对象创建与 JSON 解析峰值堆积,触发 OOM。
  2. 2. 日志异步入库无界排队
    • ThreadPoolUtils 使用 LinkedBlockingQueue 无界队列30~300 线程 + 无限排队 → 大量 asynSaveApiLogs 任务越积越多,占用堆内存
    • • 每个任务执行一次 DB 写入,放大连接占用上下文切换
  3. 3. DB 连接池容量偏小 + 无全局限流:业务写入(订单/明细)与异步日志写入争抢连接,连接池迅速耗尽,其余查询排队。
  4. 4. 响应体过大直存ApiLogsEntity.resp 直接落库原始响应(包含接口请求和响应数据),放大内存与 I/O 压力。

以上四点叠加,形成“高并发无界队列内存占用与 GC 飙升连接耗尽服务异常”的级联效应。

三、Why

  • • 缺少背压机制(限并发、有界队列、拒绝策略)。
  • 批处理与分页策略不足(没有固定上限的“桶”)。
  • 连接池参数与峰值负载不匹配,且缺少可观测(看不到“最大同时使用数、等待数、慢 SQL”)。
  • • 过度“逐条写”,未利用批量写入响应体瘦身

四、How we know

  • • 群内“一直在 GC、新生代老年代都是满的”指向对象快速堆积。
  • • “数据库连接打满、所有查询在等待”与“调到 50 才缓解”指向连接池饱和。
  • • 检查代码,发现日志相关的接口服务很明显的缺陷:
    • ThreadPoolUtilsnew LinkedBlockingQueue<>()无界)+ maxPoolSize=300
    • ApiLogsService.asynSaveApiLogs(...) 每条调用都入队一次 DB insert(放大并发)。
    • • 订单/发票循环内逐条处理与落库,缺少“固定上限批量”。

五、立即救火

  1. 1. 给线程池上“盖子”(有界队列 + 拒绝策略 + 合理线程数)``` public final class BgExecutor {   private static final int QUEUE_CAP = 5000;                 // 视 QPS 调整   private static final int CORE = Math.max(4, Runtime.getRuntime().availableProcessors());   private static final int MAX  = CORE * 2;   private static final ThreadPoolExecutor EXEC =       new ThreadPoolExecutor(CORE, MAX, 60L, TimeUnit.SECONDS,           new ArrayBlockingQueue<>(QUEUE_CAP),           new ThreadPoolExecutor.CallerRunsPolicy());        // 背压:生产者降速   public static void async(Runnable r){ EXEC.execute(r); } }

  2. 2. 异步日志“瘦身 + 批量”

    • resp 截断(如前 64KB)+ SHA256 摘要,超限落 OSS/对象存储,仅存指针与哈希。
    • • 积攒到 N=200 条再 batch insert(MyBatis-Plus saveBatch(list, 200) / MyBatis ExecutorType.BATCH)。
  3. 3. 强制分页 + 固定上限 + 全局限并发

    • • 拉取/解析/写库 三段式,每段设置 pageSize(例如 5001000)与并发 Semaphore(如 `816`):
    static final Semaphore ORDER_PARALLEL = new Semaphore(12);
    try {
      ORDER_PARALLEL.acquire();
      // 仅处理本批(固定上限)→ 释放引用 → 进入下一批finally {
      ORDER_PARALLEL.release();
    }
    
  4. 4. 连接池配置(Druid 示例):``` spring:   datasource:     druid:       max-active: 50         # 与DB总连接上限协调       min-idle: 5       initial-size: 5       max-wait: 60000       remove-abandoned: true       remove-abandoned-timeout: 120       validation-query: SELECT 1       test-on-borrow: true       filters: stat,wall,slf4j       stat-view-servlet:      # 暴露监控页/指标(或 Micrometer)         enabled: true

    *   • 同步打开 **慢 SQL****Active/Pooling/Wait** 指标采集。
    
  5. 5. JVM 与 OOM 证据保留

    • • 固定堆 -Xms = -Xmx(减少扩容抖动),推荐 G1GC
    • • 打开 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump,便于二次溯源。
  6. 6. 业务与调度

  • • 夜间 03:00 峰值改为分片 + 梯度触发(XXL-Job 分片(多微服务)、错峰、速率上限),避免同一时间蜂拥进入 DB。

六、中期改进

  1. 1. 可观测性:JVM(堆、GC、线程)、线程池(队列深度、拒绝次数)、DB 连接池(active、waiting)、批处理耗时/吞吐、增加 Prometheus/Grafana 可视化与阈值告警。
  2. 2. 解析与写库:采用 流式 JSON 解析(Jackson streaming),避免把整批数据驻留内存(Steam思想);对大字段使用 分列存储/压缩
  3. 3. 失败重试与幂等:重试采用指数退避;写库幂等(基于业务主键或 sha256)避免重复放大负载(如果数据库支持 写入更新,可采用)。
  4. 4. 压测与容量管理:建立可复现压测(订单明细/发票场景),标定“队列上限、并发、连接池规模、堆大小”的安全工作点。

七、核心共识点

  • “分页处理就好了 / 每次处理量要有上限” 已调整为为“固定批大小 + 全局并发门闩 + CallerRuns 背压”。
  • “连接数使用情况能看到吗?” 开启 Druid 指标采集(druid线上面板);面板展示每个服务的 maxActive 峰值 / 等待次数 / 慢 SQL
  • “加大内存只是短期” 临时止血,永久方案是 流控 + 有界队列 + 批处理 + 连接治理

附:两个最关键的改动示例

1)替换无界线程池

// 原:无界 LinkedBlockingQueue 容易 OOM
new ThreadPoolExecutor(3030010, MINUTES, new LinkedBlockingQueue<>());

// 现:有界 + CallerRuns
new ThreadPoolExecutor(
  CORE, MAX, 60, SECONDS,
  new ArrayBlockingQueue<>(5000),
  new ThreadPoolExecutor.CallerRunsPolicy()
);

2)日志入库瘦身与批量

// 构建日志:resp 截断 + sha256
_log.setResp(StringUtils.left(respRaw, 64 * 1024));
_log.setRemark(DigestUtils.sha256Hex(respRaw));

// 批量缓冲到 200 条再写库
logBuffer.add(_log);
if (logBuffer.size() >= 200) {
   apiLogsMapper.batchInsert(new ArrayList<>(logBuffer));
   logBuffer.clear();
}

结论
此次故障是“高并发 + 无界排队 + 大对象”与“连接池争抢”叠加导致的典型资源枯竭问题