SQLite与Syncthing同步冲突:原理剖解与五种解决方案
大概在两周前我处理了一个很典型的“看起来正常但用着用着就出事”的场景一台小 NAS 上跑着基于 SQLite 的轻量业务应用为了让另一台机器也能拿到最新数据我图省事直接用了 Syncthing 同步整个应用数据目录。刚开始一切正常但跑了几天之后SQLite 数据库开始频繁报database disk image is malformed同步目录里也多出了十几个.sync-conflict-xxx文件。这个问题本质不是 Syncthing 的 bug也不是 SQLite 的 bug而是把这两个工具放在一起用的时候文件同步模型和本地数据库一致性模型发生了根本冲突。这篇文章就从复现、原理到解决方案完整地梳理一遍希望能帮你少踩这个坑。1. 背景Syncthing 与 SQLite 各自是什么组合后为什么危险1.1 SQLite 的关键特性SQLite 是嵌入式关系型数据库它的最大特点是“不需要服务端进程”数据直接存储在一个普通文件里例如app.db。应用通过 SQLite 驱动直接读写这个文件不需要远程连接也不需要独立部署数据库服务。SQLite 为了保证事务的 ACID 特性依赖底层文件系统提供的能力做事务控制。在写事务进行时SQLite 会创建额外的临时文件比如app.db-journal或者app.db-wal通过文件锁机制保证同一时间只有一个进程能够安全写入。这就带来了一个关键限制SQLite 的锁只在本地文件系统上有效。两个进程如果分布在两台机器上并且访问的是同一个磁盘文件SQLite 本身没有能力协调它们。1.2 Syncthing 的关键特性Syncthing 是开源的点对点文件同步工具常被用于跨设备同步个人文件、配置目录、NAS 备份等场景。它会在多台设备之间维护一个“最终一致”的目录副本。工作模型是监听本地目录变化。按块对比设备间文件差异。增量传输发生变化的文件块。遇到两端同时修改同一个文件时自动保留冲突版本。Syncthing 解决的是“把文件复制到多台设备”它没有实现分布式锁也没有多端并发写同一个文件的协调机制。它默认假设“同一时刻只有一端在修改文件同步就是把这个修改分发到另一端”。正是这个“同步拷贝”的逻辑和 SQLite 依赖本地文件锁的事务机制放在一起时会出现各种难以排查的诡异问题。1.3 容易混淆的几个概念许多新手会把“文件同步工具”和“网络文件系统”混淆这里先梳理清楚工具/概念工作层级是否支持并发写典型代表文件同步工具文件/目录级不支持多端并发写同一文件Syncthing网络文件系统虚拟文件系统层支持但依赖协议和内核实现NFS、SMB客户端/服务端数据库网络协议层支持PostgreSQL、MySQL嵌入式数据库本地文件层级仅单机多进程协调SQLiteSyncthing 不是 NFS它也做不到让两端直接读写同一个文件。它只是一个“复制工具”只是这个复制过程足够智能和高效。当你把 SQLite 数据库文件放进 Syncthing 同步目录相当于让两台机器同时写同一个文件而 Syncthing 只能不断地把一端的文件“覆盖”到另一端——冲突几乎必然发生。2. 环境准备构建一个最小复现实验为了说清楚问题我们先搭一个可以复现的环境。你不用真实的两台机器用本机 Docker 容器模拟两个 Syncthing 节点再配合本机 SQLite 写入脚本即可。2.1 准备 Syncthing 环境如果你日常使用 NAS常见设备上也可以直接安装威联通QNAP系统在 App Center 中启用社区源后安装 Syncthing或者通过 Container Station 运行 Syncthing 容器。TrueNAS SCALE通过 Applications 页面安装官方或社区提供的 Syncthing 应用。群晖 DSM在套件中心添加社区源后安装。由于不同 NAS 版本安装入口差异比较大下面用 Docker 方式作为统一复现环境。version: 3.8 services: syncthing-device-a: image: syncthing/syncthing container_name: syncthing-device-a ports: - 8384:8384 - 22000:22000 volumes: - ./device-a-config:/var/syncthing/config - ./sync-folder:/sync-folder environment: - PUID1000 - PGID1000 syncthing-device-b: image: syncthing/syncthing container_name: syncthing-device-b ports: - 8385:8384 - 22001:22000 volumes: - ./device-b-config:/var/syncthing/config - ./sync-folder-b:/sync-folder environment: - PUID1000 - PGID1000注意不同 Syncthing 镜像对数据目录的定义可能不同如果使用的是syncthing/syncthing常见数据目录为/var/syncthing。如果你的镜像不同请根据镜像说明调整 volume 挂载路径。启动后分别访问http://localhost:8384和http://localhost:8385完成初始化、设置用户名密码然后把两台设备互相添加为远端设备。这一步需要互相填写设备 ID是 Syncthing 正常工作的前提。2.2 准备 SQLite 工具在宿主机上安装 SQLite 命令行工具# Debian/Ubuntu sudo apt update sudo apt install -y sqlite3 python3 # CentOS/RHEL sudo yum install -y sqlite python3验证安装python3 -c import sqlite3; print(sqlite3.sqlite_version) sqlite3 --version如果需要在 Windows 上查看数据库可以安装 DB Browser for SQLite。这是一个图形化工具后面排查损坏文件时会用到。2.3 创建测试数据库和同步目录在本地创建两个目录模拟两台设备的同步根目录mkdir -p /tmp/sync-a mkdir -p /tmp/sync-b初始化测试数据库cd /tmp/sync-a sqlite3 app.db CREATE TABLE IF NOT EXISTS t(id INTEGER PRIMARY KEY, note TEXT); sqlite3 app.db INSERT INTO t(note) VALUES (hello);将/tmp/sync-a和/tmp/sync-b分别挂载到两个 Syncthing 容器中的同步目录并在两端添加相同的文件夹 ID。这样 Syncthing 会持续双向同步两个目录。3. 完整复现在两台设备同时写入 SQLite3.1 编写写入脚本这个脚本模拟一个真实的业务进程不断打开数据库、读写、提交事务、关闭连接。# 文件路径/tmp/sync-a/writer.py import os import sqlite3 import time import sys DB_PATH os.path.join(os.path.dirname(os.path.abspath(__file__)), app.db) def write_batch(conn, batch_id): conn.execute( INSERT INTO t(note) VALUES (?), (fbatch-{batch_id},) ) conn.commit() if __name__ __main__: batch_id int(sys.argv[1]) if len(sys.argv) 1 else 0 while True: conn sqlite3.connect(DB_PATH, timeout10) try: write_batch(conn, batch_id) batch_id 1 finally: conn.close() time.sleep(0.2)在设备 A 的同步目录启动cd /tmp/sync-a python3 writer.py 0在设备 B 的同步目录也放一份同样的脚本并启动cd /tmp/sync-b python3 writer.py 100000两个进程开始同时向各自的app.db写入数据。由于 Syncthing 会不断双向同步过一会就会出现各种异常。3.2 观察异常现象等待几十秒后先停止两个写入脚本然后分别查看两端cd /tmp/sync-a ls -la cd /tmp/sync-b ls -la正常情况下你会看到两端生成了大量.sync-conflict-*文件例如app.db.sync-conflict-20250415-101530-ABCDEF.db。某些一端的app.db可能已经无法打开。同步目录里出现了app.db-journal之类的临时文件。此时用 SQLite 检查sqlite3 /tmp/sync-a/app.db PRAGMA integrity_check; sqlite3 /tmp/sync-b/app.db PRAGMA integrity_check;可能输出database disk image is malformed也可能输出一堆类似row X missing from index sqlite_autoindex_t_1的结构错误。3.3 数据丢失的直观验证即便没有立刻损坏你也会发现两边的数据“对不上”。在一端查询表里的max(id)与另一端不同。因为两端各自提交了自己的事务Syncthing 无法把两端的事务合并成一份一致的数据它只会选择保留一份文件并创建冲突副本。这个复现已经能说明问题只要两端同时写SQLite 文件和它的锁、日志文件就会被同步机制“拆散”数据库损坏只是时间问题。4. 原理拆解为什么 SQLite 和 Syncthing 天生冲突4.1 文件锁在同步环境中的失效SQLite 事务的写流程大致如下应用执行BEGIN IMMEDIATE或写入语句。SQLite 对数据库文件加写锁。根据 journal 模式生成日志文件。写入主数据库文件。提交事务后释放锁删除日志文件。关键点在于这个锁是操作系统文件锁它的作用域是“同一台机器上的进程”。Syncthing 在另一台设备上复制这个文件时不会同步锁状态也不理解 SQLite 的日志协议。它看到的是文件内容变化于是把文件整体或分块传输到另一端。如果传输发生在事务提交前另一端可能会看到一个“中间状态”的数据库文件。如果传输发生在事务提交后另一端可能又把旧版本文件同步回来覆盖新版本。最终导致文件内容不是任何一个完整事务的结果。4.2 journal 模式加剧问题SQLite 常见三种日志模式模式写事务时生成的文件特点DELETEapp.db-journal事务完成立即删除 journal 文件TRUNCATEapp.db-journal事务完成截断 journal 文件WALapp.db-wal、app.db-shm写入先进入 WAL 文件checkpoint 后才合并回主库在 DELETE/TRUNCATE 模式下如果app.db-journal文件被 Syncthing 同步到另一台设备而主数据库文件还没有同步完整另一端的 SQLite 打开时可能误认为数据库处于“需要从 journal 恢复”的状态从而出现各种恢复错误。WAL 模式看似更安全因为写入先进 WAL 文件。但 Syncthing 同步app.db和app.db-wal是分开独立同步的两个文件无法保证同一声明周期。两端可能看到不同版本的 WAL 文件与不同的主库文件错配数据库照样会报 malformed 或直接拒绝打开。4.3 冲突文件只是“尽力而为”的兜底Syncthing 检测到两端文件同时变化时会保留一个冲突文件。命名格式大致如下app.db.sync-conflict-20250415-101530-ABCDEF.db这说明 Syncthing 知道两端发生了冲突但它无法解决 SQLite 事务层面的冲突。它只能保留一个“幸存者”版本并把另一个版本改名为冲突文件。对于 SQLite 来说这个冲突文件可能是一份尚可读取的数据库也可能是已经损坏的数据库。无论哪种情况业务层面的数据合并都变成了棘手的人工操作。4.4 一句话切中要害文件同步工具解决的是“多端复制”问题SQLite 需要的是“单机文件锁和事务完整性”。把 SQLite 数据库文件交给 Syncthing 同步等于让两个写者同时写同一个文件并且没有协调机制。这不是配置问题是架构问题。5. 解决方案五种可落地的替代方案5.1 方案 A不要把 SQLite 放进同步目录这是最推荐、最省心的方案。Syncthing 同步目录只放文档、配置文件、导出文件SQLite 数据库单独放在本地磁盘。如果你需要数据库备份可以用 SQLite 的在线备份 API 在应用内生成一个备份文件再把备份文件交给 Syncthing 同步。Python 示例import sqlite3 import shutil src sqlite3.connect(live.db) dst sqlite3.connect(backup/live-backup.db) with dst: src.backup(dst) dst.close() src.close() # 然后由 Syncthing 同步 backup 目录中的 live-backup.db这样 Syncthing 同步的是“某个时间点的完整备份”而不是正在被写入的活跃数据库文件安全性大大提高。5.2 方案 B单向同步从库只读如果业务场景是“一台设备负责写其他设备只是读取副本”可以把 Syncthing 配置为单向同步写设备设置为 “发送仅”。只读设备设置为 “接收仅”。这样只有一端会修改数据库Syncthing 将数据从主设备分发到从设备。从设备上的应用要用只读模式打开数据库不能执行写操作。如果从设备必须写可以写到本地其他数据库再通过 API 同步回主设备。5.3 方案 C同步导出文件而不是 .db 文件与其同步数据库文件本身不如在应用层增加导出和导入能力主节点定期将 SQLite 表导出为 JSON、CSV 或 SQL 文件。Syncthing 同步这些结构化文本文件。从节点解析文件后写入本地 SQLite。这样 Syncthing 同步的只是普通文本即使发生冲突也不会损坏数据库。对于笔记类应用、轻量数据展示端来说这个方案成本低、稳定。5.4 方案 D换成真正支持分布式的数据库如果确实需要多台设备实时写入同一个业务库应该评估更适合的数据库使用 PostgreSQL/MySQL走网络协议访问由数据库自身处理并发控制。使用 Litestream 做 SQLite WAL 归档将 WAL 文件持续备份到远端对象存储。使用 rqlite 或 Dqlite 这类基于 Raft 协议的 SQLite 衍生方案支持多节点一致性。这些方案各有优缺比如引入运维成本、网络依赖等需要结合项目规模评估。但如果你的核心诉求就是“多人同时写一份数据”继续使用 Syncthing 加 SQLite 是不现实的。5.5 方案 E极低写入频率下的降级方案如果业务场景非常简单例如只有一个脚本每天写一次数据库可以在应用层增加一个“全球锁约定”所有写入节点都遵守同一个互斥规则确保同一时间只有一个节点在写数据库文件。import os LOCK_FILE /sync-folder/.db-write.lock def acquire_lock(): if os.path.exists(LOCK_FILE): raise RuntimeError(another node is writing) open(LOCK_FILE, w).close() def release_lock(): if os.path.exists(LOCK_FILE): os.remove(LOCK_FILE)注意这只是一个应用层约定不是分布式强一致锁。它只能应对“操作人员手动遵守约定”的低风险场景不能作为生产级方案。如果加锁后 Syncthing 刚好把 lock 文件同步到另一端另一端依然可能误判。6. 常见问题与排查清单6.1 错误现象与排查思路问题现象常见原因解决思路database disk image is malformed数据库文件在写入过程中被同步或覆盖停止同步用最近备份恢复检查冲突文件打开数据库报journal file is not recognizedjournal/wal 文件被独立同步导致错配删除同步目录中的 journal/wal 文件恢复备份两端的查询结果不一致两端各自提交了事务文件频繁冲突覆盖检查.sync-conflict-*文件合并业务数据数据库文件没有损坏但数据“倒退”旧版本文件覆盖了新版本从冲突文件中恢复数据评估最近写入WAL 模式下主库文件很小但查不到数据WAL 入库文件被同步、主库和副本错配用备份恢复或合并 WAL 文件前先停止同步6.2 用 DB Browser for SQLite 验证损坏情况在 Windows 或桌面环境中可以用 DB Browser for SQLite 打开疑似损坏的 db 文件打开app.db。点击菜单栏“数据库” - “完整性检查”。如果提示ok说明结构尚未损坏可以尝试导出表数据。如果提示 malformed可以尝试导出 SQL 脚本尽量保留可恢复的表。注意DB Browser 打开数据库时如果检测到冲突日志文件可能会询问是否恢复。请先确认该日志文件是本地事务产生的而不是从其他设备同步过来的再执行恢复操作。6.3 威联通安装 Syncthing 后服务无法启动如果你是在威联通QNAP上通过 App Center 或 Container Station 安装 Syncthing遇到服务无法启动的问题可以按以下顺序排查确认 App Center 中 Syncthing 的版本与 QTS 版本是否兼容。检查共享文件夹的读写权限Syncthing 至少需要读写同步目录和配置目录。查看系统日志或容器日志确认是否有端口占用默认 Web UI 端口为 8384。在 Container Station 中重启容器观察启动日志是否出现权限错误。不同 QTS 版本的自带容器能力差异较大社区套件更新也未必及时如果多次启动失败可以考虑改用 Docker 方式手动部署。6.4 TrueNAS SCALE 安装 Syncthing 报 failed up actionTrueNAS SCALE 的官方应用基于 Kubernetes 编排如果出现类似failed up action for syncthing app的错误常见原因包括应用版本与当前 TrueNAS 版本不匹配。存储卷的权限或路径配置不对。系统没有正确创建持久化卷。应用依赖的服务未就绪。排查步骤建议在 TrueNAS 的 Applications 页面打开 Syncthing 应用的 Events 和 Logs查看具体错误。检查存储路径是否由 TrueNAS 正常挂载权限是否允许应用写入。尝试卸载应用后重新安装选择较新的可用版本。如果官方 train 里的 Syncthing 一直异常可以使用社区提供的 train 或直接使用 TrueNAS 的 Docker 兼容层运行 Syncthing 容器。由于报错信息通常不会直接说明根因实际排查时还是要以具体日志为准。7. 工程实践建议让 SQLite 和同步工具各司其职7.1 明确同步边界在项目设计阶段就要清楚数据目录的归属同步目录适合放普通文件比如文档、图片、配置文件、导出备份。本地目录适合放数据库文件、临时文件、敏感凭证。SQLite 数据库文件不要直接出现在 Syncthing 的根目录中更不要在两边同时被业务进程打开写入。7.2 Syncthing ignore patterns即使采用“备份文件同步”方案也可以配置.stignore把 SQLite 的临时文件全部排除防止无意中同步到其他设备// 排除 SQLite 临时文件和 WAL 文件 *.db-journal *.db-wal *.db-shm // 排除 Syncthing 冲突文件 *.sync-conflict-* // 排除锁文件 .lock.stignore文件放在同步目录的根目录中并需要由 Syncthing 自动加载。如果是多设备场景每个设备都要有相同的 ignore 文件或使用文件夹级配置。7.3 SQLite 备份策略不要在数据库运行过程中直接复制.db文件。正确做法包括sqlite3 live.db .backup backup.db或使用 SQLite 在线备份 API。这样可以保证备份文件是一个完整一致的事务快照。之后再把备份文件移动到同步目录交给 Syncthing 同步到其他设备。7.4 版本迁移要使用用户版本号如果你在本地正常使用 SQLite后续需要升级表结构建议通过应用层管理 schema 版本而不是直接在生产库上手工执行 DDL。import sqlite3 def migrate(conn): version conn.execute(PRAGMA user_version;).fetchone()[0] if version 1: conn.execute(CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);) conn.execute(PRAGMA user_version 1;) conn.commit()这样每次升级都记录在案。使用PRAGMA user_version管理数据库结构版本比临时手工加字段更安全团队协作时也不会互相覆盖。7.5 多设备读取 SQLite 的推荐结构一个稳妥的推荐结构如下生产机器 /data/live/app.db 本地活跃数据库不参与同步 /data/export/app-20250415.db 定时备份生成的快照 /sync/ 同步目录只放 export 文件 远端机器 /sync/ 通过 Syncthing 收到快照文件 /data/local/app.db 本地读取库由导入脚本从快照恢复SQLite 存活在“本地应用 本地磁盘”的环境里Syncthing 只负责把快照送到远端。这样两端都有数据可用而且不会发生文件锁冲突。7.6 安全与权限边界无论是 Syncthing 还是 SQLite都涉及数据安全Syncthing 设备 ID 等同于设备身份凭证不要随意添加未知设备。数据库文件和备份文件应设置合理的文件权限避免普通用户直接读取。对数据库执行删除、覆盖、恢复操作前必须先手动备份并在测试环境验证恢复流程。如果需要在生产环境变更数据库结构优先使用迁移脚本而不是直连生产库执行高风险 DML。8. 经验收尾Syncthing 和 SQLite 都是优秀的开源工具但它们解决的问题不在同一个层级。Syncthing 负责文件复制SQLite 负责本地事务一致性把活跃的 SQLite 数据库文件直接放进同步目录本质上是把数据库一致性交给你无法控制的外部文件操作。如果你只是需要把 SQLite 数据库备份同步到另一台设备或者把一份“只读快照”分发给多个终端先把数据和文件同步工具的边界划分清楚把活跃数据库文件和同步目录隔离开再配合定时备份脚本和 ignore patterns就不会再出现database disk image is malformed这种让人头皮发麻的报错。这篇文章的代码和方案都偏向实践遇到具体环境差异时比如不同 NAS 的 Syncthing 安装入口或不同版本镜像的目录结构可以按自己的实际环境做小幅调整。核心思路不变不让同步工具直接操作正在被 SQLite 写入的数据库文件。如果你也踩过类似的坑欢迎把遇到的现象补充到评论区方便后来者更快定位问题。