如何表示当前状态并维护数据与 referential integrity?
结论
一致性确实需要关注,但在 LOANS 中直接保存 StatusID 并不算设计错误。这是常见的“当前状态快照”做法。真正需要解决的是:数据库能否保证状态更新与事件写入在同一个原子操作中完成。
不建议根据普通的 lastEvent 推导状态,因为最后发生的事件可能只是 payment、备注修改等不会改变状态的事件。更准确的设计是:
LOAN_EVENTS记录所有业务事件。LOAN_STATE_CHANGES只记录引起状态变化的事件及变化结果。- 可选的
LOAN_CURRENT_STATE指向最近一次状态变化事件。 - 所有相关写入都在同一个事务或存储过程中完成。
采用这种结构后,既能查询当前状态,也能找到造成该状态的具体事件。组合外键还可以确保该事件属于同一笔贷款。
为什么单独保存 StatusID 仍可能合理
例如:
LOANS
-----
LoanID
StatusID
LOAN_EVENTS
-----------
EventID
LoanID
EventTypeID
OccurredAt
这里的两个外键只能分别保证:
StatusID是合法状态。LOAN_EVENTS.LoanID指向一笔存在的贷款。
它们无法保证“这笔贷款处于 defaulted 状态,是因为发生了相应的 default 事件”。这是跨表业务约束,普通外键无法直接表达。
不过,这种设计并非不可接受。只要状态和事件只能通过一个存储过程更新,而且两个操作处于同一个事务中,一致性就能得到可靠保证。很多系统采用这种方式,因为查询当前状态很方便。
风险来自应用程序分别执行以下操作:
UPDATE LOANS SET StatusID = @Defaulted WHERE LoanID = @LoanID;
以及:
INSERT INTO LOAN_EVENTS (...) VALUES (...);
其中任何一步失败,或者某段代码绕过正常流程,都可能产生不一致的数据。
推荐的数据模型
可以把“状态变化”建模为普通事件的子类型:
CREATE TABLE LOAN_STATES
(
StateID int NOT NULL PRIMARY KEY,
StateCode varchar(30) NOT NULL UNIQUE,
StateName varchar(100) NOT NULL
);
CREATE TABLE EVENT_TYPES
(
EventTypeID int NOT NULL PRIMARY KEY,
EventTypeCode varchar(30) NOT NULL UNIQUE,
EventTypeName varchar(100) NOT NULL
);
CREATE TABLE LOANS
(
LoanID int NOT NULL PRIMARY KEY,
CreatedAt datetime NOT NULL
CONSTRAINT DF_LOANS_CreatedAt DEFAULT GETUTCDATE()
);
CREATE TABLE LOAN_EVENTS
(
EventID bigint IDENTITY(1, 1) NOT NULL PRIMARY KEY,
LoanID int NOT NULL,
EventSequence int NOT NULL,
EventTypeID int NOT NULL,
OccurredAt datetime NOT NULL
CONSTRAINT DF_LOAN_EVENTS_OccurredAt DEFAULT GETUTCDATE(),
CONSTRAINT FK_LOAN_EVENTS_LOANS
FOREIGN KEY (LoanID) REFERENCES LOANS(LoanID),
CONSTRAINT FK_LOAN_EVENTS_EVENT_TYPES
FOREIGN KEY (EventTypeID) REFERENCES EVENT_TYPES(EventTypeID),
CONSTRAINT UQ_LOAN_EVENTS_LoanSequence
UNIQUE (LoanID, EventSequence),
CONSTRAINT UQ_LOAN_EVENTS_LoanEvent
UNIQUE (LoanID, EventID)
);
CREATE TABLE LOAN_STATE_CHANGES
(
EventID bigint NOT NULL PRIMARY KEY,
LoanID int NOT NULL,
FromStateID int NULL,
ToStateID int NOT NULL,
CONSTRAINT FK_LOAN_STATE_CHANGES_EVENT
FOREIGN KEY (LoanID, EventID)
REFERENCES LOAN_EVENTS(LoanID, EventID),
CONSTRAINT FK_LOAN_STATE_CHANGES_FROM_STATE
FOREIGN KEY (FromStateID) REFERENCES LOAN_STATES(StateID),
CONSTRAINT FK_LOAN_STATE_CHANGES_TO_STATE
FOREIGN KEY (ToStateID) REFERENCES LOAN_STATES(StateID),
CONSTRAINT UQ_LOAN_STATE_CHANGES_LoanEvent
UNIQUE (LoanID, EventID)
);
普通 payment 只需写入 LOAN_EVENTS。如果 payoff 使贷款变为 paid,同一个 EventID 也要写入 LOAN_STATE_CHANGES。
这种方式比根据 EventTypeID 临时推断状态更可靠。事件类型说明“发生了什么”,ToStateID 明确记录“该事件当时产生了什么状态结果”。即使后续业务规则发生变化,历史事件的含义也不会随之改变。
直接从历史记录计算当前状态
如果数据量和查询频率允许,可以不保存当前状态指针,直接查询最后一次状态变化:
SELECT TOP 1
sc.ToStateID,
sc.EventID,
e.EventTypeID,
e.OccurredAt
FROM LOAN_STATE_CHANGES AS sc
JOIN LOAN_EVENTS AS e
ON e.LoanID = sc.LoanID
AND e.EventID = sc.EventID
WHERE sc.LoanID = @LoanID
ORDER BY e.EventSequence DESC;
排序时应使用明确的业务顺序字段,不能只依赖 OccurredAt。SQL Server 2005 的 datetime 精度有限,两个事件可能具有相同时间。补录事件的业务时间也可能早于录入时间。
EventSequence 应表示事件应用到该贷款的先后顺序,并通过 UNIQUE (LoanID, EventSequence) 防止重复。
这个方案不保存冗余状态,因此不会出现 LOANS.StatusID 与历史记录不一致的问题。代价是每次读取当前状态都要查询历史表。面对大量列表查询、报表或高并发访问时,可能仍需缓存当前状态。
使用当前状态指针
如果需要提高读取速度,可以增加一个一对一的当前状态表:
CREATE TABLE LOAN_CURRENT_STATE
(
LoanID int NOT NULL PRIMARY KEY,
StateChangeEventID bigint NOT NULL,
CONSTRAINT FK_LOAN_CURRENT_STATE_LOAN
FOREIGN KEY (LoanID) REFERENCES LOANS(LoanID),
CONSTRAINT FK_LOAN_CURRENT_STATE_EVENT
FOREIGN KEY (LoanID, StateChangeEventID)
REFERENCES LOAN_STATE_CHANGES(LoanID, EventID)
);
查询当前状态:
SELECT sc.ToStateID,
sc.EventID
FROM LOAN_CURRENT_STATE AS cs
JOIN LOAN_STATE_CHANGES AS sc
ON sc.LoanID = cs.LoanID
AND sc.EventID = cs.StateChangeEventID
WHERE cs.LoanID = @LoanID;
组合外键:
FOREIGN KEY (LoanID, StateChangeEventID)
REFERENCES LOAN_STATE_CHANGES(LoanID, EventID)
这个组合外键可以防止贷款 A 的当前状态指针错误地引用贷款 B 的状态变化事件。
它也避免了 LOANS 和 LOAN_EVENTS 之间的直接循环引用。数据可以按以下顺序创建:
- 插入
LOANS。 - 插入初始事件。
- 插入对应的
LOAN_STATE_CHANGES。 - 插入
LOAN_CURRENT_STATE。
这些操作应放在同一个事务中。
通过事务完成状态变化
状态事件、状态变化记录和当前状态指针必须一起提交或一起回滚:
BEGIN TRY
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @EventID bigint;
DECLARE @NextSequence int;
SELECT @NextSequence = ISNULL(MAX(EventSequence), 0) + 1
FROM LOAN_EVENTS WITH (UPDLOCK, HOLDLOCK)
WHERE LoanID = @LoanID;
INSERT INTO LOAN_EVENTS
(
LoanID,
EventSequence,
EventTypeID,
OccurredAt
)
VALUES
(
@LoanID,
@NextSequence,
@EventTypeID,
GETUTCDATE()
);
SET @EventID = SCOPE_IDENTITY();
INSERT INTO LOAN_STATE_CHANGES
(
EventID,
LoanID,
FromStateID,
ToStateID
)
VALUES
(
@EventID,
@LoanID,
@FromStateID,
@ToStateID
);
IF EXISTS
(
SELECT 1
FROM LOAN_CURRENT_STATE WITH (UPDLOCK, HOLDLOCK)
WHERE LoanID = @LoanID
)
BEGIN
UPDATE LOAN_CURRENT_STATE
SET StateChangeEventID = @EventID
WHERE LoanID = @LoanID;
END
ELSE
BEGIN
INSERT INTO LOAN_CURRENT_STATE
(
LoanID,
StateChangeEventID
)
VALUES
(
@LoanID,
@EventID
);
END;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRANSACTION;
DECLARE @ErrorMessage nvarchar(4000);
SET @ErrorMessage = ERROR_MESSAGE();
RAISERROR(@ErrorMessage, 16, 1);
END CATCH;
这段示意代码兼容 SQL Server 2005。实际实现还应在写入前锁定并读取当前状态,确认 @FromStateID 与数据库中的当前状态一致,避免两个用户同时对同一笔贷款执行相互冲突的操作。
还应验证合法的状态转换
事件存在,并不代表状态转换在业务上一定合法。例如,系统可能不允许贷款从 voided 重新变为 open。
可以增加一张允许转换表:
CREATE TABLE LOAN_STATE_TRANSITIONS
(
FromStateID int NOT NULL,
ToStateID int NOT NULL,
CONSTRAINT PK_LOAN_STATE_TRANSITIONS
PRIMARY KEY (FromStateID, ToStateID),
CONSTRAINT FK_TRANSITIONS_FROM_STATE
FOREIGN KEY (FromStateID) REFERENCES LOAN_STATES(StateID),
CONSTRAINT FK_TRANSITIONS_TO_STATE
FOREIGN KEY (ToStateID) REFERENCES LOAN_STATES(StateID)
);
状态变更存储过程先进行检查:
IF NOT EXISTS
(
SELECT 1
FROM LOAN_STATE_TRANSITIONS
WHERE FromStateID = @CurrentStateID
AND ToStateID = @ToStateID
)
BEGIN
RAISERROR('Invalid loan state transition.', 16, 1);
RETURN;
END;
初始状态可以交给专门的“创建贷款”流程处理,因为它没有通常意义上的 FromStateID。
主要注意事项
- 不要把普通
lastEvent当作当前状态事件。最后发生的 payment 很可能不会改变状态。 - 不要只根据事件类型推导历史状态。相同事件在不同上下文中可能产生不同结果,业务规则也可能随时间变化。
- 当前状态指针只能证明它指向一个合法且属于该贷款的状态事件。仅凭外键,无法证明它一定指向时间顺序上的最后一个事件。这个约束需要由事务化存储过程、触发器或查询时的动态计算来保证。
- 不要把可修改的业务时间作为唯一排序依据。应使用贷款内单调递增的
EventSequence。 - 事件和状态历史通常应视为 append-only。需要修正错误时,最好增加冲正或纠正事件,不要直接修改或删除旧事件。
- 应禁止应用账号直接更新相关表,只授予其执行存储过程的权限,否则任何约束方案都有可能被绕过。
- SQL Server 的
CHECKconstraint 不能通过子查询验证另一张表,因此这类跨表时序规则无法只靠一个CHECK完成。
如果系统更看重查询性能和实现简单,可以在 LOANS 中保留 StatusID,同时保存造成该状态的 StateChangeEventID,并在同一个事务中维护两者。如果更看重审计一致性,可以把状态变化历史作为事实来源,再通过查询或 LOAN_CURRENT_STATE 指针获取当前状态。
备注:内容仅供参考。
