第 3 / 23 篇

第 2 章:让业务身份和行动意图保持稳定

在工具调用之外重建一个任务对象很容易。真正困难的是判断,这个对象代表原操作的再次尝试,还是一项新的业务意图。没有稳定身份,重试次数再少也可能产生重复效果;没有参数绑定,重复键也可能错误地吞掉一次变更。

2.1 选择能跨过进程重启的操作键

示例将租户与操作键共同作为业务唯一身份。租户来自已经完成认证的应用上下文,操作键由接入方依据业务语义确定。模型提案不能修改租户,也不能在发现超时后换一个新键来获得重新执行的机会。

假设工单系统为同一笔售后补偿生成 case-42-credit-v1。用户刷新页面、网关重发请求、worker 被替换,都继续使用它。若业务后来批准追加补偿,应创建另一项明确操作,并记录两者的关系,而不是偷偷改变原键所对应的金额。

操作键需要约定保留期限。下游只在十分钟内记得幂等键,而工作流允许两天后恢复,这两个合同就不一致。键过期后再次执行可能变成新的效果。教学工具保留整个实验生命周期内的账本,不能据此假设所有真实服务都无限保存去重记录。

2.2 联合身份的编码也属于合同

直接用冒号拼接字符串看起来很方便,但租户 a:b、操作 c 与租户 a、操作 b:c 会生成相同字符串。限制分隔符当然是一种办法,但限制必须真的执行,并在所有入口一致。

本书把两个字符串编码成有明确结构的 JSON 数组,再计算哈希,生成工具操作键。结构编码保留边界,哈希使传输长度固定。这个哈希不是身份认证,也没有隐藏原始信息的通用保密保证;它只为一个已经受信任的联合身份生成稳定表示。

[tenant, operation]
        ↓ 明确的结构编码
稳定字节序列
        ↓ SHA-256
工具操作键

如果实现要跨语言,必须进一步固定编码规则。空格、Unicode 规范化和数字表示都可能影响字节。随书工程只用字符串数组,由同一 Go 实现生成;把它扩展到多语言服务时,需要一份共享的规范与互操作样本。

2.3 指纹保护的是同一意图

操作键回答“是哪一项业务”,计划指纹回答“这项业务究竟批准了什么”。示例把版本、动作、账户、数量、币种和受信任工具能力形成确定表示。若相同操作键再次提交不同指纹,系统返回冲突,保留原记录。

这里不能简单比较模型输出的原始文本。两段 JSON 可能字段顺序不同、空白不同,却表示同样的动作;同一句自然语言也可能隐藏不同的执行参数。准入层先解析为有限、明确的数据结构,校验取值,再为结构化意图生成指纹。

数量使用整数最小单位,避免浮点字符串在多个组件之间发生细微差异。示例限制单次积分数量,不是为了证明现实业务的额度规则,而是让可执行范围能够被完整检查。若真实系统允许多币种和小数位,需要把币种精度、舍入与转换时点写入合同。

2.4 唯一约束要和冲突处理一起设计

数据库中的 UNIQUE (tenant, operation) 可以防止同一业务身份出现两行,却不能替应用决定重复请求应返回什么。示例采用插入冲突后读取原记录,再核对指纹的路径。

INSERT INTO workflows (...)
VALUES (...)
ON CONFLICT (tenant, operation) DO NOTHING;

之后必须区分两种结果:原计划完全相同,可以返回已有工作流身份和状态;原计划不同,必须返回明确冲突。只检查“已经存在”就当作成功,会让调用方以为变更后的金额已经执行,实际却可能仍是旧金额。

并发时还要关注隔离级别。另一个事务正在提交相同键,当前插入可能等待唯一约束判断完成。后续读取能否看到赢家的行,需要结合 PostgreSQL 的命令快照理解。本书使用默认 Read Committed,通过重复提交与参数漂移实验验证身份规则,并以真实并发实验验证领取;不能直接把行为推广到其他数据库、隔离级别或缓存去重实现。

2.5 变更计划需要新的审批责任

一份计划经过批准后,不应原地修改动作或参数。否则审批记录中的“同意”就失去了指向。计划变更可以创建新版本、新的业务操作,或者显式撤销旧计划后重新进入审批;关键是旧许可不能悄悄变成新参数的许可。

指纹本身也有版本。增加一个字段、调整默认值或者改变序列化规则,可能导致历史意图的哈希发生变化。升级时应保留历史指纹及其算法版本,不能批量用新规则重算以后宣称原审批仍然有效。

恢复时应优先查询原业务身份,而不是依赖内存里最后构造的对象。持久记录保存了原意图与当前状态,工具查询继续使用同一个操作键。这样,新的 worker 才能依据同一份证据接手。

练习。 第一次请求为账户 A 发放 500 单位,第二次仍用同一操作键,但模型把数量改成 600。请分别说明接入层、审批层和工具账本应怎样处理,并解释为什么“取最新参数”可能破坏已经存在的业务事实。