优惠券系统的数据难题并不只是表多,而是"今天改配置,昨天发的券会不会跟着变"。本篇用一次模板原地修改导致历史券门槛、有效期和退券规则漂移的脱敏事故,拆解不可变版本、批次固定、实例快照、迁移回放与冷热归档。
Immutable VersionIssuance BatchSnapshot HashRCA / Postmortem
结论先行: 模板负责表达可复用定义,版本负责冻结一次发布,批次负责固定一次发行,用户券负责固化个人实例。运行时不得通过 templateId 回读"当前规则";必须使用 versionId + snapshotHash + 实例绝对有效期 解释历史券。
案例边界与脱敏: 文中的模板、批次、用户、门店和订单均为示例标识,例如 template_demo_T100、batch_demo_B1、coupon_demo_C100。技术结构、故障机制和修复逻辑保留,真实业务数据已替换。
1. 背景与问题:为什么修改模板会改变已经发出的券
早期系统把 coupon_template 同时当作编辑草稿、线上规则和历史依据。user_coupon 只保存 templateId;券包查询、定价试算和退款决策都在运行时回表读取模板。运营为了下一轮活动修改门槛和有效期后,旧批次已经发出的券也看到了新规则。
更隐蔽的是缓存让不同节点在一段时间内读到不同值:券包服务展示新门槛,定价节点仍使用旧门槛,退款服务则加载新的退券策略。数据库里没有脏数据,但同一张券在三个业务链路中出现三种语义。

图 1:模板原地修改导致历史用户券语义漂移的事故链
2. 目标与约束:定义可以演进,已经发行的契约不能漂移
| 目标 | 可验证标准 | 关键约束 |
|---|---|---|
| 历史稳定 | 同一 userCouponId 在任意时间解析出相同发行规则 | Published Version 禁止 UPDATE |
| 版本可追溯 | 模板、批次、用户券可定位到唯一 contentHash | templateId 不能替代 versionId |
| 实例确定 | 有效期、门槛、适用范围在发券时固化 | 相对有效期转换为绝对时间 |
| 迁移可验证 | 旧读与快照读 Shadow Diff 可量化 | 无法还原的历史券不得猜测补值 |
| 归档可回放 | 冷热迁移前后状态与 Operation 回放一致 | 主键和审计坐标不得变化 |
核心不变量: Published(versionId) ⇒ immutable(contentHash);Batch ⇒ exactly one versionId;UserCoupon ⇒ versionId + snapshotHash + absolute validity。
3. 系统架构:定义、发布、发行、实例与审计分层
Template Service 管理草稿和不可变版本;Release Registry 保存审批与生效证据;Issuance Service 以固定版本创建批次并生成实例快照;Coupon Runtime 只读取实例语义;Hot Store、Archive Store 和 Audit / Replay 共同承担在线查询、长期留存和问题复现。

图 2:券模板、批次、实例与归档分层架构
4. 核心设计:四层身份坐标,而不是一个 templateId
templateId 是模板家族的稳定业务身份;versionId 标识一次不可变发布;batchId 表示一次具体发行计划;userCouponId 表示发给某个用户的实例。四个 ID 各自解决不同问题,不能相互替代。

图 3:模板家族、版本、批次与用户券的坐标关系
| 对象 | 可变性 | 必须保存的证据 |
|---|---|---|
| Template Family | 名称、负责人等元数据可调整 | templateId、templateCode、ownerRef |
| Template Version | 发布前 Draft 可编辑;发布后不可变 | versionNo、schemaVersion、ruleJson、contentHash |
| Issuance Batch | 首张券发出前可取消;发出后核心字段不可变 | versionId、有效期策略、资金归属、计划量 |
| User Coupon | 状态按状态机迁移,发行语义不可变 | batchId、versionId、snapshotHash、validFrom/To |
5. 数据模型与 ERD:把定义事实和运行事实分开
coupon_template 保存模板家族;coupon_template_version 保存不可变规则;coupon_release 保存审批发布证据;issuance_batch 固定版本与发行参数;user_coupon 保存实例状态和绝对有效期;coupon_snapshot 保存规范化运行规则;coupon_archive_index 负责冷数据定位。

