Celery 心跳任务内存膨胀排查与修复全记录
一次从 57GB 内存分配告警到根因定位与彻底修复的实战复盘。 本文聚焦问题分析过程与解决思路,不包含提交记录。
目录
- 背景
- 现象与初步判断
- 工具选择
- memray attach 排查过程
- memray 关键证据
- 定位到 Celery 心跳任务
- 根因分析
- 修复方案
- Liquibase 旧迁移幂等性问题
- Review 结果
- 经验总结
- 后续验证建议
- 最终结论
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 初步判断
由于业务量不大但内存持续增长,怀疑是:
- 内存泄漏:某处对象未释放。
- 大结果集加载:某次查询把大量数据全部加载到内存。
- 定时任务累积: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-spy | Python 采样 profiler | 可用,但偏 CPU 热点 |
faulcfhandler | 死锁/挂起诊断 | 不适用于内存问题 |
tracemalloc | Python 内存分配追踪 | 需要在代码中启用 |
memray | Python 内存 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
原因: --live 是 memray 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_full135176 次分配:这是 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,主要做两件事:
get_pending_and_running_tasks():获取所有 PENDING 和 RUNNING 状态的任务。get_success_tasks_with_incomplete_progress():获取所有 SUCCESS 状态但 progress 不等于 100 的任务(异常任务补偿)。
然后对这批任务做 Celery 状态同步。
6.3 发现问题
打开 Gateway 实现层 infrastructure/gatewayimpl/task_gateway_impl.py,发现这两个方法复用了业务侧的重查询接口:
- 查询
TaskQueueDO全量字段(包含大字段如resultText 字段)。 - 使用
.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.result 是 Text 类型字段,存储任务的完整结果 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 EXISTS,CREATE 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 兜底、配套索引。
本文为问题排查与修复的完整记录,重点在于分析思路和方法论,供后续类似问题参考。