数据表应保存需要长期成立的事实,而不是照着页面字段机械建表。一个页面可以同时显示用户、商品和预约;它们的生命周期不同,不能因此塞进同一张宽表。关系数据基础介绍关系和 SQL,这里把它们落实到业务约束。
先写不允许发生的事
预约系统至少需要保证:用户和商品存在;数量为正;库存不为负;同一用户的一次请求只创建一张预约。下列脚本面向 PostgreSQL,可在独立练习数据库执行。
CREATE TABLE app_user (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE product (
sku text PRIMARY KEY,
stock integer NOT NULL CHECK (stock >= 0),
price_minor bigint NOT NULL CHECK (price_minor >= 0)
);
CREATE TABLE reservation (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id bigint NOT NULL REFERENCES app_user(id),
sku text NOT NULL REFERENCES product(sku),
quantity integer NOT NULL CHECK (quantity > 0),
request_key text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (owner_id, request_key)
);
INSERT INTO app_user(name) VALUES ('Alice');
INSERT INTO product VALUES ('book-1', 5, 3990);
INSERT INTO reservation(owner_id, sku, quantity, request_key)
VALUES (1, 'book-1', 2, 'request-1');
用最小货币单位保存整数金额,示例 3990 表示 39.90 元;涉及不同币种时还要保存币种,并明确舍入规则。不要用二进制浮点直接累加账务金额。时间使用能表达时刻的类型,接口再序列化为带时区的 ISO 8601 文本;“每天当地时间 9 点”则还需要时区及日历规则。
CHECK (quantity > 0) 单独使用并不拒绝 NULL,所以同时写 NOT NULL。外键阻止悬空引用,但不会帮你决定“用户注销以后历史订单怎样保留”;这需要先确定匿名化、软删除或归档策略。
约束与接口校验各自负责什么
接口校验尽早给出可读错误;数据库约束覆盖所有写入路径,包括并发请求、批处理和人工脚本。先查“键不存在”再插入,两个事务仍可能同时通过;唯一约束才是最后防线。发生冲突时,服务应识别约束错误并转成明确的业务结果,不能把所有数据库异常都吞成“已存在”。
Java JDBC 用 PreparedStatement 占位符;Go database/sql 的占位符样式由驱动决定,PostgreSQL 常用 $1。参数化保护值,不能直接保护动态表名和排序列;后者应从服务端白名单映射。
try (var statement = connection.prepareStatement(
"SELECT stock FROM product WHERE sku = ?")) {
statement.setString(1, userSuppliedSku);
try (var rows = statement.executeQuery()) {
while (rows.next()) System.out.println(rows.getInt("stock"));
}
}
模型也会变,需要迁移
给现有大表增加强制字段时,不能只提交一份新的 CREATE TABLE。常见顺序是先加可空列、发布兼容读写、分批回填、检查空值、最后收紧约束。滚动发布期间旧程序仍在写数据,每一步都要允许新旧版本短暂共存。Flyway 或 Liquibase 可以记录迁移版本,但不会自动证明迁移无锁等待或可回滚。
ORM 负责映射和生成 SQL,不取消数据库成本。循环查询每张预约的商品,会出现一条列表查询加 N 条商品查询。观察实际 SQL 后,再决定 join、批量查询或预加载;不能只看 Java/Go 层调用次数。
练习与失败检查
依次尝试插入不存在的商品、数量为 0、重复请求键,确认它们触发不同约束。再创建第二个用户,让他使用相同 request_key,检查复合唯一约束为什么允许这一点。
设计一个订单金额快照:商品价格修改后,历史订单应继续显示下单时价格。说明哪些值应引用当前商品、哪些值必须作为当时事实保存,并写出迁移步骤。
面试自查
- 业务主键和代理主键如何取舍?为什么用了自增 ID 仍可能需要业务唯一约束?
- 外键是否应该级联删除?回答必须包含具体数据生命周期。
- 参数化查询为什么不能把
ORDER BY ?当作任意排序列替换?