Celery 心跳任务内存膨胀排查与修复全记录

Celery 心跳任务内存膨胀排查与修复全记录

一次从 57GB 内存分配告警到根因定位与彻底修复的实战复盘。 本文聚焦问题分析过程解决思路,不包含提交记录。


目录

  1. 背景
  2. 现象与初步判断
  3. 工具选择
  4. [memray attach 排查过程](#memray attach 排查过程 "#4-memray-attach-%E6%8E%92%E6%9F%A5%E8%BF%87%E7%A8%8B")
  5. [memray 关键证据](#memray 关键证据 "#5-memray-%E5%85%B3%E9%94%AE%E8%AF%81%E6%8D%AE")
  6. [定位到 Celery 心跳任务](#定位到 Celery 心跳任务 "#6-%E5%AE%9A%E4%BD%8D%E5%88%B0-celery-%E5%BF%83%E8%B7%B3%E4%BB%BB%E5%8A%A1")
  7. 根因分析
  8. 修复方案
  9. [Liquibase 旧迁移幂等性问题](#Liquibase 旧迁移幂等性问题 "#9-liquibase-%E6%97%A7%E8%BF%81%E7%A7%BB%E5%B9%82%E7%AD%89%E6%80%A7%E9%97%AE%E9%A2%98")
  10. [Review 结果](#Review 结果 "#10-review-%E7%BB%93%E6%9E%9C")
  11. 经验总结
  12. 后续验证建议
  13. 最终结论

1. 背景

线上 Docker 容器中运行的 Celery worker 出现内存持续膨胀现象。容器整体内存占用逐步逼近上限,存在 OOM 风险。

系统架构概要:

  • 普通业务 worker :处理用户提交的文档解析、风险识别等重任务,部署在 TASK_DEFAULT_QUEUE 队列。
  • beat worker(心跳 worker) :独立队列 TASK_BEAT_QUEUE,单并发 -c 1,运行 Celery beat 调度的定时任务,其中核心是 task_status_sync_job(心跳同步任务)。
  • 数据库:PostgreSQL,ORM 使用 SQLAlchemy。
  • 部署方式:Docker 容器 + supervisord 进程管理。

2. 现象与初步判断

2.1 现象

  • 容器内存占用持续上涨,没有回落趋势。
  • 普通业务 worker 和 beat worker 都在同一容器内运行。
  • 通过 docker stats 可以观察到内存稳步增长。
  • 业务流量并不大,按理说不应该消耗这么多内存。

2.2 初步判断

由于业务量不大但内存持续增长,怀疑是:

  1. 内存泄漏:某处对象未释放。
  2. 大结果集加载:某次查询把大量数据全部加载到内存。
  3. 定时任务累积:Celery beat 调度的定时任务每次执行都产生内存增长。

通过 ps -o pid,rss,cmd 对比各进程 RSS,发现 PID 1161 的进程内存占用突出。

2.3 确认进程身份

bash 复制代码
cat /proc/1161/cmdline | tr '\0' ' '

输出(已简化):

text 复制代码
celery -A infrastructure.config.task_config worker --loglevel=INFO -c 1 -Q TASK_BEAT_QUEUE -n beat_worker@...

确认:PID 1161 就是 beat worker,运行的是心跳队列。 这是一个关键线索------心跳任务本应是轻量的,为什么内存占用如此之大?


3. 工具选择

针对 Docker 容器内 Python/Celery 进程的内存分析,有以下候选工具:

工具 适用场景 本次是否适用
docker stats 宏观容器级内存/CPU 监控 适用,用于观察趋势
ps -o rss 进程级 RSS 查看 适用,用于定位进程
/proc/<pid>/cmdline 确认进程启动命令 适用,已用于确认 beat worker
strace -p <pid> 系统调用级追踪 可用但粒度太低
py-spy Python 采样 profiler 可用,但偏 CPU 热点
faulcfhandler 死锁/挂起诊断 不适用于内存问题
tracemalloc Python 内存分配追踪 需要在代码中启用
memray Python 内存 profiler,支持 attach 运行中进程 最终选择

选择 memray 的原因:

  • 支持 memray attach <PID> 在不重启进程的情况下挂载到运行中的 Python 进程。
  • 可以输出分配内存的完整调用栈。
  • 提供 memray stats 子命令快速查看热点。
  • 不需要侵入式修改业务代码。

4. memray attach 排查过程

4.1 第一次尝试:直接 attach

bash 复制代码
memray attach 1161

报错:

text 复制代码
Cannot find a supported lldb or gdb executable and sys.remote_exec is not available.

原因: memray attach 依赖 gdb 或 lldb 来注入到目标进程,或者需要 Python 3.13+ 的 sys.remote_exec。容器内既没有 gdb/lldb,Python 版本也低于 3.13。

解决: 在容器内安装 gdb(或使用已安装 gdb 的镜像)。

4.2 第二次尝试:加 --live

bash 复制代码
memray attach --live -o trace.bin 1161

报错:

text 复制代码
memray: error: unrecognized arguments: --live

原因: --livememray run 的参数,不是 memray attach 的参数。attach 模式下不支持 live TUI。

4.3 第三次尝试:仅 -o 不指定时长

bash 复制代码
memray attach -o trace.bin 1161

进入 TUI 后按 q 退出,发现 trace.bin 文件为空(0 字节)。

原因: memray attach 默认不会自动结束采样,需要:

  • --duration <秒> 指定采样时长,到期自动 flush 并 detach;
  • 或者手动 detach 后才会 flush。

4.4 最终正确命令

bash 复制代码
memray attach -o /tmp/trace.bin -f --duration 600 --follow-fork 1161

参数说明:

  • -o /tmp/trace.bin:输出文件路径。
  • -f:force,覆盖已存在文件。
  • --duration 600:采样 600 秒(10 分钟),覆盖多个心跳周期。
  • --follow-fork:跟踪 fork 出的子进程。

等待 10 分钟后自动 detach 并生成完整的 trace.bin


5. memray 关键证据

bash 复制代码
memray stats /tmp/trace.bin | head -80

关键输出(节选):

text 复制代码
Total memory allocated: 57.444GB
Peak memory usage: 24.735GB

Top allocations by function:
  do_execute                57.163GB   ← SQLAlchemy 执行器
  _populate_full            135176 allocations  ← ORM 对象填充
  fetchall                  ...
  ...

5.1 证据解读

  • do_execute 占用 57.163GB :这是 SQLAlchemy 的 do_execute 方法,说明内存大头来自数据库查询执行
  • _populate_full 135176 次分配 :这是 SQLAlchemy 将查询结果行填充为 ORM 对象的过程,13 万次分配意味着一次性加载了大量行
  • Peak 24.7GB:单次峰值就达到了 24.7GB,远超合理范围。

结论:beat worker 的内存膨胀来自一次(或多次)超大规模的数据库查询,将海量行加载为 ORM 对象。


6. 定位到 Celery 心跳任务

6.1 grep 心跳任务入口

bash 复制代码
grep -rn "task_status_sync_job" --include="*.py"

定位到 app/scheduler/task_status_sync_job.py,这是 Celery beat 调度的心跳任务,soft_time_limit=45 秒。

6.2 深入心跳同步逻辑

心跳任务的核心逻辑在 domain/ability/task_status_sync_ability.py,主要做两件事:

  1. get_pending_and_running_tasks():获取所有 PENDING 和 RUNNING 状态的任务。
  2. get_success_tasks_with_incomplete_progress():获取所有 SUCCESS 状态但 progress 不等于 100 的任务(异常任务补偿)。

然后对这批任务做 Celery 状态同步。

6.3 发现问题

打开 Gateway 实现层 infrastructure/gatewayimpl/task_gateway_impl.py,发现这两个方法复用了业务侧的重查询接口

  • 查询 TaskQueueDO 全量字段(包含大字段如 result Text 字段)。
  • 使用 .all() 一次性加载全部行。
  • 没有时间窗口限制,全表扫描。
  • 部分过滤逻辑在 Python 端完成(先全部加载再 filter),而不是下推到 SQL。

7. 根因分析

综合代码 review 与 memray 证据,确认以下 6 大根因

根因 1:心跳任务复用了重业务查询接口

心跳任务本应只做轻量状态检查,但实际调用的查询方法与业务接口共用,加载了所有字段(包括 result 大字段),导致单次查询就拉取了大量数据。

根因 2:SUCCESS 异常任务在 Python 端过滤

get_success_tasks_with_incomplete_progress() 的逻辑是:先查所有 SUCCESS 任务,再在 Python 端过滤 progress != 100 的。这意味着即使只有几十条异常任务,也要先把全部 SUCCESS 任务加载到内存。

根因 3:缺少时间窗口

两个查询都没有时间窗口限制:

python 复制代码
# 问题代码示意
query.filter(TaskQueueDO.status.in_(['PENDING', 'RUNNING'])).all()

历史数据越多,加载量越大。系统运行时间越长,内存膨胀越严重。

根因 4:重复的 Celery 状态查询

sync_task_status_with_celery_cancel() 内部,对每个任务都单独查询了一次 Celery 状态(AsyncResult.state),产生了大量重复的网络往返和对象分配。

根因 5:加载 result 大字段

TaskQueueDO.resultText 类型字段,存储任务的完整结果 JSON。心跳任务只需要状态和 ID,却加载了这个大字段。

根因 6:缺少 task_queue 表的关键索引

PostgreSQL 侧 task_queue 表缺少以下索引:

  • (status, created_time) 复合索引:PENDING/RUNNING 查询走全表扫描。
  • (status, progress, last_updated_time) 复合索引:SUCCESS 异常查询走全表扫描。
  • (group_id) 索引:按 group 维度查询也走全表扫描。

8. 修复方案

修复一:只给 beat worker 加内存上限(不动普通业务 worker)

用户明确要求:普通队列先不动,只动定时任务的队列

修改 supervisord.conf 中 beat worker 的启动命令:

ini 复制代码
[program:celery_beat_worker]
command=celery -A infrastructure.config.task_config worker \
    --loglevel=INFO \
    -c 1 \
    --max-memory-per-child=1000000 \
    --max-tasks-per-child=30 \
    -Q %(ENV_TASK_BEAT_QUEUE)s \
    -n beat_worker@%%h
  • --max-memory-per-child=1000000:单位 KB,即 1GB。beat worker 单个子进程内存超过 1GB 时自动重启。
  • --max-tasks-per-child=30:每执行 30 个任务后自动重启子进程,防止累积泄漏。
  • -c 1:保持单并发。

修复二:新增心跳专用轻量查询

domain/gateway/task_gateway.py 新增两个抽象方法:

python 复制代码
def find_pending_running_for_heartbeat(self, limit: int = 2000) -> List[HeartbeatTaskInfo]:
    raise NotImplementedError

def find_success_with_incomplete_progress(self, limit: int = 2000) -> List[HeartbeatTaskInfo]:
    raise NotImplementedError

返回轻量 DTO HeartbeatTaskInfo,只包含心跳需要的字段(task_id、status、progress 等),不含 result 大字段。

修复三:PENDING/RUNNING 时间窗口 + ASC 排序

python 复制代码
# infrastructure/gatewayimpl/task_gateway_impl.py
def find_pending_running_for_heartbeat(self, limit=2000):
    cutoff = datetime.now() - timedelta(days=30)
    return (
        self.session.query(TaskQueueDO)
        .filter(
            TaskQueueDO.status.in_(['PENDING', 'RUNNING']),
            TaskQueueDO.created_time >= cutoff,
        )
        .order_by(TaskQueueDO.created_time.asc())
        .limit(limit)
        .with_entities(...)  # 只选需要的列
        .all()
    )

设计考量:

  • 30 天窗口:超过 30 天的 PENDING/RUNNING 基本是僵尸任务,心跳不应继续处理。
  • created_time ASC:先处理最早的,避免因 limit 截断而漏掉最久未处理的任务。

修复四:SUCCESS 下推 SQL + DESC 排序

python 复制代码
def find_success_with_incomplete_progress(self, limit=2000):
    cutoff = datetime.now() - timedelta(days=7)
    return (
        self.session.query(TaskQueueDO)
        .filter(
            TaskQueueDO.status == 'SUCCESS',
            TaskQueueDO.progress != 100,
            TaskQueueDO.last_updated_time >= cutoff,
        )
        .order_by(TaskQueueDO.last_updated_time.desc())
        .limit(limit)
        .with_entities(...)
        .all()
    )

设计考量:

  • progress != 100 下推到 SQL:不再 Python 端过滤,直接在数据库层过滤。
  • 7 天窗口:SUCCESS 异常补偿只需要关注近期任务。
  • last_updated_time DESC:最新异常优先处理。

修复五:心跳入口改用轻量查询

domain/ability/task_status_sync_ability.py

python 复制代码
# 改前
def get_pending_and_running_tasks(self):
    return self.task_gateway.get_tasks_by_status(['PENDING', 'RUNNING'])

# 改后
def get_pending_and_running_tasks(self):
    return self.task_gateway.find_pending_running_for_heartbeat(limit=2000)

同样对 SUCCESS 异常查询也改用轻量方法。内部 sync_task_status_with_celery_cancel() 返回值改为三元组 Tuple[bool, bool, Optional[Dict]],对外门面保持兼容。

修复六:消除重复 Celery 状态查询

原先对每个任务都单独 AsyncResult(task_id).state,改为批量查询后本地缓存,避免 N+1 调用。

修复七:补充 task_queue 索引

新增 Liquibase 迁移 resource/db/release_1_6_0/changelog_1_6_0.xml

xml 复制代码
<changeSet id="add_task_queue_indexes" author="system">
    <!-- 唯一约束 -->
    <addUniqueConstraint tableName="task_queue"
                         columnNames="task_id"
                         constraintName="uk_task_queue_task_id"/>

    <!-- PENDING/RUNNING 心跳查询索引 -->
    <createIndex tableName="task_queue"
                 indexName="idx_task_queue_status_created">
        <column name="status"/>
        <column name="created_time"/>
    </createIndex>

    <!-- SUCCESS 异常补偿查询索引 -->
    <createIndex tableName="task_queue"
                 indexName="idx_task_queue_status_progress_updated">
        <column name="status"/>
        <column name="progress"/>
        <column name="last_updated_time"/>
    </createIndex>

    <!-- group_id 维度查询索引 -->
    <createIndex tableName="task_queue"
                 indexName="idx_task_queue_group_id">
        <column name="group_id"/>
    </createIndex>
</changeSet>

注:索引创建会锁表。由于迁移时服务已停止(停机窗口),锁表不影响业务。


9. Liquibase 旧迁移幂等性问题

9.1 新问题

索引迁移上线后,Docker 容器重启时 Liquibase 报错:

text 复制代码
Unexpected error running Liquibase: Migration failed
ERROR: relation "idx_eval_dataset_file_dataset_id" already exists

9.2 原因

定位到 resource/db/release_1_4_0/changelog_1_4_0.xml,发现旧的 changeset 使用的是:

xml 复制代码
<sql>CREATE INDEX idx_eval_dataset_file_dataset_id ON ...</sql>

CREATE INDEX(不带 IF NOT EXISTS不是幂等的

  • 如果 DATABASECHANGELOG 表中该 changeset 已有记录,Liquibase 会跳过执行。
  • 但如果 DATABASECHANGELOG 被清空(或新环境首次部署时索引已被人工创建),Liquibase 会重新执行,而索引已存在,导致报错。

9.3 修复

将旧迁移中的 CREATE INDEX 改为 CREATE INDEX IF NOT EXISTS

xml 复制代码
<sql>CREATE INDEX IF NOT EXISTS idx_eval_dataset_file_dataset_id ON ...</sql>

必要时可使用 Liquibase 的 clearCheckSums 命令重置 checksum:

bash 复制代码
liquibase clearCheckSums

教训:所有 DDL 迁移脚本必须幂等。


10. Review 结果

对本次全部改动进行 review,确认以下要点:

维度 检查项 结果
兼容性 心跳查询方法对外门面签名是否保持兼容 兼容,返回值未变
影响范围 是否影响普通业务 worker 不影响,未改普通 worker 配置
影响范围 是否影响其他业务查询 不影响,新增方法独立,旧方法未删除
索引 索引是否幂等 幂等,使用 CREATE INDEX IF NOT EXISTS
内存 beat worker 内存上限是否合理 1GB + 30 任务重启,合理
数据正确性 limit=2000 是否会漏任务 不会,配合时间窗口和排序保证覆盖
回滚 改动是否可回滚 可回滚,新旧方法并存

11. 经验总结

11.1 定时任务不是"免费"的

心跳任务看起来轻量,但如果复用了业务重查询接口,每次执行都会把大量数据拉到内存。定时任务的累积效应远比单次业务请求严重------因为它是周期性的、无人值守的。

11.2 轻量任务用轻量查询

设计原则:查询的字段集和数据量应与任务的实际需求匹配 。心跳任务只需要 ID、状态、进度,就不应该加载 result 大字段。

11.3 过滤必须下推到 SQL

Python 端过滤 = 全量加载 + 内存过滤 = 内存灾难。所有过滤条件必须尽可能下推到 SQL 层。

11.4 永远加时间窗口和 limit

任何"查全部"的查询都是潜在的内存炸弹。必须:

  • 加时间窗口(30 天 / 7 天)。
  • 加 limit 兜底(2000 条)。
  • 加排序策略(ASC 先处理最老的,DESC 先处理最新的)。

11.5 memray 是 Python 内存排查的利器

memray attach 可以在不重启进程的情况下定位内存热点,配合 memray stats 可以快速找到分配最多的函数调用栈。使用时注意:

  • 需要 gdb/lldb 或 Python 3.13+。
  • 必须加 --duration,否则不会自动 flush。
  • --live 只在 memray run 模式下可用,attach 模式不支持。

11.6 DDL 迁移必须幂等

CREATE INDEX 必须写成 CREATE INDEX IF NOT EXISTSCREATE TABLE 必须加 IF NOT EXISTS。这不是过度防御,是防止迁移在异常环境下重复执行时炸掉。

11.7 进程隔离的价值

本次 beat worker 和普通业务 worker 是独立队列、独立进程。这意味着:

  • 可以单独给 beat worker 设内存上限,不影响业务 worker。
  • 内存问题的爆炸半径被限制在 beat worker 内。
  • 如果共享一个 worker,排查和修复都会复杂得多。

12. 后续验证建议

12.1 验证查询计划

sql 复制代码
EXPLAIN ANALYZE
SELECT task_id, status, progress
FROM task_queue
WHERE status IN ('PENDING', 'RUNNING')
  AND created_time >= NOW() - INTERVAL '30 days'
ORDER BY created_time ASC
LIMIT 2000;

确认走了 idx_task_queue_status_created 索引而非全表扫描。

12.2 验证内存回归

容器部署后,再次 attach memray 复测:

bash 复制代码
memray attach -o /tmp/trace_after.bin -f --duration 600 --follow-fork <beat_worker_pid>
memray stats /tmp/trace_after.bin | head -80

预期:

  • Total memory allocated 从 57GB 级别降到 MB 级别。
  • do_execute 不再是 top allocation。
  • _populate_full 分配次数大幅下降。

12.3 监控 beat worker 重启频率

通过 Celery 日志观察 --max-tasks-per-child=30 触发的子进程重启频率,确认心跳任务执行频率和内存增长速率是否匹配预期。


13. 最终结论

问题 根因 修复
beat worker 内存膨胀至 57GB 心跳任务复用业务重查询 + 全量加载 + 无时间窗口 + Python 端过滤 + 缺索引 7 大修复(见第 8 节)
Liquibase 迁移报索引已存在 旧 changeset CREATE INDEX 非幂等 改为 CREATE INDEX IF NOT EXISTS

核心教训:定时任务的查询必须与业务查询隔离,做轻量化设计------少字段、窄时间窗口、SQL 端过滤、limit 兜底、配套索引。


本文为问题排查与修复的完整记录,重点在于分析思路和方法论,供后续类似问题参考。

相关推荐
她的男孩1 小时前
Spring Boot 接 Flowable 工作流:用 3 个注解搭一个请假审批流程
java·后端·架构
爱读源码的大都督1 小时前
Claude Code源码分析(三):为什么系统提示词中需要有tools呢?
前端·人工智能·后端
爱勇宝1 小时前
Claude Code 被曝暗藏“隐形检测”代码:封代理不是最可怕的,可怕的是你根本不知道它在干什么
前端·后端·程序员
ITOM运维行者2 小时前
从零搭建企业级服务器监控体系:踩坑实录与架构设计
前端·后端
用户4099322502122 小时前
Vue状态管理入门第四章:组合式store和SSR风险
前端·vue.js·后端
用户34232323763172 小时前
SPI 通信与高速外设驱动详解
后端
魏祖潇2 小时前
SDD 完整指南——Spec 端打底、Story 端交付、留白区
人工智能·后端
feelmylife592 小时前
消息队列可靠投递与幂等消费 -- 从"消息丢了"到"消息别重复"的完整工程实践
后端
雪隐3 小时前
个人电脑玩AI-10让5060 Ti给你打工——部署 Odysseus:终于有个能打的"AI管家"了
人工智能·后端
copyer_xyf3 小时前
FastAPI 如何连接 MySQL
后端·python