Firefox 每晚吃掉 30 GB 内存,凶手是我迁进去的 Cookie

一次从「Firefox 又漏内存了」查到「是我往数据库里写进了一个隐含假设之外」的排障。
先给后来搜到这里的人结论: 如果 Firefox 在每日空闲维护时突然占用几十 GB 内存,about:memory 指向主进程的 shared JSM global、Map、XPCWrappedNative_NoHelper 和 xpconnect/scopes,而你曾直接导入或修改过 cookies.sqlite,请检查 moz_cookies.creationTime 是否有至少一整批的重复值。我们这次有 959 条 Cookie 共用一个微秒时间戳,而清理任务默认每批只取 100 条。
这不是所有 Firefox 内存问题的通解。本文核对的是 macOS 上 Firefox 156.0.1 的清理实现;其他版本请以对应源码为准。
English search summary: Firefox PurgeTrackerService can fail to advance its cookie-purge cursor when a full batch shares the same creationTime. getCookiesSince() includes the cursor boundary (>=), while the next cursor is the last cookie’s creation time. Direct SQLite imports can introduce large timestamp collisions. The resulting recursive batch processing matches our observed main-process memory growth. Cookie timestamp repair and a pagination reproduction succeeded; a subsequent natural idle-daily run remained to be verified at publication.
1. 不是一天慢慢涨,而是每天准点爆⌗
这台机器只有 16 GB 内存。此前 Firefox 主进程的 physical footprint 一度达到约 57 GB,系统大量换页,浏览器自动化连接也跟着不稳定。后台采样和现场归档一直在积累,但「每天抓一份,然后重启」显然不算解决问题。
把时间序列放在一起后,形状非常明确:连续多天,Firefox 在凌晨约 00:33 前还只有 1–3 GB footprint,几分钟后就到了 20 多 GB。
其中一个采样窗口是:
| 本地时间 | 主进程 physical footprint | 主进程 RSS |
|---|---|---|
| 00:32:38 | 1.40 GB | 0.55 GB |
| 00:37:38 | 22.02 GB | 2.98 GB |
这里的 GB 沿用采样程序按 1024³ 换算的口径。RSS 和 physical footprint 不是同一个指标,也不能把报告中的各种分项直接相加。
这不是「开了太多网页」最典型的形状,而是一个固定周期触发的工作,把内存快速推高。
内存报告进一步缩小了范围:
| 报告路径或对象类别 | 一个现场中的量级 |
|---|---|
主进程 heap/committed/allocated | 约 25 GB |
explicit/xpconnect/scopes | 约 5.2 GB |
shared JSM global / class(Map) / objects/non-heap/misc | 约 2.0 GB |
shared JSM global / class(XPCWrappedNative_NoHelper) / objects/gc-heap | 约 1.65 GB |
这些指标有重叠,表格只是定位线索,不是内存账单。
扩展自己的窗口堆都不大。不过,扩展堆小不能排除扩展触发父进程泄漏。这一点很容易误判:对象在哪里占内存,不等于是谁触发了它。
2. 游标为什么停在一个月前⌗
Firefox 的 idle.lastDailyNotification 时间落在内存跳变窗口内。沿 idle-daily 查下去,找到了 PurgeTrackerService.sys.mjs:它会分批检查 Cookie 对应的主体,清理符合条件的跟踪站点数据。
另一个偏好值尤其扎眼:
privacy.purge_trackers.date_in_cookie_database
它是 Cookie 清理的进度游标,却停留在一个多月前的创建时间。
对 Cookie 数据库做只读分析,发现:
- 总共 1633 条记录;
- 其中 959 条
creationTime完全相同; - 这个重复值与清理游标逐位相同;
- 时间正好对应此前一次 Brave → Firefox 的直接写库迁移。
那次迁移是我做的。登录态迁过来了,行数也对,但大量记录共用了迁移时的一个创建时间。我们当时验证了「写入成功」,没有验证 Firefox 在这个字段上的隐藏约束。
诊断这类数据不需要读取 Cookie 值。在浏览器退出后,或一致性备份上运行下面的只读 SQL 就足够:
SELECT creationTime, COUNT(*) AS n
FROM moz_cookies
GROUP BY creationTime
HAVING COUNT(*) >= 100
ORDER BY n DESC;
100 是本文分析版本的默认批大小;如果你改过 privacy.purge_trackers.max_purge_count,需要结合实际值判断。命中只说明具备触发条件,还要对照游标、维护时间和源码,不能看到重复就宣布破案。
3. 两段各自看着没问题的代码,拼成了死循环⌗
下面是安装版本 JS 实现的关键结构,省略了分类、权限检查和清理逻辑:
let saved_date = Services.prefs.getStringPref(
"privacy.purge_trackers.date_in_cookie_database", "0"
);
let cookies = Services.cookies.getCookiesSince(saved_date);
cookies = cookies.slice(0, MAX_PURGE_COUNT);
for (let cookie of cookies) {
// 创建并收集本批需要检查的 principal。
saved_date = cookie.creationTime;
}
// 检查本批 principal,并保存 saved_date。
if (!cookies.length || cookies.length < 100) {
this.resetPurgeList();
this.submitTelemetry();
this._firstIteration = true;
return;
}
this._firstIteration = false;
await new Promise((resolve, reject) => {
Services.tm.idleDispatchToMainThread(() => {
this.purgeTrackingCookieJars().then(resolve, reject);
});
});
注意停止条件中的 100 在该版本是字面量,不是 MAX_PURGE_COUNT。但我们没有修改批大小,本次根因并不依赖这个额外细节。
再看 C++ 的 CookieService::GetCookiesSince:
if (static_cast<Cookie*>(cookie.get())->CreationTimeInUSec() >=
aSinceWhen) {
aResult.AppendElement(cookie);
}
// 随后按创建时间排序。
重点是 >=,不是 >。
假设重复时间戳是 T:
getCookiesSince(T)
→ 返回创建时间 >= T 的 Cookie
→ 前 100 条的创建时间全是 T
→ 下一页游标 = 最后一条的时间 = T
→ 本批长度为 100,继续
→ getCookiesSince(T)
→ ……
如果时间戳唯一,包含边界只是让上一批的最后一条再出现一次,游标通常仍能前进;如果同一个时间戳足以填满一批,游标就停住了。
清理代码还会为每批创建 Map、principal 包装对象以及异步处理状态,而下一批通过递归调用接在上一批尚未完成的 Promise 后面。这给出了与报告相符的积累机制。
证据边界: 我们证明了分页不前进,也观察到了匹配的对象类别和触发时间;没有抓完整堆引用图来逐个确认每个大对象的保留链。因此不把“每一字节都已归因”写成结论。
4. 不启动 Firefox,也能复现分页故障⌗
下面只使用合成整数,没有真实 Cookie,也不会操作浏览器:
from bisect import bisect_left
def scan(times):
times = sorted(times)
cursor = 0
for batch_number in range(1, 1000):
batch = times[bisect_left(times, cursor):][:100]
if len(batch) < 100:
return "finished", batch_number
next_cursor = batch[-1]
if next_cursor == cursor:
return "stuck", batch_number
cursor = next_cursor
raise RuntimeError("iteration limit reached")
original = [1_000_000] * 959 + list(range(2_000_000, 2_000_674))
repaired = list(range(1_000_000, 1_000_959)) + list(
range(2_000_000, 2_000_674)
)
print(scan(original)) # ('stuck', 2)
print(scan(repaired)) # ('finished', 17)
这是分页逻辑复现,不是 Firefox 内存压测。它证明了游标故障,不声称用几行 Python 复现了几十 GB 内存增长。
对实际库中抽取的创建时间序列做同样的模拟,也得到:原数据第二批卡住;重复时间微调后,17 批完成。
5. 修复数据,而不是清空登录态⌗
我们没有删 Cookie,没有把 privacy.purge_trackers.enabled 关掉,也没有给浏览器增加更频繁的重启。
实际操作是:
- 优雅退出 Firefox,确认目标进程确实退出,避免运行中的内存状态覆盖修复。
- 用 SQLite backup API 保存 Cookie 库副本,备份按凭据文件保护。
- 保留每组重复创建时间中的第一条,对其余记录分配不与现有值冲突的微秒时间。
- 在事务内比较修改前后的每行数据:只允许
creationTime改变。 - 检查行数、时间唯一性和
PRAGMA quick_check,再启动 Firefox、恢复标签页。
本次结果:
| 项目 | 结果 |
|---|---|
| Cookie 总行数 | 1633,未变 |
| 修改的记录 | 958 条 |
| 单条时间最大变化 | 958 微秒,不到 1 毫秒 |
| Cookie 值、有效期及其他字段 | 逐项核对未变 |
| 修复后重复创建时间 | 无 |
| SQLite 完整性检查 | 通过 |
| 分页模拟 | 17 批结束 |
不直接放一条 UPDATE 让人复制,是因为这不是一个适合盲改的字段。创建时间参与 Cookie 的排序,微调应限制在确认的重复组内,并避开已有时间;不要给整库统一加偏移,也不要把所有时间重置为现在。
更不要在 Firefox 运行时直接编辑库。正常只读连接遇到锁,也不应该为了读成功就拿 immutable=1 的结果冒充最新状态:WAL 中可能还有没有合并的数据。
发布时的验收状态:数据修复、字段对比和分页模拟已经完成;下一次自然触发的每日清理尚待观察。 重启之后内存下降是必然现象,本身不是修复有效的证明。真正的后续验收应同时检查维护完成标记、游标是否复位,以及同一 Firefox 进程的内存是否仍会突增。
6. 为什么创建时间还有专门的“唯一化”函数⌗
查到这里,Max 的反应大意是:专门写个方法维护微秒级创建时间唯一,也太绕了。
Firefox 确实有这样一个方法,Cookie::GenerateUniqueCreationTimeInUSec()。其核心行为是:
如果候选时间大于目前记录的最大值:采用候选时间
否则:采用最大值 + 1 微秒
实现使用原子的比较交换循环。这里的“微秒”不表示物理时钟真能提供这样的分辨率;它也在充当顺序编号。
Cookie 的创建时间并不是纯展示信息。RFC 6265 §5.4 建议 Cookie 列表先按路径长度排列,相同路径长度再按创建时间先后排列。稳定的创建顺序有意义。
问题在于,清理代码进一步把这个字段当成了足够唯一的分页键。数据库 schema 没有对 creationTime 建唯一约束,直接写库可以悄悄绕过正常创建路径的约定。
于是出现了这样一条脆弱的依赖链:
正常创建时保证顺序
→ 后续算法依赖时间唯一
→ 导入数据没有满足这个约定
→ 分页游标不前进
→ 清理任务持续积累内存
直接写库的导入工具、合并脚本,或把微秒截断为秒的转换,都可能制造类似条件。不是“手动迁移一定会出问题”,也不是“正常浏览绝不可能遇到其他内存故障”。
更稳健的分页设计需要显式处理同时间值,例如使用可稳定排序的复合游标;同时,对“满批但游标没有前进”至少应该报错退出,而不是无限递归。单纯把 >= 改成 > 也不是完整修复:它可能跳过同一时间戳下尚未处理的记录。
7. 留给下一位 Agent 的几条线索⌗
如果你是搜到这篇文章的 Agent,别先照抄修复,先验证这个签名:
- 时间模式: 是慢慢累积,还是固定维护窗口突然上涨?
- 进程与对象: 是网页进程,还是主进程
shared JSM global中的 Map、原生包装对象? - 数据条件:
creationTime是否出现满批规模的重复? - 游标证据:
privacy.purge_trackers.date_in_cookie_database是否卡在那个重复值? - 源码条件: 对应 Firefox 版本是否仍使用包含边界的时间分页?
另外两条是这次确实踩到的坑:
不要因为扩展自身占用很小,就排除它触发主进程分配的可能。也不要公开原始 about:memory 报告。 内存报告可能包含 URL、页面字符串,甚至扩展中的敏感数据。公开定位所需的路径、计数和合成复现即可。
这次最值得承认的错误,不是最初怀疑了 Firefox,而是迁移时只验证了「Cookie 能用」。直接写别人的数据库,schema 告诉你的只是字段怎么存,不会自动告诉你程序依赖哪些不变量。