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

0 阅读13分钟

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

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


目录

  1. 背景
  2. 现象与初步判断
  3. 工具选择
  4. memray attach 排查过程
  5. memray 关键证据
  6. 定位到 Celery 心跳任务
  7. 根因分析
  8. 修复方案
  9. Liquibase 旧迁移幂等性问题
  10. Review 结果
  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 确认进程身份

cat /proc/1161/cmdline | tr '\0' ' '

输出(已简化):

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-spyPython 采样 profiler可用,但偏 CPU 热点
faulcfhandler死锁/挂起诊断不适用于内存问题
tracemallocPython 内存分配追踪需要在代码中启用
memrayPython 内存 profiler,支持 attach 运行中进程最终选择

选择 memray 的原因:

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

4. memray attach 排查过程

4.1 第一次尝试:直接 attach

memray attach 1161

报错:

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

memray attach --live -o trace.bin 1161

报错:

memray: error: unrecognized arguments: --live

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

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

memray attach -o trace.bin 1161

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

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

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

4.4 最终正确命令

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 关键证据

memray stats /tmp/trace.bin | head -80

关键输出(节选):

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 心跳任务入口

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:缺少时间窗口

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

# 问题代码示意
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 的启动命令:

[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 新增两个抽象方法:

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 排序

# 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 排序

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

# 改前
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

<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 报错:

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 使用的是:

<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

<sql>CREATE INDEX IF NOT EXISTS idx_eval_dataset_file_dataset_id ON ...</sql>

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

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 验证查询计划

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 复测:

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 兜底、配套索引。


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