Codex Desktop 持续写入 SQLite 日志的可回滚解决方案
最近检查 Codex 的本地数据时,我发现 ~/.codex/logs_2.sqlite 正在持续写入大量 TRACE、DEBUG 和 INFO 日志。问题不只是数据库文件占用空间,高频 INSERT、WAL 刷新、旧记录清理和多进程共享数据库还可能带来额外磁盘 I/O 与 SQLite 竞争。
方案只拦截已确认高频的
TRACE/DEBUG/INFO,保留WARN/ERROR/FATAL/CRITICAL以及未来未知级别。如果官方以后提供日志级别、采样或轮转设置,应优先使用官方方案。
一、测试时环境
| 项目 | 信息 |
|---|---|
| 检查日期 | 2026-07-23 |
| 操作系统 | Windows 11 x64 |
| Codex Desktop | 26.715.10079.0 |
| 内置 codex-cli | 0.145.0-alpha.30 |
| 日志数据库 | %USERPROFILE%\.codex\logs_2.sqlite |
| SQLite journal mode | WAL |
二、问题是什么?
在一次正常活跃会话中观察到:
| 指标 | 安装前 |
|---|---|
| 最近 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。
三、影响是什么?
可能的实际影响包括:
- 磁盘 I/O 增加:日志正文、SQLite 页和 WAL frame 都会产生写入。
- 写放大:应用层正文大小不等于 WAL 写入量,更不等于 SSD 最终 NAND 写入量。
- 数据库竞争:多个 Codex/app-server 进程共享日志库时,频繁事务可能增加锁竞争。
- 空间与清理开销:旧记录删除、checkpoint 和空页复用仍会产生工作。
- 诊断库膨胀:即使历史行被删除,主数据库也可能因为空闲页而保持较大。
真实磨损还取决于 checkpoint、文件系统缓存、SSD 控制器、垃圾回收和写放大系数。本文能确认和解决的是:Codex 是否在持续落盘大量低级别日志,以及如何减少这部分不必要的写入。
四、诊断
下面的脚本只依赖 Python 标准库,提供:
status:只读统计最近日志和数据库状态;install:先备份,再安装保守黑名单 trigger;check:只读检查 trigger 是否完整;remove:先备份,再恢复默认日志行为。
把下面代码保存为 codex_log_guard.py:
#!/usr/bin/env python3from __future__ import annotations
import argparseimport osimport reimport sqlite3import sysimport timefrom datetime import datetimefrom 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 logsWHEN 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 标准库。下载后先运行:
py -3 .\codex_log_guard.py status --recent-seconds 300macOS/Linux:
python3 ./codex_log_guard.py status --recent-seconds 300status 会显示:
- 主数据库、WAL、SHM 的大小和更新时间;
- trigger 是否存在;
- 各日志级别的累计行数和估算字节量;
- 最近窗口的行数、字节量和写入速率;
- SQLite page count、free list 和可回收空间;
- 写入量最大的 target 名称。
脚本不会读取或打印 feedback_log_body 正文,因为其中可能包含任务内容、代码片段等隐私信息。
检查是否人为开启详细日志
Windows PowerShell:
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:$falsemacOS/Linux:
echo "$RUST_LOG"grep -Ei 'trace|debug|verbosity|otel|log_level|log-level' ~/.codex/config.toml还要检查启动脚本、快捷方式和 shell profile。如果发现 RUST_LOG=trace 或类似设置,应先移除并重启 Codex,这才是从源头解决。
本电脑没有发现 RUST_LOG 或 config.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_logsBEFORE INSERT ON logsWHEN 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 新增 FATAL、CRITICAL、NOTICE 或其他重要级别,它们也会被静默丢弃。
本文配套脚本改为明确黑名单,只拦截已经确认高频的:
TRACEDEBUGINFO
其他当前和未来级别默认保留。这样减少一部分极端写入收益,但更适合作为长期、保守的本地 workaround。
需要说明:trigger 不能阻止 Codex 在应用层生成、格式化日志,也不能消除发起 SQLite INSERT 的函数调用。它减少的是日志真正进入表之后的数据库页、WAL、清理和 checkpoint 开销。
六、安装/验证与回滚
1. 安装
py -3 .\codex_log_guard.py install脚本默认会先调用 SQLite backup API 创建一致性备份,并保存当时存在的 WAL/SHM 原始副本。备份文件名包含微秒,避免快速连续操作互相覆盖。
典型输出:
Installed trigger: codex_drop_low_value_logsBlocked levels: TRACE, DEBUG, INFOPreserved by default: all other and future levelstrigger 存在数据库中,安装后立即生效:
- 不需要重启 Codex;
- 不需要脚本常驻;
- 不需要后台服务;
- 不会修改 Codex 可执行文件或
config.toml。
2. 结构检查
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 正常工作,同时执行:
py -3 .\codex_log_guard.py status --recent-seconds 30重点观察:
- TRACE/DEBUG/INFO 的最大 ID 是否停止增长;
- 是否仍有被拦截级别的新行;
- WARN/ERROR 等其他级别能否继续保存;
- WAL 是否出现持续净增长。
如果窗口内所有低级别日志都被 trigger 拦截,同时没有 WARN/ERROR,status 会显示 no rows。结合下面两项判断:
py -3 .\codex_log_guard.py checkpy -3 .\codex_log_guard.py status --recent-seconds 60结构为 OK、低级别最大 ID 不再变化、最近窗口为 no rows,并且安装前同样操作会产生大量日志,就能形成完整证据链。
4. 回滚
需要提交完整诊断日志、Codex 出现异常或不再需要方案时:
py -3 .\codex_log_guard.py removeremove 同样会先备份,然后删除已知 guard trigger,恢复默认日志行为。为了获取完整故障日志,建议移除后重启 Codex,再重新复现问题。
七、实际优化结果
真实环境安装前后对比:
| 指标 | 安装前 | 安装后 |
|---|---|---|
| TRACE/DEBUG/INFO | 1,087 条/分钟 | 最近 5 分钟 0 条 |
| 估算日志量 | 约 3.9 MiB/分钟 | 最近 5 分钟 0 |
| TRACE 最大 ID | 持续增长 | 固定在 1045112 |
| DEBUG 最大 ID | 持续增长 | 固定在 1045108 |
| INFO 最大 ID | 持续增长 | 固定在 1045110 |
| 主数据库 | 53.2 MiB | 53.2 MiB,无增长 |
| trigger | 无 | codex_drop_low_value_logs,状态 OK |
后续真实运行中出现了两条新的 ERROR:
- 日志总最大 ID 从
1045112增加到1045114; - ERROR 行数从 17 增加到 19;
- TRACE/DEBUG/INFO 三类最大 ID完全不变。
保守黑名单只拦截低级别日志,严重日志仍能正常保存。
另外做了一组临时数据库压力测试:逐次提交 500 条、每条 16 KiB 的 TRACE 日志。
| 状态 | 入库行数 | WAL 大小 |
|---|---|---|
| 无 trigger | 500 | 约 14.0 MiB |
| 有 trigger | 0 | 约 20 KiB |
在这个受控测试中,被拦截日志造成的 WAL 增长下降约 99.86%。
还是需要解释一下这个数字:它不代表 Codex 整体 CPU、UI 延迟或 SSD 物理写入同样提升如此幅度。但是可以确认目标日志的落库和数据库增长已经停止。
八、副作用与维护建议
会失去哪些信息
安装后新产生的 TRACE/DEBUG/INFO 不会保存在数据库中。这可能减少排查复杂网络、流式响应或内部状态问题时的上下文。
因此:
- 日常使用可以启用;
- 准备向官方提交完整诊断时先
remove; - 重启 Codex 后重新复现;
- 问题结束后再根据新的
status决定是否重新安装。
Codex 更新后可能失效
SQLite 表被删除或重建时,关联 trigger 可能随之消失。每次 Codex 大版本更新后运行:
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 等工具定位其他路径。
九、参考资料
部分信息可能已经过时