一个 AI agent 刚完成一个任务:写了一条迁移、执行了它,并用 fixture 数据填充了 dev.db。总结写着”给 orders.status 加了默认值 'pending',回填了 3,200 行”。你想亲眼确认这件事,而不是直接相信总结。ORM 自动生成一条你没有手写的迁移时,同样的场景会发生;移动端 App 在测试设备上写本地数据库、你把文件拉出来查某个页面为什么空白时,也是一样。

最直接的做法是把行数据粘进 AI 对话框,问哪里出了问题。对大多数真实数据库来说,不该这么做。从 staging 拷贝出来的 dev.db 里有客户邮箱;移动端 App 的数据库里有 session token;CLI 的状态文件里有 API key。这份文件应该由人来读,就在它本来所在的那台机器上。

在查看器中打开 SQLite 文件 →

查看器在浏览器标签页内打开文件:展示表、视图、列、索引和 CREATE 语句,分页浏览数据行,执行你输入的任意 SQL,还能导出 CSV。整个过程不上传任何数据,这个工具页面也不加载统计或广告脚本。本文接下来说明 SQLite 文件内部是什么样子、查看器如何读取它,以及哪些情况下文件显示的内容会和你预期的不一样。

什么时候该用浏览器端 SQLite 查看器

场景你要检查什么为什么浏览器端查看器合适
AI agent 或脚本执行了一次迁移新增的列、默认值、回填的数据、新索引打开文件,看结构面板,跑一条 SELECT,关掉标签页
审查 ORM 生成的迁移SQLite 实际存储的 CREATE TABLE 语句,可能和模型定义不一致查看器原样显示存储的 CREATE 语句
移动 App 本地数据库App 在测试设备上写入的行,比如 Android Room 生成的 .db,或 iOS Core Data 的 .sqlite 存储不用装桌面客户端,文件留在你自己机器上
Electron App 或 CLI 的状态设置、缓存、队列表很多桌面 App 和 CLI 把状态存在单个 SQLite 文件里
检查测试 fixture提交进仓库的 fixture 数据库确认 fixture 里的数据和测试假设的一致
把数据交给同事一次查询结果,不是整个数据库跑查询,导出 CSV,把 CSV 发过去
正式执行前先试一条语句UPDATE 或 DELETE 的效果SQL 跑在内存副本上,磁盘上的文件不会变

需要原地编辑文件、文件特别大、或者是加密数据库时,用本地工具。下面的章节会解释原因,并为每种情况指出该用哪个工具。

SQLite 文件内部是什么

一个 SQLite 数据库就是一个普通文件。完整格式记录在 SQLite Database File Format 页面,其中几个事实就能解释查看器大部分行为的由来。

100 字节的文件头

每个数据库文件的前 100 字节是文件头,其中前 16 字节固定不变:

53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
S  Q  L  i  t  e     f  o  r  m  a  t     3  \0

这是 UTF-8 字符串 SQLite format 3 加一个 nul 字节。不以这 16 字节开头的文件,就不是普通的 SQLite 3 数据库。文件扩展名说明不了任何问题:.db、.sqlite、.sqlite3、.db3 都很常见,而且不少 .db 文件其实是别的东西。

文件行为异常时,文件头里还有几个字段有用:

偏移量大小字段
162 字节页大小(大端序)
181 字节文件格式写入版本
191 字节文件格式读取版本
404 字节schema cookie(每次 schema 变更都会递增)
564 字节文本编码
604 字节user version(应用常用它做迁移计数器)
684 字节Application ID
964 字节最后写入该文件的 SQLite 库版本号

页大小是 2 的幂。3.7.0.1 及之前的版本支持 512 到 32,768 字节。SQLite 3.7.1(2010 年)加入了 65,536 字节的页。因为 65,536 装不进两个字节,这个值会存成 1。回滚日志(rollback-journal)数据库的第 18、19 字节都是 1,WAL 数据库都是 2——不用打开文件就能看出它用的是哪种日志模式。

schema 表

文件的第 1 页是一张名为 sqlite_schema 的表的根页。老代码和大多数教程叫它 sqlite_master,这个别名现在依然有效。它有五列:type、name、tbl_name、rootpage、sql。每个表、索引、视图和触发器各占一行,sql 列存的是原始 CREATE 语句文本。

以 sqlite_ 开头的名字是 SQLite 自己创建的内部对象,比如给 AUTOINCREMENT 计数器用的 sqlite_sequence,或者给查询规划器统计信息用的 sqlite_stat1。SQLite 不允许应用创建带这个前缀的对象。

