20251007国庆某子平台数据拉取服务-凌晨崩溃复盘
一、现象与时间线
国庆节放假
- • 03:00 左右开始:拉取订单明细量级激增 → 频繁 GC、堆内外内存占用飙升,最终 OOM(“内存溢出”)。
- • 其后:重启并增大 JVM 内存后短暂恢复。
- • 随后告警再次出现:数据库连接被打满,读写线程排队等待,服务健康检查失败 → “服务运行异常”。
- • 临时处置:将连接池从 20 调到 50(“观察一下”),压力有所缓解。
- • 技术人员共识:需要分页/分批、限并发、固定上限、避免全量堆进内存,并对连接占用情况做可观测。
二、What happened
- 1. 数据拉取与入库未设上限/背压:一次性拉取规模偏大,导致对象创建与 JSON 解析峰值堆积,触发 OOM。
- 2. 日志异步入库无界排队:
- •
ThreadPoolUtils使用LinkedBlockingQueue无界队列,30~300线程 + 无限排队 → 大量asynSaveApiLogs任务越积越多,占用堆内存。 - • 每个任务执行一次 DB 写入,放大连接占用与上下文切换。
- •
- 3. DB 连接池容量偏小 + 无全局限流:业务写入(订单/明细)与异步日志写入争抢连接,连接池迅速耗尽,其余查询排队。
- 4. 响应体过大直存:
ApiLogsEntity.resp直接落库原始响应(包含接口请求和响应数据),放大内存与 I/O 压力。
以上四点叠加,形成“高并发无界队列 → 内存占用与 GC 飙升 → 连接耗尽 → 服务异常”的级联效应。
三、Why
- • 缺少背压机制(限并发、有界队列、拒绝策略)。
- • 批处理与分页策略不足(没有固定上限的“桶”)。
- • 连接池参数与峰值负载不匹配,且缺少可观测(看不到“最大同时使用数、等待数、慢 SQL”)。
- • 过度“逐条写”,未利用批量写入与响应体瘦身。
四、How we know
- • 群内“一直在 GC、新生代老年代都是满的”指向对象快速堆积。
- • “数据库连接打满、所有查询在等待”与“调到 50 才缓解”指向连接池饱和。
- • 检查代码,发现日志相关的接口服务很明显的缺陷:
- •
ThreadPoolUtils:new LinkedBlockingQueue<>()(无界)+maxPoolSize=300。 - •
ApiLogsService.asynSaveApiLogs(...)每条调用都入队一次 DB insert(放大并发)。 - • 订单/发票循环内逐条处理与落库,缺少“固定上限批量”。
- •
五、立即救火
-
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. 异步日志“瘦身 + 批量”
- •
resp截断(如前 64KB)+ SHA256 摘要,超限落 OSS/对象存储,仅存指针与哈希。 - • 积攒到 N=200 条再 batch insert(MyBatis-Plus
saveBatch(list, 200)/ MyBatisExecutorType.BATCH)。
- •
-
3. 强制分页 + 固定上限 + 全局限并发
- • 拉取/解析/写库 三段式,每段设置 pageSize(例如 5001000)与并发 Semaphore(如 `816`):
static final Semaphore ORDER_PARALLEL = new Semaphore(12); try { ORDER_PARALLEL.acquire(); // 仅处理本批(固定上限)→ 释放引用 → 进入下一批 } finally { ORDER_PARALLEL.release(); } -
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. JVM 与 OOM 证据保留
- • 固定堆
-Xms = -Xmx(减少扩容抖动),推荐 G1GC; - • 打开
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump,便于二次溯源。
- • 固定堆
-
6. 业务与调度
- • 夜间 03:00 峰值改为分片 + 梯度触发(XXL-Job 分片(多微服务)、错峰、速率上限),避免同一时间蜂拥进入 DB。
六、中期改进
- 1. 可观测性:JVM(堆、GC、线程)、线程池(队列深度、拒绝次数)、DB 连接池(active、waiting)、批处理耗时/吞吐、增加 Prometheus/Grafana 可视化与阈值告警。
- 2. 解析与写库:采用 流式 JSON 解析(Jackson streaming),避免把整批数据驻留内存(Steam思想);对大字段使用 分列存储/压缩。
- 3. 失败重试与幂等:重试采用指数退避;写库幂等(基于业务主键或 sha256)避免重复放大负载(如果数据库支持 写入更新,可采用)。
- 4. 压测与容量管理:建立可复现压测(订单明细/发票场景),标定“队列上限、并发、连接池规模、堆大小”的安全工作点。
七、核心共识点
- • “分页处理就好了 / 每次处理量要有上限” 已调整为为“固定批大小 + 全局并发门闩 + CallerRuns 背压”。
- • “连接数使用情况能看到吗?” 开启 Druid 指标采集(druid线上面板);面板展示每个服务的 maxActive 峰值 / 等待次数 / 慢 SQL。
- • “加大内存只是短期” 临时止血,永久方案是 流控 + 有界队列 + 批处理 + 连接治理。
附:两个最关键的改动示例
1)替换无界线程池
// 原:无界 LinkedBlockingQueue 容易 OOM
new ThreadPoolExecutor(30, 300, 10, 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();
}
结论:
此次故障是“高并发 + 无界排队 + 大对象”与“连接池争抢”叠加导致的典型资源枯竭问题