在中小型系统或嵌入式场景中,SQLite常被用作本地存储引擎。很多团队把业务规则完全放在应用代码里校验,一旦存在多个写入入口或批量导入脚本,就容易出现绕过校验的脏数据。SQLite的CHECK约束提供了一种声明式方案,让数据库自身成为最后一道防线,在表结构层面直接拒绝不符合规则的行。

CHECK约束的基础语法与生效机制
CHECK约束本质上是一个返回真、假或未知的布尔表达式。在CREATE TABLE时,可以将其附加在列定义后,也可以作为表级约束写在末尾。当执行INSERT或UPDATE时,SQLite会逐行计算这个表达式,若结果为假则整个语句失败并返回错误码SQLITE_CONSTRAINT。这种机制不依赖任何上层语言,无论数据来自Python脚本、C++程序还是手动执行的sqlite3命令行,规则都会被统一执行。
下面示例创建一个用户账户表,要求年龄必须大于等于零且不超过一百五十,同时用户名不能为空字符串。列级CHECK限制了age的范围,表级CHECK进一步确保用户名去除空格后仍有内容。注意CHECK中的表达式不能使用子查询访问其他表,只能基于当前行的列值进行计算。
CREATE TABLE account (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
age INTEGER CHECK (age >= 0 AND age <= 150),
CHECK (length(trim(username)) > 0)
);
当尝试插入一条age为负的记录时,SQLite会抛出约束冲突。应用层应当捕获该异常并转换为友好的提示,而不是忽略错误。与NOT NULL或默认值不同,CHECK可以表达更复杂的跨列逻辑,例如结束时间必须晚于开始时间,这在不引入触发器的情况下仅靠列属性无法完成。
多列联合与表达式函数的实战用法
业务规则往往不是单一字段能描述的。比如电商订单里,当支付状态为已退款时,退款金额必须大于零;待支付状态下退款金额必须为零。这种规则需要用表级CHECK配合CASE表达式或者逻辑运算符来书写。SQLite支持大部分核心SQL函数,可以在CHECK里调用abs、lower、strftime等函数,从而把格式化与比较逻辑压缩进约束定义。
以下表结构演示了状态与金额的联动约束。利用元组比较和OR连接,把非法组合排除掉。这里把status用lower函数归一化,避免大小写不一致导致规则失效。虽然CHECK不能跨表,但借助生成列(generated column)先把其他表的关键信息冗余进来,再对其做CHECK,也是一种变通思路。
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
status TEXT,
refund_amount REAL,
CHECK (
(lower(status) = 'refunded' AND refund_amount > 0)
OR (lower(status) = 'pending' AND refund_amount = 0)
OR (lower(status) NOT IN ('refunded', 'pending'))
)
);
在实际项目中,还可以结合CHECK与CHECK (typeof(column) = 'integer')这类类型守卫,防止JSON文本字段被误存为数字。虽然SQLite是动态类型,但显式约束能让接口契约更清晰。需要提醒的是,过于复杂的CHECK会降低写入性能,因为每次写操作都要重新求值,应在规则重要性与吞吐之间权衡。
CHECK约束与触发器、应用层校验的取舍
当业务规则涉及跨表统计,例如账户余额不能超过授信额度且额度存在另一张表,CHECK就无能为力了,此时通常使用触发器或应用事务。触发器可以执行完整SQL,但可读性差、调试困难,且容易在多人维护时产生隐式副作用。CHECK约束胜在声明简洁、优化器可预测,适合固定不变的定义域与行内逻辑。
应用层校验仍然不可完全废除。CHECK只能给出失败结果,难以提供多语言错误描述或引导用户修正界面。推荐做法是:轻量、稳定的规则下沉到CHECK,复杂或带业务语义的提示留在服务代码。这样即便某次运维直接连库改数,也不会突破底线规则。下面的对比表总结了三者差异。
| 方案 | 跨表能力 | 性能开销 | 维护成本 |
|---|---|---|---|
| CHECK约束 | 仅当前行 | 低 | 低 |
| 触发器 | 支持 | 中 | 高 |
| 应用层校验 | 任意 | 取决于实现 | 中 |
从工程落地看,先把核心数值边界、枚举范围、时间先后关系写成CHECK,是性价比最高的起步方式。后续若发现约束频繁随需求变动,再评估是否迁移到迁移脚本或代码里。数据库约束不是银弹,但作为防错底座,它能显著降低数据修复工单量。
ALTER TABLE追加约束的注意事项
已有表增加CHECK不能直接用简单ADD COLUMN语法,在SQLite中需要通过重建表来完成。具体做法是创建新表、复制数据、删除旧表、重命名新表。若原表数据已经存在违反新规则的记录,复制阶段就会报错,这反而是一次清理历史脏数的机会。借助事务包裹整个过程,可保证原子性。
另外,SQLite默认不检查已有数据的约束兼容性,只有在写入时才验证新行。因此上线新CHECK前,建议先跑一次SELECT筛选出违规行并通知业务方处理。下面代码展示用事务安全的表结构迁移,其中包含新加的CHECK要求价格非负。
BEGIN TRANSACTION;
CREATE TABLE product_new (
pid INTEGER PRIMARY KEY,
name TEXT,
price REAL CHECK (price >= 0)
);
INSERT INTO product_new (pid, name, price)
SELECT pid, name, price FROM product;
DROP TABLE product;
ALTER TABLE product_new RENAME TO product;
COMMIT;
完成迁移后,所有后续写入都会受到约束保护。如果业务允许临时关闭校验,可使用PRAGMA ignore_check_constraints=ON,但生产环境应尽量避免,否则就失去了约束的意义。把CHECK纳入版本化的建表脚本,也能在测试环境提前暴露规则冲突。