查看器怎么读取你的文件

从你拖入文件到看到表列表,中间按顺序发生了这些事。

1. 体积检查。 超过 100 MB 的文件在读取任何字节之前就会被拒绝。原因是内存:文件会先以 buffer 形式从磁盘读入一份,再在 SQLite 引擎内部持有一份。大数据库存两份会让浏览器标签页变得不稳定。

2. 文件头检查。 查看器用 File API 只读取前 100 字节,把前 16 字节和 SQLite format 3\0 比对。文件不足 100 字节或字节不匹配,就会报”Not a SQLite 3 database”错误。文件扩展名从不被信任。

3. 引擎加载。 只有文件头检查通过后,页面才会去拉取 SQLite 引擎。引擎是 sql.js 1.14.2,也就是编译成 WebAssembly 的 SQLite 3.49.1。两个文件压缩后约 340 KB,浏览器只拉取一次。选错文件、传个 PNG 或 CSV 都不会触发这次下载。

4. 内存中打开。 整个文件被读入一个 Uint8Array,传给 new SQL.Database(bytes)。sql.js 把这个数据库保存在一个虚拟的内存文件系统里。从这一步开始,所有查询都跑在这份副本上。

5. 对象列表。 查看器查询 sqlite_master 拿到表和视图。内部的 sqlite_% 对象不会出现在列表里,但你仍然可以在 SQL 编辑器里查询它们。每个表都会算一次 COUNT(*),视图列出时不带行数。

6. 结构信息。 选中一个表或视图时,查看器会执行以下表值 pragma 函数:

SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?);
SELECT name, "unique", origin FROM pragma_index_list(?);
SELECT name FROM pragma_index_info(?) ORDER BY seqno;

这些 pragma 的表值形式从 SQLite 3.16.0(2017 年)开始就有了。结构面板显示每一列的名称、声明类型、NOT NULL、默认值和主键位置。索引面板显示名称、列、唯一性和来源。来源为 c 表示由 CREATE INDEX 创建,u 表示由 UNIQUE 约束创建,pk 表示由 PRIMARY KEY 约束创建。当索引项是 rowid 或表达式时,pragma_index_info 会返回 NULL 作为列名,查看器把这种条目显示为 (expr)。索引下方是 schema 表里原样存储的 CREATE 语句。

7. 数据行。 数据网格用 LIMIT 100 OFFSET n 每次分页 100 行。范围提示行会写”Rows 101–200 of 3,200”,让你随时知道自己看到哪里了。

查看器拼进 SQL 里的每个表名和列名都用双引号包裹,名字里出现的双引号会被转义成两个双引号。名字是 order、user data、"quoted" 的表,以及非拉丁字符的名字,都能正常打开。

「不上传」具体指什么

文件通过 File API 从你的磁盘直接进入页面内存。这个工具发出的唯一网络请求是拉取引擎文件,而且这些文件是同一个站点的静态资源。因为输入是私有数据库,这个工具页面也被配置成跳过站点的统计和广告脚本。关闭或刷新标签页,内存里的副本就没了。

SQLite 里,值的类型不看列怎么声明

读 SQLite 文件时大多数困惑都来自它的类型系统。Datatypes In SQLite 页面有完整说明,简单版是这样的:

  • 一个值有五种存储类型之一:NULL、INTEGER、REAL、TEXT、BLOB。
  • SQLite 用动态类型:类型属于值,不属于列。
  • 声明的列类型只是设定一个亲和性(TEXT、NUMERIC、INTEGER、REAL、BLOB),SQLite 会在插入时尽可能按这个亲和性转换值。

亲和性由声明类型名里的子串规则决定。类型名包含 INT 得到 INTEGER 亲和性;包含 CHAR、CLOB 或 TEXT 得到 TEXT 亲和性,所以 VARCHAR(255) 是 TEXT,255 这个长度会被忽略;包含 BLOB 或者根本没声明类型,得到 BLOB 亲和性;包含 REAL、FLOA 或 DOUB 得到 REAL;其他情况一律是 NUMERIC。

这意味着,只要有代码写过,声明为 INTEGER 的列照样能存字符串 'n/a'。声明了 created_at DATETIME 的 ORM,可能在这一行存 ISO-8601 文本,在另一行存 Unix 时间戳。SQLite 没有日期类型,也没有布尔类型:日期存成 TEXT、REAL(儒略日)或 INTEGER(Unix 时间),布尔值存成整数 0 和 1。