图 4:模板、批次、用户券与归档 ER Diagram
| 表 | 主键 / 业务唯一键 | 职责与关系 |
|---|---|---|
| coupon_template | template_id;template_code | 模板家族,一对多 Version |
| coupon_template_version | version_id;template_id + version_no | 不可变规则、Schema 与 contentHash |
| coupon_release | release_id;version_id + environment | 审批、发布人与生效区间 |
| issuance_batch | batch_id;campaign_ref + batch_code | 固定 versionId、数量、渠道和有效期策略 |
| user_coupon | user_coupon_id;issue_business_key | 用户、状态、批次、版本和绝对有效期 |
| coupon_snapshot | snapshot_id;snapshot_hash | 规范化运行字段,可按哈希去重 |
| coupon_archive_index | user_coupon_id | 冷热位置、分区、objectRef 和 checksum |
6. 发布与发放流程:在正确的时间冻结正确的数据
模板 Draft 通过静态校验、影响面 Diff 和审批后生成新 Version;发布清单记录 contentHash。创建批次时固定 versionId;第一张券写入后,批次核心参数锁定。发券事务同时写 user_coupon、snapshotRef、Operation 和 Outbox。

图 5:模板发布、批次创建与用户券发放时序图
7. 用户券快照:快照什么,不快照什么
快照应包含运行时判断必需且会影响历史结果的字段:优惠类型与值、门槛、适用商品/门店表达式、叠加规则、退款退券策略、资金归属引用、Schema 版本和绝对有效期。展示名称、运营备注等非契约字段可通过独立只读元数据补充。

图 6:用户券快照生成与运行时读取数据流
snapshot = canonicalize({
"schemaVersion": 3,
"templateId": "T100",
"versionId": "V2",
"discount": {"type": "AMOUNT", "valueCent": 2000},
"thresholdCent": 15000,
"scopeExpr": "scope_demo_42",
"stackingPolicy": "EXCLUSIVE_COUPON",
"returnPolicy": "RETURN_IF_FULL_REFUND",
"validFrom": "issuedAt",
"validTo": "issuedAt + 30d"
});
snapshotHash = sha256(snapshot);
8. 有效期模型:相对策略只存在于批次,实例保存绝对时间
"领取后 30 天有效"是批次的计算策略,不应成为运行时每次重新计算的表达式。发券时基于统一时钟、时区和边界规则计算 validFrom / validTo,并写入用户券。若规则是自然日,必须明确是否包含领取日、结束时刻和夏令时处理。
- 固定区间:批次保存 startAt / endAt,实例复制绝对值。
- 相对天数:以 issuedAt 和业务时区计算,实例保存结果与 calculationPolicyVersion。
- 延迟生效:保存 validFrom,不用 status 模拟未来时间。
- 延期补偿:追加 Extension Operation,不能覆盖原始有效期证据。
9. 数据库写入顺序、事务与一致性
发布事务先写 Version,再写 Release 与 Outbox;创建批次前验证 Release 状态与 contentHash;发券事务锁定/校验批次额度,写 UserCoupon 与 SnapshotRef,递增 issuedCount,并写 Operation / Outbox。跨服务采用业务幂等键和 Inbox 去重。
BEGIN;
SELECT version_id, content_hash
FROM coupon_template_version
WHERE version_id = :versionId AND status = 'PUBLISHED';
INSERT INTO user_coupon(
user_coupon_id, batch_id, version_id, snapshot_hash,
user_ref, status, valid_from, valid_to, issue_business_key
) VALUES (...);
UPDATE issuance_batch
SET issued_count = issued_count + 1
WHERE batch_id = :batchId
AND issued_count < planned_count;
INSERT INTO coupon_operation(...);
INSERT INTO coupon_outbox(...);
COMMIT;
10. 冷热分层与状态归档:移动位置,不改变身份
AVAILABLE / LOCKED 和近期售后相关券留在 Hot Store;进入终态且超过在线窗口后移动到 Warm / Cold。归档前校验状态终结、售后无未决、Operation 连续、Outbox 已处理和 checksum 一致。全局索引保存 userCouponId 到物理分区的映射。

图 7:用户券冷热分层与归档生命周期
| 数据层 | 典型数据 | 读取与一致性策略 |
|---|---|---|
| Hot | AVAILABLE、LOCKED、近期 USED | 按 userRef 分片;强一致状态写入 |
| Warm | 近 90 天终态券与售后证据 | 在线 SQL;限制扫描范围 |
| Cold | 长期历史实例、Snapshot、Operation | 分区对象存储;通过 Archive Index 回源 |
| Purge | 超过法定与业务留存期的数据 | 匿名化/删除;保留审批和删除证明 |
11. 历史迁移:双写、回填、影子读与约束收紧
旧系统没有 versionId 和 snapshotHash 时,不能停机一次性切换。先加可空字段并双写新数据;再依据发券时间、批次记录、模板审计日志和订单证据回填;随后 Shadow Read 比较旧回表与快照解析结果;灰度切读后再收紧 NOT NULL、FK 和 Published 禁止更新约束。

