页面正在加载
公告2.0
大学生职业规划智能体项目——可预览
2889 字
14 分钟
Codex高频写入SQLite日志问题与解决方案

Codex Desktop 持续写入 SQLite 日志的可回滚解决方案#

最近检查 Codex 的本地数据时,我发现 ~/.codex/logs_2.sqlite 正在持续写入大量 TRACEDEBUGINFO 日志。问题不只是数据库文件占用空间,高频 INSERT、WAL 刷新、旧记录清理和多进程共享数据库还可能带来额外磁盘 I/O 与 SQLite 竞争。

方案只拦截已确认高频的 TRACE/DEBUG/INFO,保留 WARN/ERROR/FATAL/CRITICAL 以及未来未知级别。

如果官方以后提供日志级别、采样或轮转设置,应优先使用官方方案。

一、测试时环境#

项目信息
检查日期2026-07-23
操作系统Windows 11 x64
Codex Desktop26.715.10079.0
内置 codex-cli0.145.0-alpha.30
日志数据库%USERPROFILE%\.codex\logs_2.sqlite
SQLite journal modeWAL

二、问题是什么?#

在一次正常活跃会话中观察到:

指标安装前
最近 60 秒低级别日志1,087 条
估算日志正文约 3.9 MiB/分钟
主数据库53.2 MiB
WAL约 4.6 MiB,持续刷新
TRACE 累计正文约 38.7 MiB
DEBUG 累计正文约 1.5 MiB
INFO 累计正文约 1.2 MiB

其中 codex_http_client::transport 只有 168 行,却占约 35.1 MiB,说明少数体积很大的网络诊断日志是主要来源之一。

还有一个很有意思的信号:当时日志最大 ID 已超过 104 万,但表内只保留约 1.28 万行。这意味着系统曾发生大量“插入日志—清理旧记录”的循环。删除记录不会自动缩小 SQLite 主文件,反而会留下可复用空页,因此文件大小本身不能完整反映真实写入活动。

OpenAI Codex 的公开 issue #24275 也记录了类似现象:活跃会话中 logs_2.sqlite-wal 在几分钟内增长到约 146.6 MB,而 SQLite 中统计到的逻辑日志量只有约 2.4 MB。

该 issue 在本文写作时仍为 Open。


三、影响是什么?#

可能的实际影响包括:

  1. 磁盘 I/O 增加:日志正文、SQLite 页和 WAL frame 都会产生写入。
  2. 写放大:应用层正文大小不等于 WAL 写入量,更不等于 SSD 最终 NAND 写入量。
  3. 数据库竞争:多个 Codex/app-server 进程共享日志库时,频繁事务可能增加锁竞争。
  4. 空间与清理开销:旧记录删除、checkpoint 和空页复用仍会产生工作。
  5. 诊断库膨胀:即使历史行被删除,主数据库也可能因为空闲页而保持较大。

真实磨损还取决于 checkpoint、文件系统缓存、SSD 控制器、垃圾回收和写放大系数。本文能确认和解决的是:Codex 是否在持续落盘大量低级别日志,以及如何减少这部分不必要的写入。

四、诊断#

下面的脚本只依赖 Python 标准库,提供:

  • status:只读统计最近日志和数据库状态;
  • install:先备份,再安装保守黑名单 trigger;
  • check:只读检查 trigger 是否完整;
  • remove:先备份,再恢复默认日志行为。