查看器根据取回的值本身渲染每个单元格,不看声明的列类型:

值在网格里在 CSV 导出里
NULL灰色的 NULL 标记空字段
空字符串 ''空单元格空字段
数字(INTEGER 或 REAL)右对齐按存储值原样输出
文本按文本显示,单元格里过长会截断完整值,需要时加引号
BLOBBLOB · 4 B · 89504E47(字节长度和前 16 字节的十六进制)完整内容转成大写十六进制

列的内容看着不对劲时,直接问 SQLite 里面存的是什么:

SELECT typeof(created_at) AS storage_class, COUNT(*)
FROM orders
GROUP BY 1;

如果结果里同时出现 text 和 integer,说明有两条代码路径在用不同格式写这一列。SQLite 3.37.0(2021 年)加入了 STRICT 表,只允许 INT、INTEGER、REAL、TEXT、BLOB、ANY 作为列类型,并拒绝类型不对的值。如果存储的 CREATE 语句以 STRICT 结尾,这张表就不会出现类型混杂的问题。

在内存副本上执行 SQL

数据网格下方的编辑器接受 SQLite 3.49.1 能理解的任何 SQL。按 Run SQL 按钮,或者 Ctrl/Cmd + Enter。

  • 一次执行多条语句。 编辑器里的内容按顺序全部执行,结果网格显示最后一条返回列的语句,比如 SELECT 或 UPDATE ... RETURNING。
  • 错误信息来自 SQLite 本身。 写错一个词,状态栏会显示 SQLite 自己的报错,比如 near "SELEC": syntax error。之前的结果会被清空,不会和新结果混在一起。
  • 改动数据的语句 会报告”Done. N row(s) changed in the in-memory copy.”,这个数字来自执行前后 total_changes() 的差值。
  • schema 变更会被感知。 只要这次执行改了数据或 schema 版本,表列表就会重新加载。你刚跑的 CREATE TABLE 会出现在列表里,INSERT 或 DELETE 之后行数也会更新。
  • 大结果集。 为了让页面保持响应,网格只渲染结果的前 1,000 行,并会提示这一点。Export CSV 始终导出结果的全部行。

面对一个陌生数据库时,这几条查询很有用:

-- 每个对象及其 CREATE 语句,包括索引和触发器
SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name;

-- 很多应用存在文件头里的迁移计数器
PRAGMA user_version;

-- 某个表上声明的外键
SELECT * FROM pragma_foreign_key_list('orders');

-- 检查文件是否损坏
PRAGMA integrity_check;

写操作只留在内存里

浏览器对你选中的文件没有写权限。引擎操作的是内存里的副本,所以 INSERT、UPDATE、DELETE、CREATE、DROP 都能正常执行,查看器也会展示它们的效果,但磁盘上的文件原封不动。刷新页面或打开另一个文件,这些改动就消失了。这个工具没有下载编辑后副本的选项。

这让编辑器成了正式执行前试语句的安全场所。比如你可以先看一次清理操作会影响多少行,再看看剩下的是什么:

DELETE FROM sessions WHERE expires_at < unixepoch();
SELECT COUNT(*) AS remaining FROM sessions;

测试级联删除时有个细节要注意:新建的 SQLite 连接默认不启用外键约束,除非应用主动打开它,查看器也不例外。想让测试里的 ON DELETE CASCADE 生效,先执行 PRAGMA foreign_keys = ON;。

要改动真实文件,用你自己机器上的 sqlite3 命令行工具或 DB Browser for SQLite。

导出 CSV

有两个 Export CSV 按钮。数据网格上方那个导出当前选中的整张表或视图的全部行,不只是当前页;SQL 编辑器下方那个导出你最后一次查询的全部结果。

输出遵循 RFC 4180:逗号分隔,CRLF 换行,首行是列名,字段包含逗号、双引号、CR 或 LF 时用 " 括起来,字段内的双引号会被转义成两个双引号。文件名取自表名,字母、数字、.、_、- 以外的字符会被替换成 _;查询结果的文件名固定是 query.csv。

CSV 这种格式带来两个后果:

  • NULL 和空字符串导出后都是空字段。如果这个区别对你有意义,导出前在查询里选 COALESCE(col, '<null>') 或 col IS NULL AS col_is_null。
  • BLOB 会转成大写十六进制。一个 4 字节的 PNG 签名会导出为 89504E47。这样能让 CSV 保持是合法文本,大多数语言一次调用就能解码回来,比如 Python 里的 bytes.fromhex()。

