如何解决带更新审批的 SQL 数据库依赖循环
结论
这些表之间并没有真正的外键循环。documents 和 payment_methods 存放基础数据,transactions 引用这两张表,transaction_update_requests 再引用原交易和准备更新的新值。依赖关系如下:
documents ───────────────┐
├──> transactions ───> transaction_update_requests
payment_methods ─────────┘ │
│
documents ──────────────────────────────┤
payment_methods ────────────────────────┘
只要 documents、payment_methods 和 transactions 不反向引用更新申请表,这个结构就是有向无环的。最直接的设计是把 updated_transactions 改成一张明确的更新申请表。审批通过后,在同一个数据库事务中修改原交易并更新申请状态。
推荐设计:独立的更新申请表
建议将表名改为 transaction_update_requests。这张表保存待审批的更新申请,而不是一条已经更新的交易。
CREATE TABLE documents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
);
CREATE TABLE payment_methods (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
);
CREATE TABLE transactions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
payment_method_id BIGINT UNSIGNED NOT NULL,
document_id BIGINT UNSIGNED NOT NULL,
version BIGINT UNSIGNED NOT NULL DEFAULT 1,
PRIMARY KEY (id),
CONSTRAINT fk_transactions_payment_method
FOREIGN KEY (payment_method_id)
REFERENCES payment_methods (id),
CONSTRAINT fk_transactions_document
FOREIGN KEY (document_id)
REFERENCES documents (id)
);
CREATE TABLE transaction_update_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
transaction_id BIGINT UNSIGNED NOT NULL,
proposed_payment_method_id BIGINT UNSIGNED NOT NULL,
proposed_document_id BIGINT UNSIGNED NOT NULL,
base_version BIGINT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
requested_by BIGINT UNSIGNED NOT NULL,
reviewed_by BIGINT UNSIGNED NULL,
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP NULL,
PRIMARY KEY (id),
CONSTRAINT fk_update_request_transaction
FOREIGN KEY (transaction_id)
REFERENCES transactions (id),
CONSTRAINT fk_update_request_payment_method
FOREIGN KEY (proposed_payment_method_id)
REFERENCES payment_methods (id),
CONSTRAINT fk_update_request_document
FOREIGN KEY (proposed_document_id)
REFERENCES documents (id),
CONSTRAINT chk_update_request_status
CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled'))
);
建表顺序是:
documentspayment_methodstransactionstransaction_update_requests
在这个结构中,transactions 不需要保存 updated_state,也不需要指向申请表。申请是否存在以及是否通过,都由申请表中的 status 表示。
如果需要快速查询某条交易是否有待审批申请,给申请表添加索引即可,不必在 transactions 中增加反向外键:
CREATE INDEX idx_update_request_transaction_status
ON transaction_update_requests (transaction_id, status);
审批通过时如何应用更新
审批需要在同一个数据库事务中完成:
- 锁定更新申请。
- 确认申请状态仍是
pending。 - 锁定原交易。
- 检查申请提交后,交易是否被其他操作修改过。
- 更新原交易。
- 将申请标记为
approved。 - 提交事务。
对应的 SQL 伪代码可以写成:
START TRANSACTION;
SELECT
transaction_id,
proposed_payment_method_id,
proposed_document_id,
base_version,
status
FROM transaction_update_requests
WHERE id = :request_id
FOR UPDATE;
SELECT version
FROM transactions
WHERE id = :transaction_id
FOR UPDATE;
UPDATE transactions
SET
payment_method_id = :proposed_payment_method_id,
document_id = :proposed_document_id,
version = version + 1
WHERE id = :transaction_id
AND version = :base_version;
UPDATE transaction_update_requests
SET
status = 'approved',
reviewed_by = :admin_id,
reviewed_at = CURRENT_TIMESTAMP
WHERE id = :request_id
AND status = 'pending';
COMMIT;
应用层必须检查两条 UPDATE 语句的影响行数。如果原交易的 version 已经改变,说明这份申请是基于旧数据创建的,不能直接覆盖当前值。此时应执行 ROLLBACK,再由管理员重新审核或让用户重新提交。
上面的示例 SQL 本身不能保证所有异常都会自动触发回滚。实际代码发现影响行数不等于 1 时,必须显式回滚事务。
只允许一个待审批申请
如果同一条交易只能有一个 pending 申请,应尽量在数据库层保证这个约束。
各数据库对条件唯一索引的支持不同。PostgreSQL 可以直接创建部分唯一索引:
CREATE UNIQUE INDEX uq_one_pending_request_per_transaction
ON transaction_update_requests (transaction_id)
WHERE status = 'pending';
MySQL 不支持这种形式的部分索引。如果所用版本支持为生成列创建索引,可以使用生成列配合唯一索引:
ALTER TABLE transaction_update_requests
ADD COLUMN pending_transaction_id BIGINT UNSIGNED
GENERATED ALWAYS AS (
CASE
WHEN status = 'pending' THEN transaction_id
ELSE NULL
END
) STORED;
CREATE UNIQUE INDEX uq_one_pending_request_per_transaction
ON transaction_update_requests (pending_transaction_id);
唯一索引通常允许存在多个 NULL,因此非 pending 申请不会相互冲突,同一条交易则只能有一个待审批申请。
方案二:使用交易版本表
如果系统除了审批,还要完整保存每次修改前后的历史记录,可以将交易设计为不可变版本。
CREATE TABLE transactions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
current_version_id BIGINT UNSIGNED NULL,
PRIMARY KEY (id)
);
CREATE TABLE transaction_versions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
transaction_id BIGINT UNSIGNED NOT NULL,
version_number BIGINT UNSIGNED NOT NULL,
payment_method_id BIGINT UNSIGNED NOT NULL,
document_id BIGINT UNSIGNED NOT NULL,
approval_status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_transaction_version (
transaction_id,
version_number
),
CONSTRAINT fk_version_transaction
FOREIGN KEY (transaction_id)
REFERENCES transactions (id),
CONSTRAINT fk_version_payment_method
FOREIGN KEY (payment_method_id)
REFERENCES payment_methods (id),
CONSTRAINT fk_version_document
FOREIGN KEY (document_id)
REFERENCES documents (id),
CONSTRAINT chk_version_approval_status
CHECK (
approval_status IN (
'pending',
'approved',
'rejected'
)
)
);
用户提交更新时,系统创建一个 pending 版本。管理员批准后,将该版本改为 approved,再让 transactions.current_version_id 指向它。
这个设计会在建表时产生相互引用:版本引用交易,交易也引用当前版本。可以先创建两张表,再通过 ALTER TABLE 添加 current_version_id 的外键:
ALTER TABLE transactions
ADD CONSTRAINT fk_transaction_current_version
FOREIGN KEY (current_version_id)
REFERENCES transaction_versions (id);
但普通外键无法保证 current_version_id 指向的版本属于同一个 transaction_id。通常还要通过复合键、触发器或应用层检查来实现这项约束,所以它比独立申请表更复杂。
这种方案适合审计要求较高,或者需要回滚历史版本、展示完整变更轨迹的系统。如果业务只是提交申请、审批并覆盖原记录,通常没有必要引入版本模型。
方案三:只保存发生变化的字段
如果交易包含很多字段,而每份申请只修改少数字段,可以允许申请表中的拟议值为 NULL,并将 NULL 解释为“不修改该字段”:
CREATE TABLE transaction_update_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
transaction_id BIGINT UNSIGNED NOT NULL,
proposed_payment_method_id BIGINT UNSIGNED NULL,
proposed_document_id BIGINT UNSIGNED NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
PRIMARY KEY (id),
FOREIGN KEY (transaction_id)
REFERENCES transactions (id),
FOREIGN KEY (proposed_payment_method_id)
REFERENCES payment_methods (id),
FOREIGN KEY (proposed_document_id)
REFERENCES documents (id)
);
审批时按照业务规则合并数据:
UPDATE transactions
SET
payment_method_id =
COALESCE(:proposed_payment_method_id, payment_method_id),
document_id =
COALESCE(:proposed_document_id, document_id)
WHERE id = :transaction_id;
这种设计要求业务字段不能通过申请被修改为 NULL。如果“没有提交修改”和“申请将字段改为 NULL”都可能出现,就要增加单独的布尔标记,例如 change_document,不能只用 NULL 区分。
不建议把拟议内容全部存成 JSON
还有一种做法是建立通用审批表,将字段变化存为 JSON:
CREATE TABLE change_requests (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entity_type VARCHAR(50) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
proposed_changes JSON NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
PRIMARY KEY (id)
);
这种结构便于扩展,但数据库无法通过普通外键验证 JSON 中的 document_id 和 payment_method_id 是否存在,字段类型、查询效率和迁移也更难控制。
只有当系统需要为大量不同实体提供统一审批引擎,而且应用层具备完善的校验和审计机制时,这种方案才比较合适。对于单一的 transactions 更新审批,强类型列通常更可靠。
外键和删除策略
更新申请引用原交易、文档和支付方式,因此必须明确删除规则。通常不建议对这些关系使用 ON DELETE CASCADE,否则删除基础数据时可能连带删除审批记录,导致审计信息丢失。
可以保留默认的 RESTRICT,也可以对基础数据使用软删除:
ALTER TABLE documents
ADD COLUMN deleted_at TIMESTAMP NULL;
ALTER TABLE payment_methods
ADD COLUMN deleted_at TIMESTAMP NULL;
即使某种支付方式不再允许用于新交易,也可以保留原记录,让历史交易和审批申请中的外键继续有效。
如果业务允许删除待审批申请引用的拟议对象,也可以使用 ON DELETE SET NULL。此时对应列必须允许 NULL,审批界面还需要将申请标记为无效,不能继续批准。
原 SQL 还需要修正的细节
原定义的主键使用 BIGINT UNSIGNED,外键列却使用了 BIGINT。在 MySQL 中,外键两端的数据类型和有符号属性应保持一致,因此相关列都应使用 BIGINT UNSIGNED。
命名方面也有几处需要调整:
docment_id应统一为document_id。payment_methodes应统一为payment_methods。id_transaction更适合改为transaction_id。updated_state的含义不明确。如果它表示审批状态,应放在申请表中;如果表示并发版本,应改名为version。- 状态列不宜使用含义不清的
BIGINT,可以改用受约束的字符串、枚举或关联状态表。
最简单且便于维护的方案,是只保留一条正式的 transactions 记录,把所有未审批内容放入 transaction_update_requests,再通过事务、行锁和版本号完成审批。这样既能保留现有业务流程,也不需要删除外键或复制基础数据来处理并不存在的循环依赖。
备注:内容仅供参考。