把下面代码保存为 codex_log_guard.py

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
TRIGGER_NAME = "codex_drop_low_value_logs"
BLOCKED_LEVELS = ("TRACE", "DEBUG", "INFO")
KNOWN_TRIGGERS = (
TRIGGER_NAME,
"codex_keep_warn_error_logs",
"block_noisy_logs",
"codex_drop_trace_logs",
"codex_drop_all_logs",
)
TRIGGER_SQL = f"""
CREATE TRIGGER {TRIGGER_NAME}
BEFORE INSERT ON logs
WHEN upper(coalesce(NEW.level, '')) IN ('TRACE', 'DEBUG', 'INFO')
BEGIN
SELECT RAISE(IGNORE);
END
""".strip()
def fail(message: str) -> None:
raise SystemExit(f"error: {message}")
def default_db() -> Path:
override = os.environ.get("CODEX_LOG_DB")
return Path(override).expanduser() if override else Path.home() / ".codex" / "logs_2.sqlite"
def connect(db: Path, readonly: bool) -> sqlite3.Connection:
if not db.exists():
fail(f"database not found: {db}")
if readonly:
con = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=30)
else:
con = sqlite3.connect(db, timeout=30)
con.row_factory = sqlite3.Row
con.execute("PRAGMA busy_timeout=30000")
return con
def assert_schema(con: sqlite3.Connection) -> None:
table = con.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='logs'"
).fetchone()
if not table:
fail("logs table not found")
columns = {row["name"] for row in con.execute("PRAGMA table_info(logs)")}
missing = {"id", "ts", "level"} - columns
if missing:
fail(f"logs table missing columns: {', '.join(sorted(missing))}")
def normalized(sql: str) -> str:
return re.sub(r"\s+", "", sql).rstrip(";").lower()
def trigger_ok(con: sqlite3.Connection) -> bool:
row = con.execute(
"SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?",
(TRIGGER_NAME,),
).fetchone()
return bool(row and normalized(row["sql"]) == normalized(TRIGGER_SQL))
def backup(db: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
target = db.parent / f"{db.name}.backup-{stamp}.sqlite"
with sqlite3.connect(db, timeout=30) as src, sqlite3.connect(target) as dst:
src.backup(dst)
print(f"backup: {target} ({target.stat().st_size} bytes)")
return target
def status(db: Path, seconds: int) -> None:
with connect(db, readonly=True) as con:
assert_schema(con)
columns = {row["name"] for row in con.execute("PRAGMA table_info(logs)")}
byte_expr = (
"coalesce(sum(estimated_bytes), 0)"
if "estimated_bytes" in columns
else "0"
)
rows = con.execute(
f"""
SELECT upper(level) AS level, count(*) AS rows,
coalesce(max(id), 0) AS max_id, {byte_expr} AS bytes
FROM logs
WHERE ts >= ?
GROUP BY upper(level)
ORDER BY rows DESC
""",
(int(time.time()) - seconds,),
).fetchall()
page_size = int(con.execute("PRAGMA page_size").fetchone()[0])
free_pages = int(con.execute("PRAGMA freelist_count").fetchone()[0])
print(f"database: {db} ({db.stat().st_size} bytes)")
wal = Path(str(db) + "-wal")
print(f"wal: {wal.stat().st_size if wal.exists() else 0} bytes")
print(f"trigger: {'OK' if trigger_ok(con) else 'MISSING/OUTDATED'}")
print(f"reclaimable after VACUUM: {page_size * free_pages} bytes")
print(f"recent {seconds}s:")
if not rows:
print(" no rows")
for row in rows:
print(
f" {row['level']}: rows={row['rows']} "
f"max_id={row['max_id']} bytes={row['bytes']}"
)
def install(db: Path) -> None:
backup(db)
with connect(db, readonly=False) as con:
assert_schema(con)
for name in KNOWN_TRIGGERS:
con.execute(f"DROP TRIGGER IF EXISTS {name}")
con.execute(TRIGGER_SQL)
con.commit()
if not trigger_ok(con):
fail("trigger verification failed")
print(f"installed: {TRIGGER_NAME}")
print(f"blocked: {', '.join(BLOCKED_LEVELS)}; all other levels are preserved")
def check(db: Path) -> None:
with connect(db, readonly=True) as con:
assert_schema(con)
if not trigger_ok(con):
fail("trigger missing or outdated")
print(f"OK: {TRIGGER_NAME}")
def remove(db: Path) -> None:
backup(db)
with connect(db, readonly=False) as con:
assert_schema(con)
for name in KNOWN_TRIGGERS:
con.execute(f"DROP TRIGGER IF EXISTS {name}")
con.commit()
print("removed known guard triggers; default logging restored")
def main() -> int:
parser = argparse.ArgumentParser(description="Guard Codex SQLite low-level logs")
parser.add_argument("command", choices=("status", "install", "check", "remove"))
parser.add_argument("--db", type=Path, default=default_db())
parser.add_argument("--recent-seconds", type=int, default=300)
args = parser.parse_args()
if args.recent_seconds < 1:
fail("--recent-seconds must be positive")
if args.command == "status":
status(args.db.expanduser(), args.recent_seconds)
elif args.command == "install":
install(args.db.expanduser())
elif args.command == "check":
check(args.db.expanduser())
else:
remove(args.db.expanduser())
return 0
if __name__ == "__main__":
sys.exit(main())

脚本只依赖 Python 标准库。下载后先运行:

Terminal window
py -3 .\codex_log_guard.py status --recent-seconds 300

macOS/Linux:

Terminal window
python3 ./codex_log_guard.py status --recent-seconds 300

status 会显示:

  • 主数据库、WAL、SHM 的大小和更新时间;
  • trigger 是否存在;
  • 各日志级别的累计行数和估算字节量;
  • 最近窗口的行数、字节量和写入速率;
  • SQLite page count、free list 和可回收空间;
  • 写入量最大的 target 名称。

脚本不会读取或打印 feedback_log_body 正文,因为其中可能包含任务内容、代码片段等隐私信息。

检查是否人为开启详细日志#

Windows PowerShell:

Terminal window
Get-ChildItem Env:RUST_LOG -ErrorAction SilentlyContinue
[Environment]::GetEnvironmentVariable("RUST_LOG", "User")
[Environment]::GetEnvironmentVariable("RUST_LOG", "Machine")
Select-String -Path "$HOME\.codex\config.toml" `
-Pattern "trace|debug|verbosity|otel|log_level|log-level" `
-CaseSensitive:$false

macOS/Linux:

Terminal window
echo "$RUST_LOG"
grep -Ei 'trace|debug|verbosity|otel|log_level|log-level' ~/.codex/config.toml

还要检查启动脚本、快捷方式和 shell profile。如果发现 RUST_LOG=trace 或类似设置,应先移除并重启 Codex,这才是从源头解决。

本电脑没有发现 RUST_LOGconfig.toml 中的人为详细日志设置,所以更像是当前 Codex 日志管线自身的行为。


五、解决原理:在 SQLite INSERT 之前过滤低级别日志#

SQLite trigger 是存储在数据库 schema 中的自动规则。Codex 每次向 logs 表 INSERT 时,SQLite 会先检查日志级别:

Codex 生成日志
尝试 INSERT 到 logs
SQLite BEFORE INSERT trigger
├─ TRACE / DEBUG / INFO → RAISE(IGNORE),取消本行写入
└─ 其他级别 → 正常保存

核心 SQL:

CREATE TRIGGER codex_drop_low_value_logs
BEFORE INSERT ON logs
WHEN upper(coalesce(NEW.level, '')) IN ('TRACE', 'DEBUG', 'INFO')
BEGIN
SELECT RAISE(IGNORE);
END;

SQLite 官方文档说明,trigger 会在指定数据库事件发生时自动逐行执行,WHEN 可以读取 NEW.level;这里使用 RAISE(IGNORE) 放弃当前插入。

为什么用黑名单,而不是只允许 WARN/ERROR?#

原始项目 Simmons-jg/codex-ssd-saver 使用严格白名单:只保留 WARN/WARNING/ERROR

这个策略虽然更激进,但有一个隐患:未来如果 Codex 新增 FATALCRITICALNOTICE 或其他重要级别,它们也会被静默丢弃。

本文配套脚本改为明确黑名单,只拦截已经确认高频的:

  • TRACE
  • DEBUG
  • INFO

其他当前和未来级别默认保留。这样减少一部分极端写入收益,但更适合作为长期、保守的本地 workaround。

需要说明:trigger 不能阻止 Codex 在应用层生成、格式化日志,也不能消除发起 SQLite INSERT 的函数调用。它减少的是日志真正进入表之后的数据库页、WAL、清理和 checkpoint 开销。


六、安装/验证与回滚#

1. 安装#

Terminal window
py -3 .\codex_log_guard.py install

脚本默认会先调用 SQLite backup API 创建一致性备份,并保存当时存在的 WAL/SHM 原始副本。备份文件名包含微秒,避免快速连续操作互相覆盖。

典型输出:

Installed trigger: codex_drop_low_value_logs
Blocked levels: TRACE, DEBUG, INFO
Preserved by default: all other and future levels

trigger 存在数据库中,安装后立即生效:

  • 不需要重启 Codex;
  • 不需要脚本常驻;
  • 不需要后台服务;
  • 不会修改 Codex 可执行文件或 config.toml

2. 结构检查#

Terminal window
py -3 .\codex_log_guard.py check

正常应输出:

OK: trigger codex_drop_low_value_logs is installed and up to date

本文不建议默认安排每日自动重装。Codex 更新后先运行 check,确认 trigger 是否仍存在,再决定是否重新安装。

3. 活跃窗口验证#

保持 Codex 正常工作,同时执行:

Terminal window
py -3 .\codex_log_guard.py status --recent-seconds 30

重点观察:

  • TRACE/DEBUG/INFO 的最大 ID 是否停止增长;
  • 是否仍有被拦截级别的新行;
  • WARN/ERROR 等其他级别能否继续保存;
  • WAL 是否出现持续净增长。

如果窗口内所有低级别日志都被 trigger 拦截,同时没有 WARN/ERROR,status 会显示 no rows。结合下面两项判断:

Terminal window
py -3 .\codex_log_guard.py check
py -3 .\codex_log_guard.py status --recent-seconds 60

结构为 OK、低级别最大 ID 不再变化、最近窗口为 no rows,并且安装前同样操作会产生大量日志,就能形成完整证据链。

4. 回滚#

需要提交完整诊断日志、Codex 出现异常或不再需要方案时:

Terminal window
py -3 .\codex_log_guard.py remove

remove 同样会先备份,然后删除已知 guard trigger,恢复默认日志行为。为了获取完整故障日志,建议移除后重启 Codex,再重新复现问题。


七、实际优化结果#

真实环境安装前后对比:

指标安装前安装后
TRACE/DEBUG/INFO1,087 条/分钟最近 5 分钟 0 条
估算日志量约 3.9 MiB/分钟最近 5 分钟 0
TRACE 最大 ID持续增长固定在 1045112
DEBUG 最大 ID持续增长固定在 1045108
INFO 最大 ID持续增长固定在 1045110
主数据库53.2 MiB53.2 MiB,无增长
triggercodex_drop_low_value_logs,状态 OK

后续真实运行中出现了两条新的 ERROR

  • 日志总最大 ID 从 1045112 增加到 1045114
  • ERROR 行数从 17 增加到 19;
  • TRACE/DEBUG/INFO 三类最大 ID完全不变。

保守黑名单只拦截低级别日志,严重日志仍能正常保存。

另外做了一组临时数据库压力测试:逐次提交 500 条、每条 16 KiB 的 TRACE 日志。

状态入库行数WAL 大小
无 trigger500约 14.0 MiB
有 trigger0约 20 KiB

在这个受控测试中,被拦截日志造成的 WAL 增长下降约 99.86%。

还是需要解释一下这个数字:它不代表 Codex 整体 CPU、UI 延迟或 SSD 物理写入同样提升如此幅度。但是可以确认目标日志的落库和数据库增长已经停止。


八、副作用与维护建议#

会失去哪些信息#

安装后新产生的 TRACE/DEBUG/INFO 不会保存在数据库中。这可能减少排查复杂网络、流式响应或内部状态问题时的上下文。

因此:

  • 日常使用可以启用;
  • 准备向官方提交完整诊断时先 remove
  • 重启 Codex 后重新复现;
  • 问题结束后再根据新的 status 决定是否重新安装。

Codex 更新后可能失效#

SQLite 表被删除或重建时,关联 trigger 可能随之消失。每次 Codex 大版本更新后运行:

Terminal window
py -3 .\codex_log_guard.py check

如果显示 missing/outdated,再先运行 status,确认问题仍存在后执行 install。不要无条件自动重装,以免未来官方迁移已经改变数据库用途或表结构。

并非全局 I/O 修复#

该方案只处理 logs_2.sqlite

  • 不处理 GPUCache;
  • 不处理 Chromium/LevelDB;
  • 不处理会话 JSONL;
  • 不处理其他 Codex 临时文件;
  • 不改变应用层日志生成成本。

如果系统仍有明显 I/O 或 CPU 异常,需要继续用资源监视器、Process Monitor、iotop 等工具定位其他路径。


九、参考资料#

Codex高频写入SQLite日志问题与解决方案
https://blog.tonks.top/posts/codexissue1/codex-desktop-sqlite-log-churn/
作者
Dr.Tonks
发布于
2026-07-23
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时