把数据交给同事时,只导出他们需要的查询结果,数据库文件留在你自己手里。

常见坑和边界情况

最近的数据行不见了:WAL 文件

这是最常见的意外情况。Write-Ahead Logging 页面描述的 WAL 模式下,SQLite 不会立刻把已提交的改动写进主数据库文件,而是追加到一个以数据库名加 -wal 后缀命名的独立文件里,旁边还有一个 -shm 索引文件。checkpoint 会把 WAL 里的事务搬回主文件。默认情况下,WAL 达到 1,000 页时 SQLite 会自动 checkpoint,最后一个连接关闭时 WAL 通常会被删除。

所以,如果你从一个正在运行的 App 拷贝 app.db,或者在手机 App 开着的时候把文件拉出来,最新的事务可能还留在 app.db-wal 里。查看器只读主文件,这些行就不会出现。SQLite 官方文档也警告过,把数据库文件和它的 WAL 分开可能丢失已提交的事务,或者损坏数据库。

文件头的第 18、19 字节能告诉你文件是否用了 WAL(都是 2 就是)。要把 WAL 合并进主文件,先关掉写它的那个应用,再执行:

sqlite3 app.db "PRAGMA wal_checkpoint(TRUNCATE);"

TRUNCATE 会 checkpoint 每一帧,然后把 WAL 文件截断为零字节。之后重新打开 app.db 即可。回滚模式数据库残留的 -journal 文件也是同样道理:查看器只读主文件,所以先让原本使用它的应用或 sqlite3 shell 打开一次数据库。

明明是数据库,却报「不是 SQLite 3 数据库」

加密数据库会触发这个错误。SQLCipher 把随机 salt 存在前 16 字节,其余部分加密,整个文件看起来就是一堆随机数据,SQLite format 3 文件头没了。SQLite Encryption Extension(SEE)同样会加密文件。查看器不打开加密数据库。用 sqlcipher shell 解密,或者用支持 SQLCipher 文件的 DB Browser for SQLite 打开。

文件名带 .db 但实际是别的东西,或者是截断了的拷贝,也会报同样的错误。用 head -c 16 app.db | xxd 查一下前几个字节。

文件超过 100 MB

这个上限是固定的,因为文件打开期间要在内存里存两份。文件更大的话,本地跑 sqlite3 app.db,或者先取一份更小的子集:

sqlite3 big.db "ATTACH 'small.db' AS s; CREATE TABLE s.orders AS SELECT * FROM orders WHERE created_at >= '2026-09-01';"

然后在查看器里打开 small.db。

表显示的是错误而不是数据

有些表需要 SQLite 核心之外的代码支持。SpatiaLite 几何表、sqlite-vec 向量表、FTS5 全文索引、R-Tree 空间索引这类虚拟表,依赖编译进创建它们的那个应用里的模块。查看器用的 sql.js 1.14.2 构建版本不包含 fts5 或 rtree 模块,查询这类表会得到 SQLite 自己的报错,比如 no such module: fts5。

这类表算不出行数时,列表里显示 —,数据面板显示错误信息,数据库的其余部分照常可以浏览。FTS5 还会把数据存在普通的”影子”表里(比如 notes_fts_content),这些是可以正常读取的普通表。

查询没有匹配到任何行

匹配零行的 SELECT 依然会显示列名,状态栏会报告”0 row(s)“。这说明查询跑成功了,列名也对,问题该去 WHERE 子句里找。SELECT COUNT(*) ... WHERE ... 总能返回一行,把答案说清楚。

长文本和宽表

单元格宽度有上限,长文本会被省略号截断。超过 60 个字符的文本,鼠标悬停可以在提示框里看到前 2,000 个字符。要读某一列里存的完整 JSON 文档,单独查询这个值并导出成 CSV,或者在查询里用 json_extract() 取出你需要的部分。

安全地拷贝一个运行中的数据库

应用还在写文件的时候用 cp 拷贝,可能拿到一份撕裂的副本。SQLite 3.15.0 起提供的 VACUUM INTO 会把一份事务一致的快照写进新文件,不动原文件。这份快照是单个文件,包含了 WAL 里已提交的内容:

sqlite3 app.db "VACUUM INTO 'snapshot.db'"

代码示例

Python:检查文件头,再只读统计行数

标准库 sqlite3 模块可以通过 URI 以只读方式打开文件。下面这段脚本用和查看器一样的方式检查文件头,打印页大小和日志模式,并列出每张表的行数。

