Skip to content

[Bug] 观测数据库较大时启动期堆内存膨胀至 1GB 并 OOM:清理无上限,且逐行批量删除触发分配器多 arena 膨胀 #269

Description

@qyh9527

问题描述

观测数据库增长到约 1 GB 后,GPROXY 启动数秒内即被 Linux OOM killer 终止,且反复重启形成崩溃循环(重启计数达 16)。内存增长与流量无关:几乎没有活跃连接时,匿名 RSS 仍会迅速升至约 950 MB。使用空数据库启动不复现。

复现步骤

  1. 让观测数据库增长到超过 maxDatabaseSizeMb 的配置值;
  2. 重启 GPROXY;
  3. 观察进程 RSS、匿名内存映射形态与内核 OOM 记录。

期望结果

数据库体积偏大时,启动与裁剪过程应保持有界的内存占用,不应接近数据库本身的大小。理想情况下,GPROXY 能够以有界内存启动并裁剪超限的观测数据。

实际结果

1. 启动后 8 秒内内存升至 931 MiB

采样(每秒一次):

07:28:50  RSS=   8.7MiB  VSZ=  84.1MiB  CPU=42.1%  THR=1  CG=  23.2MiB
07:28:51  RSS=  30.7MiB  VSZ=  84.1MiB  CPU=46.5%  THR=1  CG=  43.8MiB
07:28:52  RSS=  64.1MiB  VSZ=  84.1MiB  CPU=47.2%  THR=1  CG=  69.6MiB
07:28:53  RSS=  92.7MiB  VSZ= 654.9MiB  CPU=46.6%  THR=6  CG= 143.7MiB
07:28:54  RSS= 183.4MiB  VSZ= 670.7MiB  CPU=42.8%  THR=6  CG= 437.6MiB
07:28:55  RSS= 393.7MiB  VSZ= 926.7MiB  CPU=37.9%  THR=6  CG= 718.9MiB
07:28:57  RSS= 494.3MiB  VSZ=1054.7MiB  CPU=34.1%  THR=6  CG= 842.3MiB
07:28:58  RSS= 931.8MiB  VSZ=1440.7MiB  CPU=34.6%  THR=6  CG= 993.0MiB

CPU 全程 34–47%(单核饱和),线程数由 1 增至 6 后稳定。

2. 快照:匿名内存占绝对主体

VmRSS:      955748 kB
RssAnon:    953348 kB   <- 99.7%
RssFile:      2400 kB
RssShmem:        0 kB
Anonymous:  953348 kB
AnonHugePages:   0 kB   <- 与 THP 无关
Swap:            0 kB

3. 内存映射形态(这是关键)

total kB         1545620  967452  965080
00007e6e78000000  131072  131072  131072 rw---   [ anon ]
00007e6e70000000  131072  131072  131072 rw---   [ anon ]
00007e6e68000000  131072  131072  131072 rw---   [ anon ]
00007e6e60000000  131072  131072  131072 rw---   [ anon ]
00007e6e58000000  131072  131072  131072 rw---   [ anon ]
00007e6e50000000  131072  131072  131072 rw---   [ anon ]
00007e6e98000000   65536   65536   65536 rw---   [ anon ]
00007e6ea6a00000   32816   32816   32816 r----   [ anon ]
00007e6ea8a0c000   27228   27228   27228 r-x--   [ anon ]
000055555781d000   18616   17872   17872 rw---   [ anon ]
00007e6ea0000000   17072   16960   16960 rw---   [ anon ]
...
00007e6e88000000  262144     288       0 r--s-  gproxy.db

六个 128 MiB 的匿名映射,地址从 0x7e6e50000000 起以 0x08000000(128 MiB)严格等距排列,且 size = RSS = dirty 全部占满。

这一形态符合 glibc 非主 arena 的分配特征:glibc 为缓解锁竞争,会为每个并发分配的线程建立独立 arena,非主 arena 通过 mmap 按 128 MiB 对齐保留,且释放后通常不归还操作系统。观察到的 arena 数量(6)与快照中的线程数(THR=6)一致。

六块 128 MiB 加一块 64 MiB 合计 832 MiB,已占 RSS 的绝大部分;而数据库文件映射 gproxy.db 的 RSS 仅 288 kB。

结论:这不是应用层一次性申请了 1 GB 连续内存,而是多线程高频小对象分配导致分配器 arena 膨胀。

4. 主机侧

Mem:  1.9Gi total, 1.8Gi used, 115Mi free, 138Mi available
Swap: 511Mi total, 0B used

宿主机在 snapshot 时刻已接近耗尽,swap 未被使用。gproxy 单进程即占用约 955 MiB。

5. 崩溃循环

07:10:18  A process of this unit has been killed by the OOM killer
07:10:23  restart counter is at 1
07:10:37  被 KILL(anon-rss:1115608kB)
07:13:09  restart counter is at 3
... 每轮约 20 秒内再次被杀
07:23:00  restart counter is at 16

多次内核记录结构一致:

Out of memory: Killed process 2211315 (gproxy)
  total-vm:1671824kB, anon-rss:1117580kB, file-rss:8kB, shmem-rss:0kB

file-rss 始终只有 8–520 kB,与「内存来自数据库文件映射」无关,也与 VACUUM 重写文件时的大量文件页特征不符。

被 kill 的进程之间,日志中没有任何一条清理相关的 INFO/WARN(正常路径下 maintenance.rs 会输出 "request history cleanup completed"),说明进程在完成首批清理并输出日志之前就已死亡。

环境

  • GPROXY 版本/commit:gproxy 4.0.0-dev
  • 部署方式:原生二进制 + systemd(gproxy.service)
  • 操作系统/架构:Ubuntu Linux
  • 内存分配器:系统默认(glibc)。仓库中未引入 jemalloc / mimalloc
  • 客户端与协议:—
  • Provider 与模型:—
  • 路由方式:—

日志与请求 ID

未取得 x-gproxy-request-id。

数据库实测(SQLite):文件 1.1 GB,page_size=4096,page_count=262828,freelist_count=33223(空闲页占比约 12.6%)。

按对象体积排序:

upstream_events                      144236 页   563.42 MiB
downstream_records                    72015 页   281.31 MiB
sqlite_autoindex_upstream_events_1     7768 页    30.34 MiB
usage_records                          1860 页     7.27 MiB
idx-upstream_events-turn_id            1170 页     4.57 MiB
upstream_records                        846 页     3.30 MiB
(其余索引与配置表合计约 5 MiB)

实例设置:

retention_days|quota_observation_retention_days|max_database_size_mb
7             |90                            |1024

一次启动期间出现连接池耗尽:

WARN gproxy_sdk::sync: configuration revision poll failed
  error=Execution Error: pool timed out while waiting for an open connection
WARN gproxy_app::sync: identity revision poll failed
  error=Execution Error: pool timed out while waiting for an open connection

补充信息

一、与代码实测相符的部分

1. 清理在启动后立即执行。 crates/gproxy/src/runtime.rs 中 last_cleanup 被初始化为 Instant::now() - Duration::from_secs(60),循环第一轮即满足 60 秒条件,不等待任何空闲观察窗口。

2. 清理全程没有内存或时间上限。 crates/gproxy/src/maintenance.rs 中 prune() 按 BATCH = 256 分批取 id 后删除,外层 loop 仅在 count == 0 时退出,未对迭代轮数、总删除量、累计耗时或进程内存设置任何上限。

3. 按大小清理确实会被触发。 实例显式设置了 maxDatabaseSizeMb = 1024,而库体积约 1.1 GB 已超出预算,occupied_bytes > limit 的循环会执行。

4. 删除是逐行语句的批量执行,分配密度很高。 store/repository.rs 中 delete_many 把每个 id 展开成一条 delete_by_id 语句(delete_statement 的实现即 E::delete_by_id(id).build(...)),再交给 atomic_batch_owned 在单个事务内执行。每批 256 条语句会产生大量短生命周期的小对象(语句、绑定值、字符串、行数据)——这正是触发多 arena 膨胀的分配模式。

5. 连接池耗尽有直接证据(见上节日志),与「清理长时间占用数据库连接」一致。

二、对原始报告的更正

原始描述 核实结果
maxDatabaseSizeMb 默认 1024 MB 该字段在 setting.rs 中为 Option<i64> 且无默认值,文档记载 unset 即禁用。本实例是显式设置为 1024,不是默认值
「数据库大小上限」是 OOM 的直接原因 该设置只是让清理开始工作的条件。retention_days = 7 的按龄清理同样在没有上限的情况下运行
清理发生在「启动阶段」 更准确:清理运行在主运行时循环中,从进程启动即开始,此后每分钟一次

三、已排除的假设

1. 「体积缺口来自未列出的表」——不成立。 各表与索引合计与文件体积吻合。

2. 「upstream_events 占 563 MiB 说明级联删除失效」——不成立。 验证查询返回 0 条孤儿行,级联按预期工作;该表每条捕获记录存多个流式分片 / WS 消息,故体积远大于父表。

3. 「VACUUM 重写数据库导致 OOM」——不成立。

  • 快照时 freelist_count = 33223,occupied ≈ 918 MB,仍高于 max_mb = 1024 MiB 对应的 896 MB,VACUUM 分支不会执行;
  • 内核记录 file-rss 仅 8–520 kB,与 VACUUM 的大量文件页特征不符;
  • 快照中 gproxy.db 映射的 RSS 仅 288 kB。

4. 「透明大页(THP)导致的膨胀」——不成立。 AnonHugePages: 0 kB。

四、尚未定位的部分

具体是哪个分配点撑起了这 6 个 arena,仍未定位。 已确认:

  • 内存来自堆上大量小对象的多线程分配(分配器层面),而非单次大块申请、文件映射、THP 或 VACUUM;
  • 它发生在数据库相关流程中(存活时间随库状态变化、伴随连接池耗尽、且死在第一条清理日志之前)。

但清理任务本身与其它启动期数据库初始化路径都可能贡献这段分配,仅凭现有快照无法区分。需要分配剖析:

heaptrack ./gproxy        # 或
valgrind --tool=massif ./gproxy

五、建议排查方向

立即可用的缓解(不改代码,可先验证根因)

# systemd unit
Environment=MALLOC_ARENA_MAX=2

限制 glibc arena 数量。若内存峰值随之显著下降,即可确认多 arena 膨胀是主因。

代码层面

  1. 为清理增加显式上限:按时间(如每轮最多 N 秒)或按删除量截断,把大批量清理摊到多轮完成;
  2. 使删除走 DELETE FROM ... LIMIT(单条语句、数据库侧完成),而非每 id 一条语句的批量事务——既降低数据库负载,也消除高频小对象分配;
  3. 考虑引入 jemalloc / mimalloc:两者都会在空闲时把内存归还操作系统,对长时间运行且分配模式突发的服务更友好;
  4. 让清理在内存压力或连接池压力高时退让,避免与主流程互相拖垮;
  5. 文档中为 maxDatabaseSizeMb 补充「未设置即禁用」,避免读者按「默认 1024」理解。

本报告不含任何 API 密钥、账号标识、服务器 IP 或会话令牌。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions