← 服务端与数据 · 第 2 篇

让数据库约束参与业务正确性

用库存预留验证唯一约束、条件扣减与事务回滚,并分清索引成本、并发隔离和 Supabase 行权限边界。

学完这一篇,你能做到

让重复或缺货预留不产生额外扣减,验证回滚,并设计真实并发与行权限测试。

库存只剩一份,两次请求各自先读到“还有库存”,再分别扣减。应用里写了判断,却仍可能超卖。数据库约束、条件更新和事务要参与同一个业务规则;只在页面上禁用按钮,无法约束其他实例或重试请求。

案例使用合成库存与请求 ID:成功预留消耗一份,同一请求重复到达不能再消耗,缺货失败不能留下成功记录。代码在内存 SQLite 上真实执行;它说明事务与唯一约束的作用,不证明 PostgreSQL 或 Supabase 的并发部署已经测试。

把规则写进 schema

NOT NULL 限制缺失,CHECK 限制允许值,UNIQUE 或主键限制重复,外键表达引用关系。这些规则对通过不同入口写入的数据都起作用,比每个调用者自行判断更集中。但约束需要与实际业务一致,不能只看名称觉得“有主键就安全”。

库存数量应使用明确单位与整数语义,检查不小于零。请求 ID 应有唯一约束,用于阻止同一次预留重复生效。真实系统还应记录请求与资源、用户、输入摘要和结果的关系,避免同一个 ID 被用于不同请求却返回旧结果。

SQLite 中依赖外键时,要按连接配置明确启用外键检查;示例没有外键,不能用其结果证明引用规则已生效。迁移前后测试正常与违规写入,检查约束是否真的拒绝输入,而不是只检查 schema 文件里写了什么。

事务把多步变化一起提交

事务将一组变化作为整体提交或回滚。创建预留记录与扣库存若分两次独立提交,中间失败可能让它们不一致。把它们放进同一事务,可以避免只完成一半的最终结果。

事务隔离定义并发请求怎样观察彼此,不能把“用了事务”理解成所有并发都会自动串行成功。数据库和隔离级别各有规则,锁冲突、死锁或序列化失败可能需要有界重试。重试必须重新执行完整用例并保留幂等条件。

先读后写的空隙应通过条件更新、适当锁或约束处理。本案例用 WHERE n>0 的扣减,并检查影响行数:零行表示没有可扣的库存。不能忽略 rowcount 后继续把请求标为成功。

索引帮助查询,也有维护成本

索引按某些键组织访问路径,可能减少查找与排序工作,但增加存储与写入维护。主键或唯一约束通常也涉及相应索引机制,业务正确性与查询优化的目的仍需要分别说明。

按实际查询的条件、排序和数据分布选择索引。联合索引的列顺序会影响它能支持哪些访问,给每个字段都加索引未必有效。使用查询计划与有代表性的数据检查选择,不把某次小表全扫描直接当成优化失败。

SQLite 的 EXPLAIN QUERY PLAN 可以观察所用访问路径,统计信息也会影响规划。计划不能直接提供所有运行耗时,实际测量要分开记录输入规模、选择性和环境。下方示例不包含性能实验,因此没有“索引提升多少倍”的结论。

执行预留、重复与回滚

请求表的主键先拒绝重复 ID,再条件扣库存。缺货时抛出异常,使当前事务回滚刚插入的请求记录。上下文管理器按成功或异常处理提交与回滚;这里不涉及关闭连接的演示。

import sqlite3
con = sqlite3.connect(":memory:")
con.executescript("CREATE TABLE stock(id TEXT PRIMARY KEY, n INTEGER CHECK(n>=0)); CREATE TABLE requests(id TEXT PRIMARY KEY); INSERT INTO stock VALUES('S-1',2);")
def reserve(request_id):
    try:
        with con:
            con.execute("INSERT INTO requests VALUES(?)", (request_id,))
            cursor = con.execute("UPDATE stock SET n=n-1 WHERE id='S-1' AND n>0")
            if cursor.rowcount != 1:
                raise ValueError("sold out")
        return True
    except (sqlite3.IntegrityError, ValueError):
        return False
assert reserve("R-1")
assert not reserve("R-1")
assert reserve("R-2")
assert not reserve("R-3")
assert con.execute("SELECT n FROM stock").fetchone()[0] == 0
print(con.execute("SELECT n FROM stock").fetchone()[0])

输出为 0。R-1 第一次成功,重复 R-1 被唯一约束拒绝且不扣库存;R-2 消耗第二份;R-3 因缺货失败,插入也回滚。断言检查最终数量为零。这个测试是单连接顺序调用,没有制造真实并发竞争。

本例的数量列没有声明 NOT NULL,表也没有采用严格类型模式。SQLite 的类型亲和性与 CHECK 不能据此拒绝所有缺失或错误类型;真实库存 schema 还应明确非空和整数约束,请求 ID 也应拒绝空值。示例只验证给定非空字符串请求和整数库存。

示例把重复与缺货都映射为 False,便于观察。真实 API 应返回可区分的结果,并对重复请求返回已保存的原结果。失败请求是否永久占用幂等 ID 由契约决定,本例缺货时回滚 ID,不代表所有业务都应如此。

行权限与数据库约束各管一层

Supabase 的 RLS 按请求身份控制可见或可修改的行;Data API 的 schema 暴露和表级 GRANT 决定能否访问,二者独立。只写 TO authenticated 不能限制资源归属,仍需要与所有权对应的行谓词。

UPDATE 需结合读取权限与策略,USING 判断旧行,WITH CHECK 判断结果行。应测试用户不能把记录转给另一用户。secret key 请求不带用户 access token 时通过 service_role 绕过 RLS;请求带用户 token 时应用该用户的策略。不能据客户端初始化方式就宣布永远绕过。

secret 与 service-role 凭证只放服务端,不能暴露给公开客户端。RLS 不代替库存约束和事务,反过来库存正确也不证明用户权限正确。本例没有 Supabase 请求,要另外验证两位用户、未登录和管理请求的访问结果。

预留事务不应包含长时间模型调用、文件上传或等待用户输入。事务越久,占用连接与锁的时间可能越长,还会让失败恢复更复杂。先在事务外准备不依赖锁定数据的输入,事务中完成必要读写,再由任务或补偿流程处理外部动作;外部成功与数据库成功需要额外协调。

执行计划练习可以选按资源 ID 查询库存和按用户查询历史预留两条路径。前者可能由主键支持,后者需要根据实际过滤与排序决定索引。不要只比较加索引前后的一条耗时;重复测量时注明冷热缓存与数据规模,并同时检查写入成本。

错误处理也要保留数据库异常的类别。约束冲突、锁等待超时和连接失效不应全部转为“缺货”。API可以给安全错误码,内部诊断保留非敏感原因。只有明确可重试的失败才按预算重跑,不能把永久违规输入不断交给数据库。

练习:验证整体结果而非单条 SQL

扩展预留记录,保存资源 ID、数量和请求结果。加入外键并确认启用,测试不存在资源、负数量、重复请求和缺货。每次失败后同时查询库存与请求记录,确认不存在半完成结果。

验收要求:库存始终非负;重复成功请求返回同一结果且不再扣减;事务中途失败没有残留;查询计划说明实际访问路径。再在所选真实数据库用两个连接验证并发扣减与冲突重试,明确运行环境和隔离条件;不能用内存顺序断言冒充这一检查。

资料来源与延伸阅读

资料核验:2026-11-10。本文为原创工程讲解,教学示例不代表线上实测。