SQLite 的下载、安装与连接,这三件事摆在一起看像是入门第一课,但我在实际项目里被卡住的地方,恰恰就是这三件"最不该出问题"的事。有人把压缩包解压完发现命令行敲sqlite3提示找不到命令;有人在 .NET 项目里dotnet run一切正常,发布成单文件后在客户机器上弹出"无法加载 DLL e_sqlite3";还有人数据库明明建好了,程序跑起来却查出零条记录,最后发现是连到了一个刚被自动创建的空文件上。这些问题没有一个属于"SQL 写错了"的范畴,它们全都发生在下载、安装、连接这条链路上。
这篇内容面向的是刚开始把 SQLite 塞进自己项目里的开发者,也面向那些用过但一直没搞明白"它为什么没有安装程序"的人。我会把这三步拆开,讲清楚每一步的决策依据、实操命令,以及那些文档里通常一笔带过、但实际会咬人的细节。文中涉及的平台操作和驱动选型都基于常见实践整理,具体版本行为请以你手上的实际环境为准。
1. 重新定义"安装":SQLite 装进去的到底是什么
1.1 它没有服务进程,所谓安装只是让系统找得到那个库
绝大多数人第一次接触关系型数据库是从服务端产品开始的:装一个服务、配一个端口、设一个账号密码、再开一个客户端连上去。SQLite 完全不走这套流程。它没有守护进程,不监听端口,没有用户和权限体系,一个.db文件就是全部数据。你在程序里调用它,本质上是把一段 C 代码加载进了自己的进程地址空间,数据库操作就是普通的函数调用和文件读写。
这个结构差异直接决定了"安装"的含义。对服务端数据库来说,安装是把可执行文件放到系统目录并注册成服务;对 SQLite 来说,安装只是把库文件(Windows 上是.dll,Linux 上是.so,macOS 上是.dylib)或者静态库放到编译器、链接器、运行时能找到的位置。命令行工具sqlite3也只是个普通的可执行程序,跟数据库引擎本身是两回事——你可以只有库没有 CLI,也可以只有 CLI 而项目里用另一个版本。
理解这一点的价值在于:当你遇到"SQLite 版本不一致"的问题时,你会知道去查是谁把库带进来的。一个项目里同时存在三份 SQLite 是极其常见的事——操作系统自带一份、语言运行时内置一份、你的依赖包又自带一份。它们互不干扰,但会让你在 CLI 里验证通过的功能,在程序里报"no such function"。
提示:判断某个环境里到底用的是哪份库,最直接的办法是在连接建立后执行
select sqlite_version();,和 CLI 里执行同一句的结果对比。两个数字不一样,说明你面对的是两个不同的引擎实例。
1.2 三条获取路径各自的适用边界
拿到 SQLite 的方式主要有三种,选哪种取决于你要用它做什么,而不是哪种"更正规"。
| 获取方式 | 典型产物 | 适合场景 | 主要代价 |
|---|---|---|---|
| 官方预编译包 | sqlite3.exe、sqlite3.dll、静态库 | Windows 上快速验证、需要特定版本的 DLL 随程序分发 | 需要自己管理 PATH 和位数匹配 |
| 系统包管理器 | sqlite3命令、libsqlite3-dev | Linux 服务器、CI 环境、脚本里临时用 | 仓库版本通常落后几个小版本 |
| 源码合并包编译 | sqlite3.c+sqlite3.h | 需要开启特定编译开关、需要静态链接、交叉编译 | 需要工具链,编译参数要自己定 |
预编译包的好处是拿来即用,坏处是它只解决"文件在哪里",不解决"版本怎么管"。包管理器省事,但你在生产环境里遇到某个新版本才修掉的 bug 时,会发现仓库里根本没有那个版本。源码合并包(amalgamation)把整个引擎压成一个大.c文件加一个头文件,这是 SQLite 官方推荐的集成方式,很多语言驱动的二进制 wheel 或预编译产物就是这么打出来的。
我个人的取舍是:开发机上用包管理器或者预编译包把 CLI 装好,方便随手验证;项目里一律通过语言生态的依赖管理去锁定版本,绝不依赖系统自带的那一份。原因很简单——系统那份什么时候被升级、被替换、被裁剪,你控制不了。
1.3 版本号和编译开关,比"装上没装上"更值得关心
SQLite 的版本号是主.次.补丁三段。中间那位变化意味着可能有新的 SQL 语法、新的函数、行为上的调整;补丁位则是纯修 bug。这个规律值得记住,因为你排查"为什么这句 SQL 在同事机器上能跑、在我这里报错"时,第一件事就是对比中间那位数字。
比版本号更容易被忽略的是编译开关。同一份源码,编译选项不同,能力就不同:
SQLITE_ENABLE_FTS5:全文检索模块。不开就没有CREATE VIRTUAL TABLE ... USING fts5。SQLITE_ENABLE_RTREE:空间索引模块,处理地理范围查询要用。SQLITE_ENABLE_COLUMN_METADATA:让驱动能拿到结果集的列名、表名、原始列名。不少 ORM 和查询构建器依赖它,缺了会报出一堆莫名其妙的错。SQLITE_THREADSAFE:线程模式。取 1 是序列化模式(多线程可共用一个连接),取 2 是多线程模式(连接不能共享,但不同连接可并行),取 0 是单线程。绝大多数预编译产物是 1。SQLITE_OMIT_*系列:用来裁掉不需要的功能以减小体积,比如SQLITE_OMIT_LOAD_EXTENSION、SQLITE_OMIT_DEPRECATED。移动端和嵌入式场景常见,桌面端少见。
这就是为什么"我从官网下了最新版,功能却用不了"这种情况真实存在——你用的那份可能是某个运行时为了瘦身自己裁过的。查编译选项的办法是在 CLI 里执行pragma compile_options;,它会把你手上这份库的所有开关列出来,非常实用。
2. 分平台落地:下载包里到底该挑哪个文件
2.1 Windows:几个压缩包的区别与位数陷阱
Windows 上没有安装程序,官方提供的是一组 zip。第一次打开下载页很容易懵,因为文件名长得很像。
sqlite-tools-win-x64-*.zip:里面是命令行工具,核心是sqlite3.exe,另外还有sqldiff.exe(比较两个库的差异)和sqlite3_analyzer.exe(分析表和索引的空间占用)。你要"在命令行里敲 sqlite3",要的就是这个。sqlite-dll-win-x64-*.zip:里面是sqlite3.dll和sqlite3.def。.def文件是用来生成导入库的,需要在开发人员命令提示符里跑lib /def:sqlite3.def /machine:x64生成.lib,链接时才用得上。sqlite-amalgamation-*.zip:源码合并包,包含sqlite3.c、sqlite3.h、sqlite3ext.h。要在 C/C++ 项目里静态集成,或者自己控制编译开关,用这个。
实操上最省事的做法是把 tools 包解压到一个固定目录,比如D:\tools\sqlite3,然后把这个目录加到用户环境变量Path里。加完之后新开一个终端再敲sqlite3 --version,老终端不会重新读环境变量,这一条每年都要骗到一批人。
位数是最容易踩的坑。x64 的进程加载不了 x86 的 DLL,反过来也一样。在 .NET 里表现是BadImageFormatException,在某些场景下表现为"找不到指定的模块"。判断方法很直接:任务管理器里看进程是不是带(32 位)后缀,或者命令行跑echo %PROCESSOR_ARCHITECTURE%。如果项目要同时支持两种位数,就得准备两份 DLL 分目录存放,用运行时判断去加载。
还有一个细节:路径里有空格或者中文时,某些老版本驱动在解析连接字符串时会出问题。把数据库文件放到一个纯英文、无空格的路径下,能省掉一整类排查工作。这不是 SQLite 引擎的限制,是连接字符串解析层的毛病。
2.2 Linux 与 macOS:仓库版本偏旧时的处理思路
Linux 上用包管理器装是最快的。Debian 系是apt install sqlite3 libsqlite3-dev,Red Hat 系是dnf install sqlite sqlite-devel,Arch 系是pacman -S sqlite。libsqlite3-dev那一份是给编译用的,包含头文件和.so链接;只要 CLI 的话装sqlite3就够了。
问题在于发行版仓库里的版本往往落后。LTS 发行版尤其明显,可能差好几个次版本号。这时候有两条路:一是从源码编译安装到/usr/local,二是用官方预编译的静态库或合并包自己链接。前者装完记得跑一次ldconfig,否则动态链接器找不到新装的.so,表现是编译通过但运行时报error while loading shared libraries。
macOS 的情况稍微特殊一点:系统自带一份sqlite3,位于/usr/bin/sqlite3。这一份不建议动,也不建议覆盖,因为系统完整性保护会拦着你,就算硬来,系统更新之后也可能被还原。要新版就用包管理器另装一份,装完注意PATH的顺序——如果/usr/bin排在前面,你敲sqlite3调用的还是旧的那份。用which -a sqlite3能看到所有候选,用sqlite3 --version确认当前生效的到底是哪个。
源码编译的典型命令长这样:
./configure --prefix=/usr/local \ --enable-fts5 \ --enable-rtree \ --enable-column-metadata \ --enable-threadsafe make -j$(nproc) sudo make install sudo ldconfig--enable-column-metadata那一条很多教程会漏掉,但如果你后面用 ORM,加上它能避免一类很难定位的报错。编译参数怎么选,取决于你要用什么功能,宁可多开几个也不要事后返工——重新编译一次的成本比排查一个"为什么这个函数不存在"要高得多。
2.3 语言运行时自带的私有副本,才是版本混乱的根源
Python 的标准库自带sqlite3模块,底层链接着一份 SQLite。这份库的版本跟你的 Python 版本绑定,跟你系统装的那份没关系。查版本的办法是python -c "import sqlite3; print(sqlite3.sqlite_version)"。如果你在 CLI 里跑某个新语法没问题,在 Python 里报错,多半就是这两份版本不一致。
Node 生态里,better-sqlite3这类包会在安装时下载或编译对应的原生模块,每个 Node 大版本升级都可能需要重新构建。.NET 生态里,Microsoft.Data.Sqlite依赖一个叫 SQLitePCLRaw 的中间层来提供原生库,默认会带一个e_sqlite3的预编译产物。Java 的 JDBC 驱动通常自带各平台的原生库,解压即用。Go 的驱动分两派,一派需要 C 编译器参与,一派是纯实现不需要。
这种"每个生态各带一份"的设计有它的合理性——保证开箱即用、不依赖系统环境。代价就是你得接受"项目里的 SQLite 和系统里的 SQLite 是两个东西"这个事实,并且在排查问题时明确知道自己在跟哪一个打交道。养成在应用启动日志里打一行select sqlite_version()的习惯,能省掉大量"是不是版本问题"的猜测。
2.4 编译开关里真正会咬人的几个
前面列了开关清单,这里说几个实际会出事的。
SQLITE_THREADSAFE=0在嵌入式场景很常见,但如果你的应用是多线程的,多个线程同时用一个连接会直接崩溃,不是报错,是段错误或者数据损坏。用之前必须确认调用模型。
SQLITE_ENABLE_COLUMN_METADATA缺失时,某些 ORM 在生成实体映射时会拿不到列信息,报出的错误往往是InvalidOperationException之类,跟 SQLite 一点关系都看不出来。
SQLITE_DEFAULT_FOREIGN_KEYS这个开关决定外键约束是不是默认打开。默认情况下外键是关的,需要每个连接手动PRAGMA foreign_keys = ON。有的发行版编译时打开了这个默认值,有的没打开,于是同一份代码在不同机器上行为不同——在 A 机器上删父表记录会报约束冲突,在 B 机器上直接就删掉了,留下孤儿数据。这个坑我在一次迁移中真切地踩过,后面会细说。
3. 连接:从命令行验证到驱动接入
3.1 先用 CLI 把"能连上"这件事验证干净
在写任何一行应用代码之前,先用命令行确认引擎本身是好的。这一步能帮你把问题范围缩小一半。
sqlite3 --version sqlite3 :memory: "select sqlite_version(), sqlite_source_id();"第一句确认 CLI 找到了,第二句确认引擎能跑起来,而且用的是内存库,不会在磁盘上留任何东西。
接下来验证文件连接:
sqlite3 /tmp/demo.db进入交互界面后:
.databases .tables .schema .headers on .mode column select 1; .quit.databases会显示当前连接的主库和临时库路径,这个命令在怀疑"我到底连到哪个文件了"时特别有用。
这里必须强调一个高频误判:SQLite 连接一个不存在的文件时,不会报错,它会直接创建一个空库。所以当你发现程序查不出数据时,第一反应应该是确认路径对不对,而不是怀疑查询语句。我见过不止一次,工作目录不同导致连到了另一个同名空文件上,查了半天 SQL。防止办法是在连接之后立刻执行一次select count(*) from sqlite_master;,如果返回 0 且你确信库里应该有表,那就是连错地方了。
3.2 连接字符串、URI 文件名与几种特殊连接方式
除了普通的文件路径,SQLite 还支持 URI 形式的文件名,用来表达"只读""内存""共享缓存""不可变"这些额外语义。这个能力默认不一定开启,很多驱动需要显式打开。
| 写法 | 含义 | 典型用途 |
|---|---|---|
file:app.db?mode=ro | 以只读方式打开 | 只做查询的分析工具、防止误写的报表服务 |
file::memory:?cache=shared | 同一进程内共享的内存库 | 单元测试、临时计算 |
file:app.db?immutable=1 | 声明文件不会被外部修改 | 只读介质、避免额外的文件锁开销 |
file:app.db?mode=rw | 文件必须存在,不存在就报错 | 避免自动创建空库的误判 |
immutable=1这个参数值得单独说一句。它告诉引擎"这个文件不会变",于是引擎可以跳过所有文件锁检查。放在只读介质上(比如打包进容器的静态数据)能显著提速,但前提是真的不会变——如果期间有别的进程写它,读到什么就不好说了。
.NET 这边用的是关键字形式,写法不太一样:
var builder = new SqliteConnectionStringBuilder { DataSource = "app.db", Mode = SqliteOpenMode.ReadWriteCreate, Cache = SqliteCacheMode.Shared, ForeignKeys = true, Pooling = false }; using var conn = new SqliteConnection(builder.ToString()); conn.Open();ForeignKeys = true这个属性是Microsoft.Data.Sqlite提供的语法糖,等价于每次连接后执行PRAGMA foreign_keys = ON。Pooling默认是开的,连接被复用时会保留上一次的 PRAGMA 状态,这在某些场景下会造成迷惑——你以为新连接是干净状态,其实继承了旧连接的模式。遇到"同样的代码行为不一致",可以先把池化关掉试试。
3.3 几种主流运行时的最小可用片段
光说不练没意义,下面给的是能直接跑起来的最小片段,重点在于把连接、PRAGMA 初始化、事务这三件事的先后顺序摆对。
Python 标准库:
import sqlite3 conn = sqlite3.connect("app.db", timeout=5.0, isolation_level=None) conn.execute("pragma journal_mode = wal") conn.execute("pragma foreign_keys = on") conn.execute("pragma busy_timeout = 5000") with conn: conn.execute("create table if not exists notes(id integer primary key, body text)") conn.execute("insert into notes(body) values (?)", ("hello",)) for row in conn.execute("select id, body from notes"): print(row) conn.close()isolation_level=None关闭了 Python 层的隐式事务管理,让你自己控制BEGIN和COMMIT。它对批量写入性能影响很大,后面细说。timeout参数对应 busy handler 的等待时间,不给的话遇到锁就立刻抛异常。
Node 侧用同步式驱动:
const Database = require('better-sqlite3'); const db = new Database('app.db'); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec('create table if not exists notes(id integer primary key, body text)'); const insert = db.prepare('insert into notes(body) values (?)'); const insertMany = db.transaction((rows) => { for (const r of rows) insert.run(r); }); insertMany(['a', 'b', 'c']); console.log(db.prepare('select count(*) as n from notes').get()); db.close();db.transaction()把一批写入包在一个事务里,这一层的性能差距是数量级的,不是百分比。
Java 走 JDBC:
String url = "jdbc:sqlite:app.db"; try (Connection conn = DriverManager.getConnection(url); Statement st = conn.createStatement()) { st.execute("pragma journal_mode = wal"); st.execute("pragma foreign_keys = on"); st.execute("create table if not exists notes(id integer primary key, body text)"); try (PreparedStatement ps = conn.prepareStatement("insert into notes(body) values (?)")) { ps.setString(1, "hello"); ps.executeUpdate(); } }Go 这边参数写法因驱动而异,需要 C 编译器的驱动通常用下划线形式放在 DSN 后面,纯实现的那一派用_pragma=的写法。切换驱动时记得改连接串,这类差异不会报编译错误,只会让 PRAGMA 静默失效。
3.4 驱动选型:原生绑定还是纯实现
选驱动不该只看"哪个 star 多",得看你项目的分发形态。
| 维度 | 原生绑定(调用 C 库) | 纯托管/纯实现 |
|---|---|---|
| 性能 | 接近理论最优 | 略低,通常差距在 10%~30% |
| 编译工具链 | 部分需要 C 编译器 | 不需要 |
| 单文件发布 / AOT | 要单独处理原生库的携带和路径 | 天然友好 |
| 跨平台交叉编译 | 要为每个目标平台准备二进制 | 一次构建,处处运行 |
| 新特性跟进 | 跟上官方版本很快 | 通常滞后一段时间 |
做桌面工具、需要打包成单个可执行文件分发的时候,纯实现那一派的优势非常明显——省掉了"原生库怎么跟着走、怎么被找到"这一整类问题。做数据密集的批处理,原生绑定的性能优势还是值得的。
我个人的判断标准是:如果这个程序要装到别人的机器上,优先选纯实现,运维成本比那点性能差重要得多;如果跑在自己的服务器上、且写入量确实大,那就用原生绑定,反正环境可控。
4. 连上之后马上遇到的三个"默认值陷阱"
4.1 外键约束默认是关的,这是设计选择不是 bug
SQLite 从很多年前就支持外键,但默认是关闭的。原因是历史兼容——早期版本没有外键,大量存量库依赖"插入了不存在的父键也能成功"这个行为。为了不破坏它们,官方选择默认关闭。
后果是:你写了references子句,建表语句也通过了,但插入孤儿数据照样成功,删除父记录也不会被拦。代码在测试环境跑得好好的,上线后数据一致性出了问题才发现约束从来没生效过。
打开方式是每条连接执行一次:
pragma foreign_keys = on;注意是"每条连接"。这是连接级设置,不是数据库级设置,写进建表脚本没用。想验证是否生效,执行pragma foreign_keys;,返回 1 才算开了。同一个连接里,在事务中间切换这个开关是不生效的,必须在事务外设置。
有些 ORM 或驱动提供了连接字符串级别的开关,本质上是在Open()之后自动执行了这条 PRAGMA。用之前确认一下它到底有没有真的执行,别只看文档写了支持。
4.2 类型是"亲和性",不是约束
建表时写create table t(id integer primary key, price numeric, ts datetime),看起来很像那么回事,但 SQLite 并不会因为ts声明成datetime就拒绝一个字符串。它只有五种存储类:NULL、INTEGER、REAL、TEXT、BLOB,列上声明的类型只是"亲和性",用来影响值的转换倾向。
这带来两个实际影响。第一,日期时间没有原生类型,存文本(ISO 8601 格式)还是存整数(Unix 时间戳)得自己定规矩,而且要在全项目统一。混着用的话,where ts > '2024-01-01'这种查询会给出让你怀疑人生的结果——字符串比较和数值比较完全是两回事。
第二,decimal这类精确小数类型没有对应的存储类。金额字段如果直接用REAL存,浮点误差会在累加时慢慢显现。稳妥做法是存整数分,或者存字符串,在应用层做精确计算。
integer primary key是个特例,它实际上是rowid的别名,插入时不给值会自动分配,而且这个分配是递增复用空闲位的。如果你的业务需要一个严格递增、不复用的编号,得自己维护序列表,不能指望它。
4.3 database is locked 的成因往往不是并发太高
这个报错几乎是所有 SQLite 使用者都会遇到的第一个"生产问题"。看到它的第一反应通常是"并发太高了,要不要换数据库",但实际情况多半是另一回事。
SQLite 在同一时刻只允许一个写者。写操作开始时会拿排他锁,直到事务提交才释放。所以只要有一个长事务在写,其他写请求就会拿到SQLITE_BUSY。常见的长事务来源包括:把用户交互放在事务里(等用户点确认才提交)、在事务中间做网络请求、批量插入几千条却没有分批提交、以及在 Python 里因为isolation_level默认值导致一个隐式的写事务一直挂着没提交。
处理顺序我一般是这样排的:
第一步,给所有连接设 busy timeout。默认情况下遇到锁是立即返回失败,设了超时之后引擎会自己重试一段时间,绝大多数瞬时竞争就被消化掉了。
pragma busy_timeout = 5000;第二步,切换到 WAL 日志模式。WAL 下读不阻塞写、写不阻塞读,只有一个写者的限制还在,但读写之间的冲突基本消失了。
pragma journal_mode = wal;第三步,检查事务边界。把批量写入明确包成BEGIN...COMMIT,并且尽量用BEGIN IMMEDIATE而不是默认的BEGIN。默认模式是延迟的,事务开始时只拿读锁,等到第一条写语句才尝试升级成写锁;如果这时候已经有别人拿了写锁,升级会失败,而且这个失败在某些情况下没法靠重试解决。BEGIN IMMEDIATE在事务开始就拿写锁,要么立刻成功,要么立刻等待,行为可预测得多。
第四步,确认数据库文件不在网络共享盘上。SMB、NFS 这类文件系统的锁语义跟本地文件系统不一样,SQLite 在这种环境下可能给出不可靠的结果,官方的立场是不建议。这条我吃过亏,一个放在共享目录里的库偶尔报损坏,查了很久才怀疑到文件系统上。
5. 初始化代码里该写死的那几条 PRAGMA
5.1 参数之间有顺序和副作用
把 PRAGMA 一股脑塞进初始化函数是可以的,但有几条的特性需要知道。
journal_mode是持久化的。设成 WAL 之后写进数据库文件头,下次打开还是 WAL,不需要每次重设。它是唯一一个会改变文件状态的日志模式参数。返回值是设置后的实际模式,如果返回delete说明没设成功——常见原因是这个库正被其他连接以不兼容的模式打开着。
synchronous是连接级的,不持久化。WAL 模式下推荐设成NORMAL,此时崩溃可能导致最近若干次提交丢失,但数据库本身不会损坏。设成FULL更安全但每次提交都 fsync,写入性能会下降明显。对大多数应用来说 WAL + NORMAL 是合理的平衡点。
cache_size用负数表示 KiB。-20000就是约 20MB 的页缓存。默认值很小(大约 2MB),在稍大的库上会成为瓶颈。
mmap_size让引擎用内存映射读文件,能减少系统调用。64 位系统上设个几百 MB 通常有收益,32 位地址空间有限,设了也可能被忽略。
temp_store = MEMORY把临时表和排序的中间结果放内存,避免落盘。处理大结果集的order by或group by时差别明显,但要注意内存占用。
一条相对新的建议:在连接关闭前执行pragma optimize;。它会让引擎根据这次的查询模式更新统计信息,帮助下次生成更好的执行计划。开销很小,长期跑的服务里值得加上。
5.2 WAL 带来的三个新问题
WAL 解决了不少问题,但它引入了自己的麻烦,这几点必须提前知道。
文件变多了。除了app.db,还会出现app.db-wal和app.db-shm。备份、复制、打包时如果只拷了主文件,恢复出来的数据可能缺最近的事务。要拿到一致快照,得用后面的方法,不能简单cp。
只读场景会翻车。一个处于 WAL 模式的库,以只读方式打开时,引擎仍然需要在同目录下创建或写入-shm文件。如果目录本身不可写,就会报attempt to write a readonly database——注意,是在纯读操作上爆出来的这个错,非常具有迷惑性。解决办法有两个:一个是先把日志模式改回delete再发布(适合只读分发的场景),另一个是用immutable=1打开(前提是文件确实不会变)。
检查点需要关注。WAL 文件会随着写入不断增长,引擎会在合适的时机做检查点把内容合并回主库。默认的自动检查点阈值是 1000 页,一般够用。如果有大批量写入,可以在空闲时手动触发一次pragma wal_checkpoint(truncate);,把 WAL 文件截断回零,避免它膨胀到几百 MB。
5.3 备份与版本升级:别用文件复制
在线备份有两个可靠办法。一个是VACUUM INTO,它会生成一个干净的目标文件,顺带做了碎片整理:
vacuum into '/backup/app-20240101.db';这条路简单直接,适合定时快照。缺点是它会重写全部数据,大库上耗时较长,而且过程中会有额外的磁盘占用。
另一个是备份 API,通过逐步拷贝页的方式完成,可以在线进行、不阻塞读写。各语言驱动基本都封装了它,.NET 里是SqliteConnection.BackupDatabase,Python 里是Connection.backup。做需要持续同步的备份任务,用这个更合适。
版本升级这块,我的做法是给每个库维护一个user_version记号:
pragma user_version; pragma user_version = 3;应用启动时读出来,跟代码里期望的版本号比,低了就按顺序跑迁移脚本。这个方案土,但足够用,而且不引入额外依赖。迁移脚本本身要能重复执行(用create table if not exists、alter table前先查pragma table_info),免得中途失败后重跑出问题。
单文件分发的另一个注意点:如果你把数据库放在程序目录下,而程序被装到了Program Files这类受保护目录,写入会失败。正确做法是放到用户数据目录,用系统提供的位置 API 去取,别硬编码路径。
6. 现场排查:几条高频报错对应的检查方向
下面这张表是我这些年攒下来的,遇到对应现象时按顺序查,能省掉不少时间。
| 现象 | 优先检查 | 常见根因 |
|---|---|---|
命令行提示找不到sqlite3 | echo %PATH%/which -a sqlite3 | 没加 PATH,或加了但没重开终端 |
| 查询结果永远是空 | 连接后的实际文件路径 | 连到了一个刚被自动创建的空库 |
no such function: json_* | pragma compile_options; | 用的那份库被裁剪过 |
no such module: fts5 | 同上 | 编译时没开全文检索开关 |
无法加载 DLLe_sqlite3 | 运行时标识、发布目录下的原生库 | 位数不匹配,或单文件发布没带上原生库 |
database is locked | 事务长度、journal_mode、busy_timeout | 长事务占着写锁,或没设等待超时 |
| 外键约束完全不起作用 | pragma foreign_keys; | 没在每条连接上打开 |
| 只读环境报"试图写入只读数据库" | journal_mode与目录权限 | WAL 模式需要写-shm文件 |
| 日期比较结果不对 | 该列的存储类 | 文本和数值混存,比较语义不一致 |
| 程序偶发崩溃、数据异常 | 数据库文件所在位置 | 放在了网络共享盘上 |
拿"单文件发布后找不到原生库"这条展开说,因为太典型了。.NET 项目里通过 NuGet 引用了 SQLite 驱动,本地调试一切正常,因为构建输出目录里有runtimes/{rid}/native/下的一堆原生库文件。发布成单文件之后,这些文件没有被打进主程序,或者打进去了但运行时解压路径出了问题,于是启动就崩。
处理思路有两条:一是发布时明确带上原生库,检查发布目录里有没有对应的e_sqlite3.dll、libe_sqlite3.so、libe_sqlite3.dylib;二是干脆换成纯托管实现的那一派,从根上避开这个问题。做跨平台桌面工具的团队,我一般建议直接走第二条,虽然性能差一点,但分发环节的坑少太多了。
还有一条经验:所有跟 SQLite 相关的异常,都要把SqliteException.SqliteErrorCode和SqliteExtendedErrorCode打出来。光看Message经常看不出所以然,比如同样是database is locked,扩展码能区分是普通忙、还是快照冲突、还是只读库。日志里多这几个数字,排查效率提升是实打实的。
写到这里差不多把这条链路上容易出问题的地方过了一遍。我自己的习惯是在项目里做一个db-init的小模块,把所有连接创建、PRAGMA 设置、版本检查集中在一个函数里,任何地方要连数据库都走它,绝不允许业务代码自己new一个连接出来。这个约束看起来有点死板,但正是因为 SQLite 的很多行为是连接级的、默认值是"历史兼容"取向的,把初始化收口起来,才能保证每个连接都处在同一个已知状态里。