图 8:从可变模板回表到实例快照的安全迁移
12. Troubleshooting:历史券门槛和有效期突然变化
Symptoms
用户券 coupon_demo_C100 上午显示"满 100 减 20",下午变成"满 150 减 20";部分节点仍允许满 100 使用。退款重算时又采用了新模板中的"不退券"策略,客服无法解释原订单。
Investigation
- 按 userCouponId 查看实例,只找到 templateId,没有 versionId、snapshotHash 或发行规则。
- 审计日志显示运营在 11:30 原地更新 coupon_template,未创建新版本。
- 比较服务节点缓存,发现同一个 cacheKey=templateId 对应两个 ruleHash。
- 订单 PriceQuote 保存的门槛为 10000 分,当前模板为 15000 分,证明历史语义已漂移。
- 退款服务直接读取当前 returnPolicy,未引用原 Quote 或用户券快照。

图 9:模板语义漂移事故的证据链
Root Cause
直接根因是 Published 模板允许原地 UPDATE,历史用户券在运行时通过 templateId 回读当前规则;系统性根因是领域模型没有区分模板家族、不可变版本、发行批次和个人实例。缓存并不是根因,它只是让不同时间的错误真相同时可见。

图 10:历史用户券规则改变的 5 Whys
Resolution
- 引入 coupon_template_version,Published 行由数据库触发器和服务权限共同禁止更新。
- 批次固定 versionId / contentHash;首张券发出后禁止修改核心发行参数。
- 用户券写入绝对有效期、snapshotHash,并让所有运行链路优先读取快照。
- 历史数据按证据可信度回填;无法唯一还原的实例进入 REVIEW,不猜测版本。
- 退款、核销和展示增加 Snapshot Missing / Hash Mismatch Gate。
Verification
修复后回放 320,000 张历史券,在券包、试算、核销和退款四条链路比较解析结果;可确定版本的数据 Diff 为 0。对 1,842 张证据不足的券保持 REVIEW 并按原订单证据处理。连续 14 天监控未再出现同一 userCouponId 多 ruleHash。
Lessons Learned
配置只要进入订单、资金或用户权益链路,就不再是普通配置,而是历史契约。契约的正确演进方式是创建新版本并让新批次引用它,而不是在原记录上覆盖字段。
13. 为什么原方案会失败,最终方案为什么有效
| 原方案 | 失败机制 | 最终方案为何有效 |
|---|---|---|
| 用户券只存 templateId | 无法定位发行时规则 | versionId + snapshotHash 固定历史坐标 |
| Published 与 Draft 共用一行 | 编辑下一期规则会覆盖线上历史 | 新版本追加写,发布后不可变 |
| 运行时重新计算相对有效期 | 时间基准和边界规则变化 | 发券时写绝对 validFrom / validTo |
| 缓存当前模板 | 不同节点形成多个短期真相 | 缓存 key 包含 versionId/contentHash,内容不可变 |
| 直接删除或覆盖归档数据 | ID 映射和审计链断裂 | 归档索引移动物理位置,保留业务身份 |
14. 关键实现:不可变发布和快照读取契约
UserCoupon issue(IssueCommand cmd) {
return tx.execute(() -> {
Batch batch = batches.requireOpen(cmd.batchId());
TemplateVersion v = versions.requirePublished(batch.versionId());
require(v.contentHash().equals(batch.contentHash()));
CouponSnapshot s = snapshotFactory.freeze(v, batch, cmd.issuedAt());
UserCoupon c = coupons.insertUnique(cmd.businessKey(), s);
batches.incrementIssuedWithLimit(batch.id());
operations.appendIssued(c, v.versionId(), s.hash());
outbox.append("CouponIssued", c.id(), s.hash());
return c;
});
}
CouponRule runtimeRule(UserCoupon c) {
return snapshots.requireByHash(c.snapshotHash()); // no current-template fallback
}
15. 版本兼容与 Schema 演进
不可变不等于永远使用同一个 JSON 结构。Snapshot 保存 schemaVersion;读取端通过 Upcaster 将旧结构转换为当前内存模型,但不回写和改变原始内容哈希。涉及业务含义变化时创建新 Template Version,不能用 Upcaster 偷偷改变历史规则。
- 结构性变化:字段重命名、默认值展开,可由纯函数 Upcaster 处理。
- 语义性变化:金额舍入、叠加规则、有效期边界,必须创建新版本。
- 每个 Upcaster 具备 Golden Case、幂等性和旧快照回放测试。
- 保存 originalSnapshotHash 与 canonicalViewHash,区分原始证据和当前视图。
16. 验证方案:历史回放、哈希、迁移与归档
- **Immutable Test:**Published Version 的任何 UPDATE 在服务层和数据库层均失败。
- **Replay Test:**同一 userCouponId 在不同发布日期、缓存状态和代码版本下解析结果一致。
- **Hash Test:**字段排序、空值和金额规范化后 contentHash 稳定,业务字段变化必然改变哈希。
- **Migration Test:**回填可断点续跑,同一行重复执行不改变结果,未知证据进入 REVIEW。
- **Archive Test:**冷热迁移前后查询、Operation 回放和 checksum 一致。
Invariant 1: published version content never changes
Invariant 2: one batch pins exactly one version and contentHash
Invariant 3: one user coupon resolves to exactly one snapshotHash
Invariant 4: runtime never falls back to the current template
Invariant 5: validFrom/validTo are absolute instance facts
Invariant 6: archive move preserves identity, checksum and replay result
Invariant 7: unknown historical evidence is REVIEW, never guessed
17. 可观测性与发布 Gate
| 指标 / Gate | 观察内容 | 触发动作 |
|---|---|---|
| Multi RuleHash | 同一 userCouponId 出现多个规则哈希 | P0:阻断发布并冻结相关批次 |
| Snapshot Missing | 运行时实例缺少 snapshotHash | 转 REVIEW,禁止回退当前模板 |
| Hash Mismatch | Version / Batch / Snapshot 哈希不一致 | 停止发券并核对发布证据 |
| Shadow Diff | 旧回表与快照解析的字段级差异 | 按字段和批次阻断灰度 |
| Archive Checksum | 归档前后 checksum 与计数 | 失败分区回滚,不删除热数据 |
| Unknown Version | 历史券无法唯一映射版本 | 进入人工/规则化裁决队列 |
18. 方案取舍
| 决策 | 收益 | 代价与控制 |
|---|---|---|
| 每次发布新建 Version | 历史稳定、可审计、易回滚 | 版本数量增加;提供 Diff 与复制草稿 |
| 实例保存快照 | 运行时独立于模板变更 | 存储增长;规范化后按 hash 去重 |
| 绝对有效期 | 判断简单且可复现 | 补偿延期需追加 Operation |
| 双写 + Shadow Read 迁移 | 不中断业务并量化风险 | 迁移周期长;按批次设 Gate |
| 冷热分层 | 控制在线库体积和索引成本 | 查询路由复杂;保留全局索引与校验和 |
19. Lessons Learned
- **第一,**模板是家族,版本才是可以被业务依赖的不可变契约。
- **第二,**批次不是一个统计标签,它冻结一次发行的版本、数量、渠道和有效期策略。
- **第三,**用户券必须保存绝对有效期与快照哈希,不能在运行时猜测历史规则。
- **第四,**缓存键必须包含版本坐标;缓存一致性不能修复可变契约本身。
- **第五,**无法可靠还原的数据应显式 REVIEW,错误的确定性比暂时的不确定更危险。
- **第六,**归档是物理位置变化,不是身份、证据和回放能力的删除。
20. 后续优化与总结
- 建设模板版本 Diff、依赖批次和受影响用户券的可视化影响面。
- 为 Snapshot Schema 建立注册中心、Upcaster 兼容矩阵和持续回放。
- 将 Template/Batch/Snapshot contentHash 纳入 PriceQuote、Redemption 与 Refund 证据链。
- 按状态、时间和法规要求自动化冷热迁移,并定期做抽样恢复演练。
- 为 Published UPDATE、Snapshot Missing 和 Multi RuleHash 设置不可绕过的发布 Gate。
优惠券数据模型的核心不是把字段拆到更多表里,而是明确每个事实何时产生、何时冻结、由谁引用、如何回放。模板版本保证定义历史,发放批次保证发行边界,实例快照保证运行语义,归档索引保证长期证据仍可定位。
**最终结论:**任何已经影响用户权益、订单价格或资金结算的配置,都必须被当成不可变历史契约。新需求通过新版本向前演进,不能通过覆盖旧记录重写过去。
21. 下一篇
第十四篇进入优惠券查询与性能治理:《亿级用户券查询与索引设计:券包分页、状态过滤、热点分片、缓存一致性与归档回源》。重点讨论高基数用户券表的复合索引、游标分页、读模型、缓存键、热点用户和冷热路由。
Coupon System Engineering Case Study · Part 13
单文件 HTML · 10 张 PNG 已 Base64 内嵌 · 面向 CSDN 富文本发布