云端 Mac 上的 iOS SQLite 读快照验收:事务一致性与检查点边界

云端 Mac 上的 iOS SQLite 读快照验收:事务一致性与检查点边界

后台同步已经提交新记录,列表刷新却仍显示旧值;你重建缓存后问题暂时消失,下一次批量读取又复现。这时应先检查读取是否仍处在同一个 SQLite 事务里。在云端 Mac 上用两个独立连接复现,比反复操作远程桌面更容易区分数据库语义与界面缓存问题。

先定义需要守住的读取契约

本文只讨论本地文件数据库的 WAL 模式,不把结论套到内存数据库或共享缓存配置。准备可用的 Python 3 与 SQLite 模块,先运行 python3 -c 'import sqlite3; print(sqlite3.sqlite_version)',把输出随测试记录保存。

验收目标是:读连接首次查询后,写连接可以提交;旧读事务仍看到旧值;读事务结束后,再查才能看到新值。这里的“旧”是事务一致性,不是数据丢失。

不要为了让列表“更实时”,在一次需要一致视图的多表读取中随意提交事务。先决定业务需要同一快照,还是每次查询都读取最新提交。

测试必须使用真实临时文件和两个连接。两个普通的 :memory: 连接各有自己的数据库,不能验证这组交互。

用双连接建立最小实验

将下面内容保存为 snapshot_check.py,执行 python3 snapshot_check.py。脚本不连接业务数据库,临时目录退出后自动删除。所有游标都立即取完并关闭,避免把未结束的语句误当成显式事务问题。

import sqlite3
import tempfile
from pathlib import Path

def one(db, sql):
    cursor = db.execute(sql)
    try:
        return cursor.fetchone()
    finally:
        cursor.close()

with tempfile.TemporaryDirectory() as root:
    path = Path(root) / "snapshot.sqlite"
    writer = sqlite3.connect(path, isolation_level=None)
    reader = sqlite3.connect(path, isolation_level=None)
    try:
        assert one(writer, "PRAGMA journal_mode=WAL")[0] == "wal"
        writer.execute("PRAGMA wal_autocheckpoint=0")
        writer.execute("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)")
        writer.execute("INSERT INTO item VALUES(1, 'old')")
        one(writer, "PRAGMA wal_checkpoint(TRUNCATE)")

        reader.execute("BEGIN")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"

        writer.execute("BEGIN IMMEDIATE")
        writer.execute("UPDATE item SET value='new' WHERE id=1")
        writer.execute("COMMIT")

        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "old"
        held = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert held[1] > held[2], held

        reader.execute("COMMIT")
        assert one(reader, "SELECT value FROM item WHERE id=1")[0] == "new"

        released = one(writer, "PRAGMA wal_checkpoint(PASSIVE)")
        assert released[1] == released[2], released
        print({"held": held, "released": released, "result": "passed"})
    finally:
        if reader.in_transaction:
            reader.rollback()
        if writer.in_transaction:
            writer.rollback()
        reader.close()
        writer.close()

代码显式关闭自动检查点,只为让实验过程可观察,不是生产配置建议。初始化后的截断也只针对这个独占夹具;不要把它复制到正在承接任务的数据库上。

分清 BEGIN 与首次读取

普通 BEGIN 开启的是延迟事务,不能把执行这一行的时刻直接当成快照时间。上面的第一次查询才建立了读视图。

再做一个独立变体:把写入与提交移动到读连接的第一次查询之前。此时第一次查询以及同一事务中的后续查询都应得到 new,需要同步调整断言。

这能揭穿一种常见误判:日志记录了“事务开始”,工程师便认为后续所有读取都对应那一刻。诊断时至少记录连接编号、事务编号、首次查询与结束位置,不要只记录方法入口。

多次查询是否必须一致

例如先查订单总数,再查明细,两次查询若属于同一业务快照,就应放进一个短读事务。若每次滚动加载都要求最新数据,则不应把整个页面生命周期包进同一事务。验收标准来自业务边界,而不是哪种方式看起来更新更快。

看检查点进度,不只看文件大小

wal_checkpoint(PASSIVE) 返回三个整数,依次表示忙状态、日志帧数与已检查点帧数。夹具中的旧读者仍需要早期视图,因此部分新帧不能完成检查点;释放读事务后,再执行便能推进。

不要固定断言某次恰好产生几帧。页大小、数据库布局和运行库版本都会影响数量,测试应检查相对关系。

同样,已检查点帧数追上日志帧数,不代表 WAL 文件立即缩小。文件可以保留空间供后续复用。线上观察应同时记录读事务持续时间、检查点结果与写入趋势,不能只凭目录里一个大文件就删除日志。

若第二个断言失败,先找仍然打开的读事务、游标或其他连接,再分析检查点策略。不要先加一个循环强制截断,把真正的长读者藏起来。

把夹具迁入 iOS 数据层测试

宿主侧实验验证的是当前 Python 所链接的 SQLite。迁入 iOS 后,要在目标模拟器或设备上通过实际数据访问层重新执行,并记录该环境的 sqlite3_libversion(),不能把宿主结果当成全部运行环境的证明。

保留两个真正独立的底层连接,用明确的测试屏障控制“首次读完、写入提交、再次读取”三个阶段,不要依赖睡眠等待。若连接池把两次操作调度到同一个连接,实验就失去了意义。

最后检查四项:旧事务读旧值、新事务读新值、异常路径也结束事务、长读者释放后检查点可以推进。异步查询还要确认取消后游标和事务均已收口。把这些断言留在回归测试里,下一次列表读到旧数据时,就能先判断它是正确快照,还是事务生命周期已经越界。

常见问题

执行 BEGIN 后,SQLite 的读快照就固定了吗?

普通的延迟事务不是仅靠 BEGIN 固定快照;在本文的 WAL 实验里,首次实际读取才建立读快照。

写连接提交后,旧读事务应该立即看到新值吗?

不应该。旧读事务继续读取原快照,结束事务并重新读取后才能看到已经提交的新值。

检查点完成后,WAL 文件一定缩小吗?

不一定。帧被写回数据库与文件截断是不同操作,不能仅凭 WAL 文件大小判断检查点是否推进。

把任务交给独享的云端 Mac

租用一台独享物理 Mac mini,按你的工作安排选择节点与计费周期。开通后在控制台查看连接信息;使用中遇到问题,可在控制台提交工单。

选择云端 Mac 方案

按你的工作安排选择节点

先核对配置与周期,再开通你自己的独享物理 Mac mini。