2026-08-30
· Dylan YuSQLite 外键和关系:实用指南(含坑)
SQLite 默认不强制外键,大多数教程跳过了真正让你踩坑的部分。这里讲外键在 SQLite 里怎么工作、PRAGMA 干什么、不用 ALTER TABLE 怎么建模关系、以及如何在管理面板里可视化它们。
SQLite 是世界上部署最广的数据库引擎。每个手机、每个浏览器、每台 macOS 都有,你桌面上大概一半的应用现在也在用。但它的外键故事真的很怪——怪到我见过有经验的工程师为此浪费一下午。
简短版:外键在 SQLite 里存在,但默认关闭。你必须在每个连接上打开,否则它们静默什么都不做。而一旦你打开,你会发现 ALTER TABLE 限制太多,以至于修正一个搞错的关系是个多步骤的折磨,大多数教程根本不提。
网上大多数「SQLite 外键」文章覆盖快乐路径:建两个带 REFERENCES 子句的表、插一行、继续。那大概是你实际需要知道的 10%。这篇文章是另外 90%——PRAGMA 行为、ORM 配置、ALTER TABLE 问题,以及当你不能改 schema 但仍需要建模关系时怎么办。
开始。
外键在 SQLite 里怎么工作(让人意外的默认)
SQLite 从 3.6.19 版本(2009 年发布)就支持外键。不是打错——外键已经可用超过十五年。问题是它们为了向后兼容默认禁用,而启用方式不是 schema 属性或数据库设置。它是连接上的运行时标志。
这就是让人绊倒的部分。你可以写完全正确的 REFERENCES 子句、建表、插入违反约束的行,SQLite 会乐意接受。没错误。没警告。约束只是……装饰性的。
看看长什么样。先是 schema:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
那个 FOREIGN KEY (user_id) REFERENCES users(id) 是正确的 SQL。它说:orders 里每个 user_id 必须指向 users 里真实的 id。现在看插入违反它的行会怎样:
INSERT INTO users (id, email) VALUES (1, 'alice@example.com');
-- 这引用了 user_id 999,不存在。
INSERT INTO orders (id, user_id, total) VALUES (1, 999, 49.99);
-- 结果:插入成功。没错误。
如果你从 PostgreSQL 或 MySQL 过来,这是你盯着屏幕的时刻。约束就在 schema 里。为什么没触发?
因为外键强制关闭了。你用 PRAGMA 打开:
PRAGMA foreign_keys = ON;
-- 再试同样的插入:
INSERT INTO orders (id, user_id, total) VALUES (2, 999, 49.99);
-- 结果:Error: FOREIGN KEY constraint failed
这就是全部机制。一行,你的约束开始工作。问题是:这行放哪,怎么确保它一直在?这就烦了。
怎么检查 FK 现在是否开着
做任何其他事之前,学这一个查询:
PRAGMA foreign_keys;
-- 返回 0(关)或 1(开)
在你在调试的任何连接上跑它。如果你看到孤行并奇怪为什么约束没抓住它们,这几乎总是答案——做插入的连接 FK 关着。
PRAGMA 的坑
让 PRAGMA 方式真正危险的是:它是按连接的,不是按数据库的。 在一个连接上设 PRAGMA foreign_keys = ON 不影响任何其他连接,也不持久。开一个新连接——即使到同一个文件——FK 又关了。
这意味着设置必须在每次开连接时由开连接的代码应用。如果忘了,你得到静默约束违反。没有错误日志、没有警告、什么都没有。数据就进去了。
实践中,这意味着配置在你应用代码或 ORM 设置里,不在 schema 里。而每个流行 ORM 处理方式不同,这本身就是 bug 来源。
Prisma
Prisma 连 SQLite 时默认启用外键。你不用做任何事——Prisma 引擎在连接设置里跑 PRAGMA foreign_keys = ON;。这是对的默认,是 Prisma 的 SQLite 支持中少数开箱即用的东西之一。
如果你通过 $executeRaw / $queryRaw 用原始查询,同样的连接池适用,FK 仍开着。好。
SQLAlchemy
SQLAlchemy 不为 SQLite 默认启用外键。这坑了很多人,因为 SQLAlchemy 乐意让你在模型里定义 ForeignKey 列然后静默不强制。
你必须用事件监听器显式启用:
from sqlalchemy import event
from sqlalchemy.engine import Engine
@event.listens_for(Engine, "connect")
def _enable_sqlite_fk(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
那个监听器在每个新连接上触发,正是你要的。如果忘了这个,你的 ForeignKey 声明是文档,不是约束。
Drizzle
Drizzle 的 Node SQLite 驱动(底层 better-sqlite3)也默认关 FK。你在构造客户端时启用:
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
const sqlite = new Database('app.db');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON'); // <-- 这行
export const db = drizzle(sqlite);
注意 better-sqlite3 的 .pragma() 调用适用于那个连接。如果你用连接池(better-sqlite3 罕见,因为它是同步单连接),你需要按连接应用。
通用规则
不管你用什么栈,规则一样:找到创建连接的地方,在那设 PRAGMA。 如果你的 ORM 没清楚文档化这个,搜 issues 标签——几乎总有一串困惑用户在上线后发现 FK 关着的讨论。
还有一个 wrinkles:PRAGMA 不能在事务里改。PRAGMA foreign_keys = ON 在事务里是 no-op。它必须在任何 BEGIN 之前设。大多数 ORM 正确处理这个,因为它们在连接打开时、用户代码跑之前设,但如果你手动管理连接,记住这点。
SQLite 外键支持什么(和不支持什么)
一旦 FK 真正开着,SQLite 的支持比人们以为的更完整。常见引用动作都能用。我们带例子过一遍。
ON DELETE CASCADE
当父行删除时,所有子行自动删除。
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO users (id, email) VALUES (1, 'alice@example.com');
INSERT INTO orders (id, user_id, total) VALUES (1, 1, 49.99);
INSERT INTO orders (id, user_id, total) VALUES (2, 1, 12.50);
DELETE FROM users WHERE id = 1;
-- 两行 orders 现在都没了。不用手动清理。
这是你用得最多的。对「子记录没父就没意义」是对的正确默认。
ON UPDATE CASCADE
当父的主键改变时,子的外键列自动更新匹配。
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE transactions (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON UPDATE CASCADE
);
INSERT INTO accounts (id, name) VALUES (1, 'Checking');
INSERT INTO transactions (id, account_id, amount) VALUES (1, 1, 100.00);
UPDATE accounts SET id = 100 WHERE id = 1;
-- transactions.account_id 现在是 100,自动的。
如果你重新编号 ID 有用。较少需要,因为大多数 schema 用不可变代理键,但它在那。
SET NULL
当父删除时,子的 FK 列设为 NULL(列必须可空)。
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
project_id INTEGER, -- 可空
title TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
);
INSERT INTO projects (id, name) VALUES (1, 'Migration');
INSERT INTO tasks (id, project_id, title) VALUES (1, 1, 'Write schema');
DELETE FROM projects WHERE id = 1;
-- tasks 行还在,project_id 现在是 NULL。
当子记录没父仍有意义时——没项目的任务、没父帖的评论——这是对的选择。
RESTRICT 和 NO ACTION
RESTRICT 阻止删除父如果有子存在,且立即——不延迟,即使在事务里。
CREATE TABLE invoices (
id INTEGER PRIMARY KEY,
total REAL NOT NULL
);
CREATE TABLE invoice_lines (
id INTEGER PRIMARY KEY,
invoice_id INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE RESTRICT
);
NO ACTION 是默认。在 SQLite 里,NO ACTION 和 RESTRICT 一个微妙区别:NO ACTION 在语句末检查约束(所以你可以在复杂语句里重排删除),而 RESTRICT 立即检查。实践中你几乎从注意不到区别,但如果你要「快速失败,无例外」,用 RESTRICT。
不支持的
几件事值得知道:
- 没有部分外键。 你不能有只在某条件为真时适用的 FK(如「仅当
status = 'active'时强制这个」)。约束对列是全有或全无。 - 没有基于表达式的外键。 FK 必须引用实际列,不是表达式。
- 延迟约束支持但很少用。 你可以声明 FK 为
DEFERRABLE INITIALLY DEFERRED,意味着检查推迟到COMMIT。这对循环引用(A 引用 B,B 引用 A)有用,你需要在一个事务里插两行。它能用,但我在真实 schema 里几乎从不需要——你通常可以用可空列打破循环。 - 父列必须是主键或有 UNIQUE 索引。 SQLite 在这里比某些数据库严格——你不能引用任意列。它必须唯一。
对绝大多数 schema,支持的子集足够了。咬人的空白不是功能列表——是 ALTER TABLE 问题,接下来讲。
ALTER TABLE 问题
这里 SQLite 的外键故事变得真正痛苦。
SQLite 的 ALTER TABLE 出了名地有限。你能做的完整列表:
ALTER TABLE ... RENAME TO ...— 重命名表ALTER TABLE ... RENAME COLUMN ... TO ...— 重命名列ALTER TABLE ... ADD COLUMN ...— 加列ALTER TABLE ... DROP COLUMN ...— 删列(3.35.0 加的)
就这样。值得注意的是缺失的:
- 你不能给现有表加外键约束。
- 你不能修改现有列的类型或约束。
- 你不能给现有列加
NOT NULL约束。 - 你不能改列的默认值。
所以如果你建表时没 FK 后来发现需要,没有 ALTER TABLE orders ADD FOREIGN KEY ... 语句。它不存在。官方 SQLite 文档描述了变通方法,是个 12 步过程,我完整展示给你看让你理解为什么人们避免它。
12 步重建
模式是:用你想要的 schema 建新表、复制数据、删旧表、重命名新表、重建依赖旧表的索引、触发器或视图。SQL 如下:
-- 1. 迁移期间关掉 FK 强制(必须,因为
-- SQLite 不让你在 FK 开着时改被 FK 引用的表)。
PRAGMA foreign_keys = OFF;
-- 2. 开始事务。
BEGIN TRANSACTION;
-- 3. 用你想要的 FK 约束建新表。
CREATE TABLE orders_new (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 4. 复制数据,先过滤掉孤行。
INSERT INTO orders_new (id, user_id, total)
SELECT id, user_id, total FROM orders
WHERE user_id IN (SELECT id FROM users);
-- 5. 删旧表。
DROP TABLE orders;
-- 6. 重命名新表为原名。
ALTER TABLE orders_new RENAME TO orders;
-- 7. 重建旧表上的索引。
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- 8. 重建触发器(如果有的话)。
-- CREATE TRIGGER ... (省略)
-- 9. 重建引用该表的视图(如果有的话)。
-- CREATE VIEW ... (省略)
-- 10. 跑外键检查确认一切一致。
PRAGMA foreign_key_check;
-- 11. 提交。
COMMIT;
-- 12. 把 FK 强制打开。
PRAGMA foreign_keys = ON;
这是官方流程。能用。它也是,客观地说,很多——而且容易漏步骤。忘了重建索引查询变慢。忘了 foreign_key_check 你上线孤行。忘了把 FK 打开你回到静默违反问题。
这是人们对 SQLite 关系沮丧的真正原因:不是因为功能缺失,而是因为事后修正关系是手动、易错的过程。 在 Postgres 里你跑 ALTER TABLE orders ADD CONSTRAINT ... FOREIGN KEY ... 一行搞定。在 SQLite 里你在重建表。
那你实际怎么办?有三个现实选项,取舍不同。
不用 ALTER TABLE 建模关系
选项 1:12 步重建
这是我上面展示的。它是「正确」答案,意义上它产生带真正强制 FK 约束的 schema。如果你在意引用完整性在数据库层强制——你通常应该——这是路径。
什么时候用:
- 你拥有 schema 且能接受短暂写锁。
- 表不是巨大(复制百万行花时间,虽然 SQLite 快)。
- 你想要约束即使被原始 SQL 或其他碰 DB 的工具强制。
什么时候避免:
- 你不能承受停机,即使几秒。
- 表巨大,复制太久。
- 你不拥有 schema(是第三方应用的数据库)。
实用提示:整个脚本化,先在数据库副本上测试,提交前跑 PRAGMA foreign_key_check。如果返回行,你有孤行,需要在迁移安全前决定怎么处理。
选项 2:让 ORM 在应用层处理
大多数 ORM 让你在模型定义里声明关系,即使数据库没有 FK 约束。ORM 在应用代码里强制关系——当你做 user.orders,它跑 SELECT * FROM orders WHERE user_id = ?,当你创建订单,它确保 user_id 设了。
在 Prisma:
model User {
id Int @id @default(autoincrement())
email String @unique
orders Order[]
}
model Order {
id Int @id @default(autoincrement())
user_id Int
user User @relation(fields: [user_id], references: [id])
total Float
}
在 SQLAlchemy:
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
orders = relationship("Order", back_populates="user")
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
user_id = Column(Integer) # ORM 级关系不需要 ForeignKey()
user = relationship("User", back_populates="orders")
取舍:关系只在你通过 ORM 时存在。 如果有人跑原始 SQL,或不同工具连数据库,或后台任务直接插行,没什么阻止孤 user_id 值。你信任所有写都通过你的应用。对很多小项目那没问题。对有多个写入者或外部工具碰 DB 的,这是漏的保证。
选项 3:在管理工具的 UI 层定义关系
这是我最终用得最多的方式,值得解释因为它解决特定问题:你想使用关系(浏览 join 数据、从父导航到子、建有用的管理界面)而不修改底层 schema。
思路是关系定义在数据库之上的层——在你用来看数据的工具里——而不是在 schema 本身。你的应用代码完全不受影响。schema 保持原样。但当你管理数据时,你得到从「真正」关系期望的 join 视图和导航。
这正是 BaseVolt 做的。它是本地优先桌面应用(macOS 和 Windows),作为 SQLite、PostgreSQL、MySQL 和 Cloudflare D1 的管理面板。你指向数据库,对任意两个表你可以在 UI 里定义关系——选父表、子表、连接列——BaseVolt 为浏览、join 和导航目的把它们当链接的。不用 ALTER TABLE。不用 schema 迁移。不改你应用看到的。
这对 SQLite 特别有用,因为替代是上面的 12 步重建。如果你只想使用关系而不重写 schema,在 UI 层定义它工作量少得多且零风险。
有免费版(最多 2 个数据源),Pro $99/年。demo.basevolt.app 有在线 demo,装之前想看关系 UI 可以看看。因为 BaseVolt 有内置 MCP 服务器,你可以让 Claude 或 Cursor 指向你的数据库,请它帮忙做 schema 工作——包括建议哪里应该有关系。
在管理面板里可视化关系
我来走一遍在 BaseVolt 里定义关系实际长什么样,因为工作流是让它有用的东西。
第 1 步:连接数据库
打开 BaseVolt,点「Add Data Source」,指向你的 SQLite .db 文件。它直接读 schema——不用连接字符串、不用迁移、不用配置。十几个表的数据库,几秒。
第 2 步:选要链接的两个表
在关系视图里,你选父表(如 users)和子表(如 orders)。BaseVolt 显示每个表的列。你选连接字段——users.id 和 orders.user_id——定义关系。就这样。不对你的数据库跑 SQL 来创建这个。定义存在 BaseVolt 的配置里,不在你的 schema 里。
第 3 步:浏览 join 视图
一旦关系定义了,几件事自动发生:
- 看一行
users时,你看到显示所有相关orders行的链接区域。点进去编辑任何一个。 - 看一行
orders时,你看到回到父user的链接。 - 你可以跨两个表建筛选视图而不用写
JOIN。
这是文字难传达但用起来明显的部分:你不再想「跑查询看相关数据」,开始想「点那个东西」。对在 sqlite3 CLI 里反复做 SELECT * FROM orders WHERE user_id = 5 的人,这是有意义的生活质量改变。
第 4 步:让 AI 帮忙
因为 BaseVolt 暴露 MCP 服务器,你可以连 Claude 或 Cursor 用自然语言问:「哪些订单没有匹配的用户?」或「建议这些表之间的关系。」AI 可以通过 MCP 服务器检查 schema 和数据,提出你可能漏掉的关系。不是魔法——只是 schema 内省已经在那,MCP 服务器让它对模型可用。
关键点是这些都不碰你的 schema。如果你后来决定关系错了,在 UI 里删了重定义。不用迁移、不用重建、对生产数据零风险。
常见模式
具体说说你会反复建模的三种关系模式,每个带 SQL 和在管理工具里怎么显示。
一对多
最常见模式。一个用户有多个订单。一个项目有多个任务。一个作者有多个帖子。
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
FK 在「多」的一边。在 BaseVolt 里,这显示为:看用户时下面显示其订单;看订单时显示到用户的链接。任一方向一键。
多对多(连接表)
多个学生注册多门课程。你需要第三个表——连接表——每个配对一行。
CREATE TABLE students (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE courses (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
CREATE TABLE enrollments (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);
几点注意:
- 连接表的主键是两个 FK 列的复合。这防止重复注册。
- 两个 FK 都用
ON DELETE CASCADE,所以删学生或课程自动清理注册。 - 连接表可以持有额外数据(这里
enrolled_at)。
在管理工具里,这是两个关系:students ↔ enrollments 和 courses ↔ enrollments。看学生的课程,你导航 student → enrollments → course。BaseVolt 把这当两跳,这是建模它的诚实方式——关系数据库里没有「直接」多对多,只有连接表。
自引用
一个类别有父类别。一个员工有经理,经理也是员工。一条评论有父评论。
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);
FK 从表指向回自己。parent_id 可空所以根类别可以没父。ON DELETE SET NULL 意味着删类别不删其子——只是把它们孤儿到顶层。如果你想删除沿树级联,用 ON DELETE CASCADE 代替,但小心:那一条语句删整个子树。
自引用关系是在 UI 层定义特别好的情况,因为「父」和「子」是同一个表。在 BaseVolt 里你从 categories 到 categories 在 id ↔ parent_id 上定义关系,然后看类别时内联显示其父和子。
调试 FK 问题
即使 FK 开着,也会出问题。你继承有孤行的数据库。迁移没完全成功。后台任务在 FK 关时插了坏数据。SQLite 给你两个 PRAGMA 来搞清楚发生了什么。
PRAGMA foreign_key_check;
这扫描整个数据库找违反外键约束的行并返回。当你怀疑时跑:
PRAGMA foreign_key_check;
-- 返回行如:
-- orders|42|users|1
-- 意思:表 'orders',rowid 42,违反到 'users' 的 FK,约束 #1
每行告诉你子表、违规行的 rowid、父表、哪个 FK 约束(按索引)被违反。从那你可以 SELECT * FROM orders WHERE rowid = 42 看实际行并决定怎么处理——修 user_id、删行、或插入缺失的父。
这也是 12 步重建末尾、提交前应该跑的。如果返回空,你的迁移一致。
PRAGMA foreign_key_list(table);
这显示特定表上定义的外键约束:
PRAGMA foreign_key_list(orders);
-- 每个 FK 返回一行,显示:
-- id | seq | table | from | to | on_update | on_delete | match
-- 0 | 0 | users | user_id | id | NO ACTION | CASCADE | NONE
当你忘了表有什么约束时有用——尤其不是你设计的数据库。from 和 to 列告诉你哪个本地列指向哪个父列,on_delete / on_update 告诉你动作。
手动找孤行
如果你想为特定关系找孤行而不扫描整个 DB:
SELECT o.*
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL;
这是经典的「找没父的子」查询。如果你只关心一个关系,它比 foreign_key_check 快,且给你完整子行而不只是 rowid。
调试工作流
当关系感觉不对时,这是我会按的顺序:
- 在做写入的连接上跑
PRAGMA foreign_keys;。如果是0,那就是答案。 - 跑
PRAGMA foreign_key_check;看损害。 - 对每个违规,决定:修子、删子、或插入缺失的父。
- 跑
PRAGMA foreign_key_list(child_table);确认约束是你以为的。 - 修连接设置让 FK 到处开着,从现在起。
最常见的根本原因,远超其他,是第 1 步。我估计 80% 的「我的 SQLite 外键不工作」问题就是连接 PRAGMA 关着。
总结
SQLite 的外键支持没问题。造成问题的不是缺失功能——是默认值和人体工程学。具体:
- FK 默认关。 在每个连接上设
PRAGMA foreign_keys = ON,在创建连接的代码里。调试时用PRAGMA foreign_keys;验证。 - PRAGMA 按连接。 你的 ORM 文档会告诉你它怎么处理。如果不,假设它不,加事件钩子。
ALTER TABLE不能加 FK。 修正缺失的关系意味着 12 步表重建,或在应用/UI 层定义关系代替。- 支持的引用动作对实际使用足够。 CASCADE、SET NULL、RESTRICT、NO ACTION 都能用。延迟约束如果需要存在。
- 调试是两个 PRAGMA。
foreign_key_check和foreign_key_list会告诉你需要的一切。
如果你只想在 SQLite 里使用关系而不重写 schema——浏览 join 数据、在父子行间导航、建管理界面——在 UI 层定义它们而不是跟 ALTER TABLE 斗。这正是 BaseVolt 的用途:指向你的数据库、可视化定义关系、得到可用管理面板而不碰你的 schema 或应用代码。
在 basevolt.app 试试 — 不用注册,不用信用卡。demo.basevolt.app 有在线 demo,想先看看可以逛逛。
如果这对你有用,我写关于数据库、本地优先软件、和构建开发者工具的不光鲜部分。在 X 上找我。