在设计SQLite数据库表结构时,很多人习惯性地给每张表加一个自增id主键。但有些场景下,一条记录天然需要多个字段才能唯一确定,比如订单明细表要靠订单号加商品编号来区分,学生选课表要靠学号加课程号来标识。这种情况下,复合主键(联合主键)就成了更自然的选择。而复合主键一旦牵扯到表间关联,外键的写法和级联策略就需要格外小心,SQLite在这两个方面都有一些和其他数据库不同的细节,值得单独梳理一遍。

复合主键的声明方式与rowid的关系
SQLite中声明复合主键有两种常见写法。第一种是直接在列定义后面写表级约束:
CREATE TABLE order_item (
order_no TEXT NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
price REAL NOT NULL,
PRIMARY KEY (order_no, product_id)
);
第二种写法是给主键取个名字,便于后续维护时识别:
CREATE TABLE order_item (
order_no TEXT NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (order_no, product_id)
) WITHOUT ROWID;
这里出现了一个很关键的点:普通表声明的复合主键其实是一个逻辑约束,底层存储仍然是rowid表。也就是说,即使你定义了PRIMARY KEY (order_no, product_id),SQLite内部还是会有一列隐藏的rowid,复合主键只是建了一个唯一索引。而加上WITHOUT ROWID之后,表本身就按主键的顺序物理存储,复合主键直接成为B-tree的排序键,省掉一层间接引用,查询主键列时会更快,也省磁盘空间。
不过WITHOUT ROWID表也有适用边界:它适合行数据较小、且经常按主键前缀查询的场景。如果行数据很大(比如有BLOB字段),或者写入顺序和主键顺序差异很大导致频繁页分裂,WITHOUT ROWID反而可能拖慢插入速度。另外要注意,WITHOUT ROWID表不能使用AUTOINCREMENT,这对复合主键表来说通常不是问题,因为复合主键本身一般不含自增列。
还有一个容易忽略的细节:复合主键中的每一列都会被隐式约束为NOT NULL(SQLite从3.x某个版本起对此做了强化,更早版本历史上允许NULL,这是与SQL标准不兼容的历史遗留),所以即使不显式写NOT NULL,插入NULL也会报错。为了可读性,建议还是显式声明。
外键如何引用复合主键
当子表要引用一个复合主键时,外键必须完整引用所有主键列,列数和类型都要一一对应。这是SQL标准的要求,SQLite同样遵守。举个例子,假设有一张发货记录表要关联到订单明细:
CREATE TABLE shipment_record (
shipment_id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL,
product_id INTEGER NOT NULL,
shipped_qty INTEGER NOT NULL,
ship_date TEXT,
FOREIGN KEY (order_no, product_id)
REFERENCES order_item (order_no, product_id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
这里的FOREIGN KEY (order_no, product_id)必须把复合主键的两列都写全。如果你只写其中一列去引用,SQLite会直接报错,因为父表不存在名为order_no的单列唯一约束。这也是复合主键设计的一个代价:子表必须冗余存放所有主键列,如果复合主键包含很多列,子表的宽度会被迫增加。
如果确实希望子表只引用单个字段,一个折中方案是父表额外加一个自增id并加UNIQUE约束,复合主键负责表达业务唯一性,自增id负责被引用。这种设计在业务字段比较长(比如用UUID做主键)时尤其常见,能明显减少子表索引体积。
另一个重点:SQLite的外键约束默认是关闭的。这一点和MySQL、PostgreSQL完全不同,无数人栽在这里。每一次打开数据库连接后,都必须执行下面这条语句才能真正激活外键检查:
PRAGMA foreign_keys = ON;
注意这是连接级别的设置,不是数据库级别的。就算你在建库时执行过,换个连接、重启应用后又会回到默认关闭状态。实践中应该在每次获取连接的封装函数里统一执行这条PRAGMA。外键关闭时,SQLite不会报任何错误,你会眼睁睁看着子表出现大量孤儿数据,等问题被发现往往已经很晚了。可以用PRAGMA foreign_keys;查询当前状态,返回1表示已开启。
级联策略与数据一致性实践
外键定义中的ON DELETE和ON UPDATE决定了父表数据变动时子表如何响应。可选策略包括CASCADE(级联执行)、SET NULL(置空)、RESTRICT(阻止删除)、NO ACTION(默认,延迟检查到事务提交时)以及SET DEFAULT。对订单场景来说,删除订单时自动清掉明细,用CASCADE最省事:
-- 开启外键后,删除订单会级联删除其明细和发货记录 PRAGMA foreign_keys = ON; BEGIN; DELETE FROM orders WHERE order_no = 'SO-2024-0001'; -- 此时 order_item、shipment_record 中相关行已被自动删除 COMMIT;
级联虽然方便,但要警惕深层链路。如果A表删一行触发B表级联,B表又级联到C表,链条越长越难排查意外删除。建议在关键业务上优先用RESTRICT,让应用层显式处理删除顺序,级联只用在从属关系明确、生命周期完全跟随父表的子表上,比如订单和订单明细。
排查存量数据的完整性也很简单,SQLite提供了PRAGMA foreign_key_check;,它会列出所有违反外键约束的孤儿行。这在接手历史项目、确认外键是否曾经被绕过时非常实用:
PRAGMA foreign_key_check; -- 返回格式:表名、rowid、父表名、父表主键值 -- 空结果集表示数据完整
性能方面还有两点建议。第一,被引用的复合主键本身已经是索引,子表的外键列上SQLite不会自动建索引,删除父表行时需要扫描子表,数据量大时删除会变慢,所以给子表的外键列组合手动建一个索引是值得的:CREATE INDEX idx_ship_order ON shipment_record(order_no, product_id);。第二,级联删除发生在事务内,大批量删除时建议分批提交,避免长事务持锁影响并发读。
总结一下设计要点:复合主键适合用业务字段天然表达唯一性的关联表;小型高频查的表可以加WITHOUT ROWID获得更好的存储布局;子表外键必须完整引用全部主键列;每次连接记得开启PRAGMA foreign_keys;级联策略只用于强从属关系,并给子表外键列补索引。按这套思路建表,数据一致性就能交给数据库本身来保障,而不是靠应用代码小心翼翼地维护。
SQLite复合主键SQLite外键级联删除修改时间:2026-09-09 00:23:04