餐饮 SaaS 优惠券系统架构演进(十三)券模板、发放批次与用户券数据模型:不可变快照、版本演进、状态归档与冷热分层

优惠券系统的数据难题并不只是表多,而是"今天改配置,昨天发的券会不会跟着变"。本篇用一次模板原地修改导致历史券门槛、有效期和退券规则漂移的脱敏事故,拆解不可变版本、批次固定、实例快照、迁移回放与冷热归档。

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 富文本发布

相关推荐
微订校园外卖平台6 小时前
搭建本地外卖平台避坑:校园外卖系统架构、验收与交付清单
系统架构·外卖系统·本地生活·微订
微三云生态系统架构师-彭丹7 小时前
GEO品牌负面舆情监测?多AI问答采集与自动澄清架构
系统架构·大模型应用·舆情监测·geo系统·geo代运营服务·ai引用率·品牌防护
荆棘鸟智能7 小时前
扬尘在线监测数据接入与异常清洗:从断点补传到质量审计
人工智能·算法·系统架构
qq_4017004110 小时前
Qt Modbus 源码剖析:从 QModbusClient 到工业级通信框架
开发语言·qt·系统架构
Jmyd012311 小时前
博物馆文物建模设备系统架构设计:固定式与手持式协同采集方案
系统架构·智慧文博
科威网络11 小时前
《无人场馆系统架构:IoT 硬件对接、预约并发与支付一致性》
物联网·系统架构·软件需求
坐看风云变幻1 天前
《系统架构师成长之路》第 8 期|微服务:单体拆分的时机、粒度与通信
微服务·架构·系统架构
Aman19841 天前
优雅的红点系统:MV数据视图分离与全局集中管理
系统架构·游戏程序
艺杯羹1 天前
告别大模型滥用与延迟失控:确定性优先法则与本地规则引擎实战
前端·人工智能·系统架构