import sqlite3
import sys

path = sys.argv[1]
with open(path, "rb") as f:
    header = f.read(100)

if len(header) < 100 or header[:16] != b"SQLite format 3\x00":
    sys.exit(f"{path}: no SQLite 3 header (encrypted, truncated, or not a database)")

page_size = int.from_bytes(header[16:18], "big")
if page_size == 1:
    page_size = 65536                      # 1 是 64 KiB 页的魔数
journal = "WAL" if header[18] == 2 else "rollback"
print(f"page size: {page_size}  journal mode: {journal}")

con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)   # 只读
tables = con.execute(
    "SELECT name FROM sqlite_master WHERE type = 'table' "
    "AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name"
).fetchall()
for (name,) in tables:
    quoted = '"' + name.replace('"', '""') + '"'
    count = con.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone()[0]
    print(f"{name:<32}{count:>10}")
con.close()

JavaScript:Node.js 里用同一个引擎

sql.js 在 Node.js 里也能跑。这和浏览器工具用的是同一套模型:文件加载进内存,写操作只改这份副本。

import { readFileSync } from "node:fs";
import initSqlJs from "sql.js";

const bytes = readFileSync(process.argv[2]);
const SQL = await initSqlJs();
const db = new SQL.Database(bytes);          // 文件的内存副本

const [schema] = db.exec(
  "SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view') ORDER BY name"
);
for (const [type, name] of schema?.values ?? []) console.log(type.padEnd(6), name);

// 写操作只改这份副本,磁盘上的文件不受影响
db.run("DELETE FROM users WHERE email LIKE ?", ["%@example.com"]);
console.log("rows deleted in memory:", db.getRowsModified());

// 如果想保留结果,db.export() 会把改动后的数据库返回成 Uint8Array
db.close();

Bash:为查看做准备,并从命令行导出

# 把还没落盘的 WAL 事务合并进主文件(先关掉写它的应用)
sqlite3 app.db "PRAGMA wal_checkpoint(TRUNCATE);"

# 或者拿一份一致的单文件快照,不动原文件
sqlite3 app.db "VACUUM INTO 'snapshot.db'"

# 打开前先确认文件头
head -c 16 snapshot.db | xxd

# 从 shell 导出 CSV;hex() 像查看器一样把 BLOB 转成文本
sqlite3 -header -csv snapshot.db \
  "SELECT id, email, hex(avatar) AS avatar FROM users LIMIT 100" > users.csv

hex() 这一步很关键。sqlite3 shell 会把 BLOB 列原样以字节形式写进 CSV,这对大多数 CSV 读取器来说都是损坏的文件。

和其他 SQLite 工具的对比

下面每个工具都适合不同的场景。

工具运行在哪里能读什么是否写回文件加密文件
ZeroTool SQLite 查看器浏览器标签页,无需安装单个文件,最大 100 MB否;改动只留在内存副本里不支持
sqlite3 命令行 shell本地终端本地磁盘上的文件,包括 WAL是不支持(用 sqlcipher shell)
DB Browser for SQLiteWindows / macOS / Linux 桌面应用,开源本地磁盘上的文件是支持,SQLCipher

sqlite3 shell 是参照标准的工具。它原地打开文件,自身没有大小限制,能读 WAL,也很适合写脚本。只想看看的话用 sqlite3 -readonly app.db。它的局限是你得记住那些点命令,还要在终端里读宽表。

DB Browser for SQLite 是一个完整的桌面编辑器,能创建和修改表、在网格里编辑单元格、增删 SQLCipher 加密。需要真正改动文件时用它。

浏览器查看器是给快速查看用的:结构面板、分页数据、SQL 编辑器、CSV 导出,不用装东西,不用账号,不上传。它对文件天生只读,所以你不可能弄坏正在查看的数据库。

相关工具与参考资料

ZeroTool 上和数据库文件搭配使用的工具:

  • CSV to SQL 把 CSV 转成 CREATE TABLE 和 INSERT 语句,用来给一个全新的 SQLite 文件灌测试数据。
  • SQL Formatter 在你把一条很长的 CREATE 语句或查询粘进编辑器之前,先把它格式化到可读。
  • CSV ↔ JSON 把导出的查询结果转成 JSON,用作 fixture 或 API mock 数据。
  • JSON Formatter 用来处理存在 TEXT 列里的 JSON 文档。

本文引用的一手资料: