问题描述
观测数据库增长到约 1 GB 后,GPROXY 启动数秒内即被 Linux OOM killer 终止,且反复重启形成崩溃循环(重启计数达 16)。内存增长与流量无关:几乎没有活跃连接时,匿名 RSS 仍会迅速升至约 950 MB。使用空数据库启动不复现。
复现步骤
- 让观测数据库增长到超过
maxDatabaseSizeMb 的配置值;
- 重启 GPROXY;
- 观察进程 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 膨胀是主因。
代码层面
- 为清理增加显式上限:按时间(如每轮最多 N 秒)或按删除量截断,把大批量清理摊到多轮完成;
- 使删除走
DELETE FROM ... LIMIT(单条语句、数据库侧完成),而非每 id 一条语句的批量事务——既降低数据库负载,也消除高频小对象分配;
- 考虑引入 jemalloc / mimalloc:两者都会在空闲时把内存归还操作系统,对长时间运行且分配模式突发的服务更友好;
- 让清理在内存压力或连接池压力高时退让,避免与主流程互相拖垮;
- 文档中为
maxDatabaseSizeMb 补充「未设置即禁用」,避免读者按「默认 1024」理解。
本报告不含任何 API 密钥、账号标识、服务器 IP 或会话令牌。
问题描述
观测数据库增长到约 1 GB 后,GPROXY 启动数秒内即被 Linux OOM killer 终止,且反复重启形成崩溃循环(重启计数达 16)。内存增长与流量无关:几乎没有活跃连接时,匿名 RSS 仍会迅速升至约 950 MB。使用空数据库启动不复现。
复现步骤
maxDatabaseSizeMb的配置值;期望结果
数据库体积偏大时,启动与裁剪过程应保持有界的内存占用,不应接近数据库本身的大小。理想情况下,GPROXY 能够以有界内存启动并裁剪超限的观测数据。
实际结果
1. 启动后 8 秒内内存升至 931 MiB
采样(每秒一次):
CPU 全程 34–47%(单核饱和),线程数由 1 增至 6 后稳定。
2. 快照:匿名内存占绝对主体
3. 内存映射形态(这是关键)
六个 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. 主机侧
宿主机在 snapshot 时刻已接近耗尽,swap 未被使用。gproxy 单进程即占用约 955 MiB。
5. 崩溃循环
多次内核记录结构一致:
file-rss始终只有 8–520 kB,与「内存来自数据库文件映射」无关,也与 VACUUM 重写文件时的大量文件页特征不符。被 kill 的进程之间,日志中没有任何一条清理相关的 INFO/WARN(正常路径下
maintenance.rs会输出 "request history cleanup completed"),说明进程在完成首批清理并输出日志之前就已死亡。环境
gproxy 4.0.0-devgproxy.service)日志与请求 ID
未取得
x-gproxy-request-id。数据库实测(SQLite):文件 1.1 GB,
page_size=4096,page_count=262828,freelist_count=33223(空闲页占比约 12.6%)。按对象体积排序:
实例设置:
一次启动期间出现连接池耗尽:
补充信息
一、与代码实测相符的部分
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 MBsetting.rs中为Option<i64>且无默认值,文档记载 unset 即禁用。本实例是显式设置为 1024,不是默认值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,仍未定位。 已确认:
但清理任务本身与其它启动期数据库初始化路径都可能贡献这段分配,仅凭现有快照无法区分。需要分配剖析:
heaptrack ./gproxy # 或 valgrind --tool=massif ./gproxy五、建议排查方向
立即可用的缓解(不改代码,可先验证根因)
限制 glibc arena 数量。若内存峰值随之显著下降,即可确认多 arena 膨胀是主因。
代码层面
DELETE FROM ... LIMIT(单条语句、数据库侧完成),而非每 id 一条语句的批量事务——既降低数据库负载,也消除高频小对象分配;maxDatabaseSizeMb补充「未设置即禁用」,避免读者按「默认 1024」理解。本报告不含任何 API 密钥、账号标识、服务器 IP 或会话令牌。