主题
系统设计经典题目:十二种系统,十二组必须守住的约束
短链、秒杀、Feed 和支付系统都可以画成“入口、服务、数据库、缓存、队列”。图相似,不代表难点相同。短链不能因碰撞覆盖旧映射;秒杀不能多卖;聊天要让重连用户找回消息;支付超时后不能擅自认定失败。设计能不能成立,取决于这些约束在并发、重试和故障时是否仍成立。
下面逐题给出需求边界、最小数据与接口、关键路径、失败恢复和选择理由。它们是教学方案,不是某家公司的完整生产架构。文末有十二个有界本地模型和反例测试,帮助观察关键行为;除秒杀模型外,这些状态机按单线程调用,不声称提供通用并发安全。全部模型都只用内存,不模拟真实数据库、消息集群或支付渠道。
1. 短链系统:先保证映射不会被覆盖
先确认短链是否允许修改目标、设置过期、自定义别名,以及点击统计是否影响跳转。这里选择:公开访问、创建要鉴权;别名唯一;支持停用与过期;跳转优先,统计允许异步。公开短链容易被扫描和滥用,不能把“难猜”当作访问控制。
最小数据是 links(code 主键、target、owner、status、expires_at、version),以及创建幂等记录。POST /links 接受 URL、可选别名和幂等键;GET /{code} 查询映射并重定向;PATCH /links/{code} 只允许所有者修改。URL 先限制协议与长度;如果后台要预览网页,另做 SSRF 防护,不能拿任意 target 直接访问内网。
正在绘制图示…
查看图源代码
flowchart LR
C["创建请求"] --> W["校验目标、生成候选码"]
W -->|"唯一键插入;冲突换码"| D[("权威映射")]
U["访问短码"] --> R["跳转服务"]
R --> H["缓存"]
H -->|"未命中"| D
R -->|"校验状态与有效期后 3xx"| U
R -.->|"有界异步点击事件"| A["统计"]
生成码有三种常见选择。自增 ID 转 Base62 简单、无随机碰撞,但容易枚举并暴露大致数量;随机码分散,必须在唯一约束失败后重试;URL 哈希可以稳定生成,但截断后仍有碰撞,而且“相同 URL”是否应该共享链接,涉及所有者、过期与统计语义。长度 L 的 Base62 空间是 62^L;随机选 n 次的碰撞概率近似 n(n−1)/(2×62^L),这个近似只在概率较小时有用,不能替代唯一约束。
模型把候选码固定为 a、b,a 已占用:
go
links := map[string]string{"a": "old"}
code, err := allocate(links, "new", []string{"a", "b"})text
shortlink code=b old=old err=<nil>换码保住了旧映射。生产实现必须把“只有不存在才插入”交给存储的原子约束,不能把模型中的 map 查写直接放到多实例服务。关系数据库的唯一约束可查 PostgreSQL 18 Constraints。
热点短码会集中压垮一个缓存键或分片,可用边缘缓存、进程内短 TTL 缓存和回源合并;不存在的短码也可做短期负缓存,容量必须有上限,防止随机扫描撑满缓存。修改或封禁链接时,长缓存会延迟生效,需要版本化失效或较短缓存期限。
| 选择 | 优点 | 限制与恢复要求 |
|---|---|---|
| 永久重定向 | 稳定映射可减少后续请求 | 客户端可能长时间记住目标,不适合随时改址或撤销 |
| 临时重定向并明确缓存控制 | 更容易控制更新窗口 | 回源更多;“临时”不等于绝不缓存 |
| 同步记录每次点击 | 统计较完整 | 统计故障会拖慢跳转 |
| 异步统计 | 跳转路径短 | 需要选择丢失、重复和延迟的容忍度 |
具体状态码语义和缓存规则应分别核对 RFC 9110 与 RFC 9111。缓存丢失后从权威库恢复;权威库不可用时,是否继续使用未过期旧缓存,必须与停用及时性一起决定。
2. 秒杀/抢购:入口挡流量,库存事务守住不超卖
这里假设每用户限购一件、库存固定、成功下单后保留一段支付时间。需要分别定义“抢到资格”“生成订单”“支付成功”,否则返回一个 200 就容易让用户误以为一定买到了。
最小数据为 inventory(sku、available、version)、reservation(id、user、sku、quantity、state、expires_at)、orders(id、reservation_id、state),并对业务限购键做唯一约束。POST /reservations 带请求键;GET /reservations/{id} 查询结果。重复请求返回同一预留结果,不能重复扣库存。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Reserved: 原子扣减可售量并创建预留
Reserved --> Confirmed: 核验支付成功并转换状态
Reserved --> Released: 到期或取消且条件更新成功
Confirmed --> [*]
Released --> [*]
关键不变量是 available 不小于零,且可售、已预留、已售出的数量能与初始库存及显式补货对应。数据库方案是在同一事务里执行带 available≥quantity 条件的扣减、创建预留和幂等结果。单行更新条件与并发行为要按目标数据库隔离级别验证,不能笼统地说“用了事务就不会超卖”。PostgreSQL 的并发更新说明可作为一个具体实现依据。
模型用锁把检查和扣减合在一起:
go
if ok, exists := s.Orders[key]; exists { return ok }
ok := s.Stock > 0
if ok { s.Stock-- }
s.Orders[key] = oktext
sale first=true replay=true other=false stock=0故意错误的测试不需要制造 data race:先让 A、B 都读到库存 1,再分别写回 0,最终库存仍是 0,却接受了两单。
text
broken schedule: stock=0 accepted=2因此 race 测试通过不证明业务不超卖。正常模型另跑 100 个并发请求抢 3 件库存,只接受 3 个。
入口应按活动、用户、设备风险和下游容量做有界准入;排队返回排队编号或明确拒绝,队列容量与可接受等待时间匹配。Redis 原子脚本可将检查与预扣放在同一执行边界,但 Redis 成功与数据库落单仍是两处状态,必须定义持久化、故障切换、补偿和对账。脚本执行期间会阻塞其他命令处理,因此脚本应短小有界,见 Redis Lua 执行文档。
支付确认和到期释放会竞速。两者都必须从 Reserved 做条件状态转换,只有一个成功;重复释放不能反复加库存。支付渠道已经成功而本地预留释放后的异常,需要退款或人工处理的业务流程,不能用“重新扣一下”掩盖。
| 路径 | 适用条件 | 主要代价 |
|---|---|---|
| 数据库直接预留 | 可承载的峰值、正确性优先 | 热 SKU 行锁竞争,需要入口限流 |
| 预分库存额度到分区 | 单热点写入已成为瓶颈 | 局部额度闲置、调拨与恢复更复杂 |
| 先排队再事务落单 | 可接受等待,峰值远大于库存 | 排队状态、超时取消和积压管理 |
3. Feed 流:把合并工作放在写时还是读时
先选时间序还是推荐排序。这里做按时间倒序的关注流,要求新内容在可接受延迟内出现,分页不重复展示同一帖子,并在读取时检查最新权限与删除状态。推荐模型与广告混排是另一个系统,不能用一个“排序服务”盒子假装完成。
数据至少有 posts(post_id、author、created_at、visibility、version)、follows(follower、followee)、inbox(user、post_id、排序键)。POST /posts 发布;GET /feed?cursor=... 读取。游标包含稳定排序键,通常需要时间加唯一 ID;若排序会动态变化,还要设计会话快照或接受翻页漂移。
| 模式 | 发布时做什么 | 读取时做什么 | 适合与代价 |
|---|---|---|---|
| 推模式 | 把 post_id 写进粉丝收件列表 | 读收件列表并取内容 | 读快;写放大与冷用户存储高 |
| 拉模式 | 只写作者列表 | 取关注作者的近期列表并合并 | 发布便宜;关注多时读扇出大 |
| 混合 | 普通作者推,高粉作者保留待拉 | 合并预计算列表与高粉作者列表 | 平衡成本;需要去重与切换规则 |
假设作者发一篇文章,有 F 个粉丝,推模式产生约 F 次列表写入;拉模式成本大致转移到这些用户实际打开 Feed 的时候。阈值应结合发帖频率、活跃粉丝比例、读取频率和延迟目标决定,不能规定“超过某固定粉丝数必用拉”。
正在绘制图示…
查看图源代码
flowchart LR
P["发帖提交"] --> E["事件日志"]
E --> F["普通作者:分批 fanout"]
F --> I[("用户 inbox:存 post_id")]
P --> A[("作者时间线")]
R["读 Feed"] --> I
R -->|"高粉作者候选"| A
I --> M["按稳定键合并、去重、权限过滤"]
A --> M
M --> O["有界一页结果"]
扇出任务要带 post_id 和批次游标,崩溃后从进度继续;inbox 唯一键 (user_id, post_id) 避免重投重复。模型用唯一整数代替真实排序键,合并推列表 [7,4] 和拉列表 [9,7]:
go
feed([]int{7, 4}, []int{9, 7}, 10, 3)text
feed=[9 7 4]它只证明候选合并去重,不证明跨页快照或权限正确。生产读取要在返回前处理拉黑、取消关注、删帖和可见性变更,不能认为“曾推到 inbox”就是永久授权。TAO 原始论文讨论了社交图查询与读取时隐私检查,但不应把它直接当作本文混合 Feed 的完整实现,见 TAO §2。
热点作者的发布会形成扇出洪峰,按粉丝分片、批量写、只预计算活跃用户可减压;代价是新登录冷用户需要补建。重建时从帖子与关注关系恢复候选,再应用当前权限。关注关系在发帖时还是读取时生效,需要产品明确,历史回填会因此不同。
4. IM 即时通讯:连接在线,不代表消息已可靠送达
这里做单聊和有上限的小群,要求同一会话内有稳定顺序、断线能补消息、多设备同步;不承诺所有会话之间的全局顺序。“服务端已接收”“设备已收到”“用户已读”是三种状态,应分别记录。
最小数据包括 conversations、members、messages(conversation_id、seq、message_id、sender、client_msg_id、body)、device_cursor。发送接口可以是 WebSocket 消息帧中的 Send,也可另设 POST /messages;补拉接口按 conversation_id 和 after_seq 分页。服务端先检查成员权限,再用 (会话、发送者、client_msg_id) 去重,同键不同内容应拒绝。
正在绘制图示…
查看图源代码
sequenceDiagram
participant C as 发送设备
participant G as 连接网关
participant S as 会话存储
participant R as 接收设备
C->>G: client_msg_id + 内容
G->>S: 校验成员、分配会话序号、持久化
S-->>G: seq=42 已提交
G-->>C: 服务端接收 ACK
G->>R: 推送 seq=42
R-->>G: 设备收到 ACK
R->>G: 重连后请求 after_seq=41
G->>S: 拉取缺口
S-->>R: 包含 seq=42 的持久消息
WebSocket RFC 6455提供双向连接和帧协议,不替应用定义消息持久化、跨重连去重和已读语义。TCP 当前连接里的有序传输也不能消除两次不同连接上的业务重试。
模型同一个客户端消息发送两遍:
go
m1, _ := chat.Send("client-1", "hi")
m2, _ := chat.Send("client-1", "hi")text
chat seq=1 replay=1 missed=1第二次复用序号,离线游标 0 仍只补一条。模型以 slice 长度分配 seq,仅适用于这个单线程单会话模型;生产可按会话分区由单个顺序所有者处理,或用数据库序列化分配。序号通常只需单调,不必无缺口;若删除留空号,补拉协议不能无限等待“缺失序号”。
| 失败或压力 | 处理方式 | 代价 |
|---|---|---|
| 持久化后发送方没收到 ACK | 相同 client_msg_id 重试 | 需要保留去重记录或唯一约束 |
| 网关重启、设备离线 | 持久消息加游标补拉 | 网关内存不是唯一消息存储 |
| 慢设备读不动 | 有界发送缓冲,断开后补拉 | 在线推送可能中断,但不无限占内存 |
| 大群扇出 | 分批推送或读时拉取 | 单聊方案不能无条件扩成万人群 |
| 多设备已读 | 按用户或设备定义游标,再单调推进 | 不能把一个设备收到当作用户已读 |
消息保留期、附件存储、端到端加密和删除合规要另定边界。端到端加密会改变服务端搜索、审核和多设备密钥同步方式,不能只在图上补一把锁。
5. 限流器:限制哪种资源,决定算法与故障策略
先问限的是每用户操作数、每租户花费、每 IP 请求数,还是下游并发。每秒 100 次请求并不一定保护住内存:若请求持续 30 秒,在途量仍可能很大。因此速率限制与并发限制常常需要一起用。
最小接口是 Allow(key, cost),返回允许与否、剩余额度或建议等待时间;HTTP 拒绝可使用 429,并按协议选择 Retry-After,见 RFC 6585 §4。key 必须有明确身份来源,不能信任客户端随便填的用户号。
| 算法 | 保存的状态 | 行为 | 主要限制 |
|---|---|---|---|
| 固定窗口 | 窗口计数 | 每窗口最多 N 次 | 窗口边界两侧可短时接近 2N |
| 滑动日志 | 每次请求时间 | 精确限制任意滑动窗口 | 状态量随请求数增长 |
| 滑动窗口计数 | 相邻窗口计数与权重 | 低成本近似平滑 | 近似误差需接受 |
| 令牌桶 | 剩余令牌、上次补充时间 | 长期速率 r,最多突发 b | 时间与原子更新必须正确 |
| 漏桶/有界队列 | 排队任务、出队时刻 | 输出趋于平滑 | 增加等待;队列满时仍要拒绝 |
令牌桶每次先补充 min(容量, 原令牌 + elapsed×速率),令牌够才扣。模型容量 2、每秒补 1 个,输入时间由测试直接传入:
go
bucket := &Bucket{Tokens: 2, Capacity: 2, Rate: 1}text
limit=true true false true前三次都在 0 ms,第四次在 1000 ms。测试还验证时间倒退时拒绝计算,避免负时间错误扣减。单进程可以用单调时钟;跨机器的全局限流需要共享权威时间或设计可接受的误差,不能假设各节点时钟完全一致。
最小部署是入口中间件调用限流服务,限流服务按 key 路由到状态分片,再原子更新 (tokens, last_refill);服务端验证身份,业务 handler 只在准入后执行。单实例小系统可以把同一状态放在进程内并用锁保护,但扩为多实例后额度范围就变了。
分布式令牌桶的读、补、扣必须原子完成。Redis 脚本是一种选项;多分片 key 的操作边界仍需另看。将额度预分给多个实例可减少中心调用,但实例故障可能暂时浪费额度,租约过期和重新发额度若重叠又会超发。每台独立限 100 次不等于全局限 100 次。
故障时放行能保护可用性,却可能压垮下游;故障时拒绝能保护稀缺资源,却会误伤正常用户。支付防重复、昂贵导出和普通页面读取应有不同策略。维护 key 的 TTL 和最大数量,限制恶意构造的高基数键;把拒绝率、限流原因和实际下游负载一起观测,避免“限流生效但资源仍满”。
6. 分布式 ID:唯一、排序和不可猜不是同一个要求
需求应逐项确认:是否全局唯一、是否大致按时间排序、是否要离线生成、字段长度、是否暴露给外部。订单号可猜与否是安全边界的一部分;任何 ID 都不代替授权检查。
| 方案 | 生成与数据 | 好处 | 风险和代价 |
|---|---|---|---|
| 数据库序列 | 中心顺序分配 | 简单、容易管理 | 中心依赖;缓存与回滚可能留下空号 |
| 号段分配 | 中心记录下一个区间,节点本地用 | 降低中心访问频率 | 节点重启会浪费剩余段;不能重用已发区间 |
| UUIDv4 | 随机生成 | 不依赖中心时间顺序 | 概率唯一;随机写入局部性需评估 |
| UUIDv7 | 时间戳加随机/单调相关字段 | 更利于按生成时间聚集 | 不是跨节点严格全序,仍需处理时钟与随机性 |
| Snowflake 类 | 时间差、节点号、序列号 | 定长整数、本地产生 | 节点号唯一、时钟回退、序列耗尽都要管 |
UUID 的位布局与生成要求见 RFC 9562。Snowflake 的原始实现可查 归档 IdWorker。本文模型选 41 位毫秒时间差、10 位 worker、12 位序列作为教学布局,并不声称逐行复刻原始版本。
正在绘制图示…
查看图源代码
flowchart LR
T["相对 epoch 的毫秒数"] --> C["检查回退与时间范围"]
W["独占 worker_id"] --> C
C --> S["同毫秒序列加一;耗尽拒绝或等待"]
S --> I["time 左移 22 位 OR worker 左移 12 位 OR seq"]
模型同一毫秒调用两次,序列不同;随后时间回退:
go
gen := &IDGen{Worker: 3}
id1, _ := gen.Next(100)
id2, _ := gen.Next(100)
_, err := gen.Next(99)text
ids distinct=true rollback=clock moved backward测试在同一毫秒发完 4096 个后,第 4097 个明确失败,到下一毫秒恢复。生产可以短时间等待真实时钟前进,或采用明确的逻辑时间策略,但不能静默把序列绕回零。
即使进程里用了锁,两个节点拿到同一 worker_id 仍会重复。租约分配要避免旧节点在失联后继续发号;重启时重用节点号,也要处理上一次已使用的最高时间戳。时钟位决定使用寿命,41 位毫秒约为 69.7 年,epoch 到期需迁移方案。大整数送到 JavaScript 客户端时通常用字符串避免精度丢失。恢复策略是停止不安全发号并报警,不能为了“高可用”牺牲唯一性。
7. 延迟任务调度:到点、领走、执行成功是三个状态
先定义精度:允许晚 100 ms,还是允许晚一分钟?到点后是至少执行一次,还是宁可漏掉也不要重复?这里选择“到期后尝试执行,允许重复投递,业务副作用必须幂等”,并允许重试、取消和死信人工处理。
数据为 jobs(id、due_at、payload、state、attempt、lease_until、lease_epoch),另存业务幂等结果。POST /jobs 带幂等键创建;DELETE /jobs/{id} 通过条件状态转换取消;GET 查询状态。取消成功只应承诺尚未执行部分的语义,不能假装撤回已发生的外部副作用。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Scheduled
Scheduled --> Leased: 到期且原子领取
Leased --> Succeeded: 当前租约确认
Leased --> Scheduled: 租约超时后重领
Leased --> RetryWait: 可重试错误
RetryWait --> Leased: 到重试时间
Leased --> DeadLetter: 达重试上限或不可恢复错误
Scheduled --> Cancelled: 条件取消
小规模可用带 due_at 索引的数据库周期扫描;内存最小堆便于找最近到期项,但重启必须从持久存储重建。时间轮适合大量定时器和固定精度;有序集合便于按分数查到期任务,但“查询到期”和“标记领取”仍需原子协调。分布式周期调度的故障语义见 Google SRE Cron 章节。
模型用递增 epoch 表示每次领取代次:
go
e1, _ := job.Claim(10, 5)
e2, _ := job.Claim(15, 5)
old, current := job.Ack(e1), job.Ack(e2)text
job old_ack=false current_ack=true第一个 worker 的租约到期,第二个重新领取;旧确认不能覆盖新状态。这仍没有阻止旧 worker 向外部系统发请求。若副作用支持 fencing token,应让下游拒绝过时代次;否则使用业务幂等键、结果查询和补偿。只给调度表加租约,不能得到“恰好执行一次”。
多个任务同一时刻到期会突发,按分区和优先级限量领取,用有界 worker 池;重试采用退避、抖动和最大尝试期限,不无限重试。监控应包括最老到期任务的延迟和执行耗时,而不仅是队列长度。恢复时扫描过期租约与未完成任务,明确运行中任务的结果可能未知,再决定查结果还是重投。
8. 排行榜:先定义同分怎么排,再考虑 Top K
需求包括实时还是定期刷新、全局榜还是分组榜、只读前 100 名还是查询自己的名次、是否有赛季重置和反作弊。这里用每赛季用户积分榜,同分按用户 ID 升序,明确这是“顺序名次”;若产品要 1、2、2、4 或 1、2、2、3,需要另外定义竞争排名或密集排名。
数据至少有 score_events(event_id、season、user、delta) 和 scores(season、user、score、version)。POST /score-events 只接受可信业务事件;GET /leaderboard?season=...&limit=...;GET /rank/{user} 查询个人。不能让客户端直接提交一个想要的最终分数。
| 结构 | 适用读取 | 主要代价 |
|---|---|---|
| 数据库分组/排序 | 小中规模、复杂筛选 | 大量实时排序和深分页会贵 |
| Redis sorted set | 有序取区间、改分数、查排名 | 热榜写入集中;持久与恢复需要权威来源 |
| 分片本地 Top K 再归并 | 全局 Top K | 查询任意用户精确名次较难 |
| 定期批量快照 | 可接受刷新间隔 | 展示滞后,实时资格判定不能直接使用旧榜 |
Redis sorted set按 score 排序,同分按成员字节序处理;反向读取也会影响同分方向,不能默认恰好符合产品规则。模型使用显式比较器,先高分、再 ID 升序:
text
ranking=[{b 100} {a 90} {z 90}]这是输入 z=90、b=100、a=90 的实测结果,不是 Redis 输出。单纯把“分数×系数+时间”塞进浮点 score,可能超过精确整数表示范围或混淆排序规则;应证明取值范围,必要时保留单独排序字段。
最小架构是可信积分事件写入权威日志,再由按用户分区的消费者更新榜单投影,查询服务读取指定赛季榜单;前几页可缓存,日志作为恢复来源。
并发增分需要原子累加,重复事件需要在同一个业务边界去重。消费者崩溃后重放原始积分事件,得到同样最终分数;删除作弊积分应追加修正事件并保留审计来源,而不是直接改缓存且丢失原因。热门榜可缓存前几页,实时分数与展示榜允许滞后时要标更新时间。
分片 Top K 的一个有用边界是:若每个用户只在一片,排序规则完全相同且分片视图在同一逻辑时刻,那么各片取本地 Top K 的并集足以求全局 Top K。实际异步读取不自动满足同一时刻,因此跨片严格实时排名仍需协调或快照协议。
9. 计数系统:浏览量、去重人数和余额不能共用一种计数器
先说明算什么。PV 是事件次数,UV 是一段时间内不同主体数;点赞数是当前有效关系数;账务余额还涉及货币、审计和合法状态。本文讨论浏览/点赞类业务统计,不把一个可丢失的内存计数器用于资金决策。
最小数据为 events(event_id、object、actor、time、kind)、计数投影和去重状态。POST /events 带稳定事件 ID;GET /counts/{object} 返回数值、统计窗口和更新时间。点赞/取消点赞若表达的是当前状态,用 (对象, 用户) 的关系和版本通常比盲目 +1、−1 更容易处理乱序。
| 需求 | 可选方案 | 它没有自动解决的事 |
|---|---|---|
| 单节点原子递增 | 数据库条件更新、Redis INCR | 网络超时后的重试去重、跨故障丢失 |
| 热点分摊 | 按事件或主体分到多个计数桶,读取求和 | 精确瞬时总数需要一致的读取边界 |
| 多副本单调累加 | G-counter 每副本分量,合并取 max | 减少计数、重复业务事件、无限副本元数据 |
| 近似不同人数 | HyperLogLog | 精确成员列表与精确删除 |
| 必须精确且可审计 | 唯一业务事实加事务更新,或可回放日志 | 成本、延迟与保留要求更高 |
INCR 官方文档说明命令行为;命令原子不表示“超时重试只算一次”。模型给同一个 e1 加两遍,只计一次;另一个模型把副本 a=2 与旧 a=1、b=3 按分量最大值合并:
text
counter dedup=1 merged=5G-counter 的关键是每个副本只能增加自己的单调分量,合并用 max,重复和乱序传播不会把旧值加第二遍。如果两个节点共用同一副本身份并各自从旧值递增,仍可能丢更新。支持增减的 PN-counter 可分别维护增加和减少的 G-counter,再求差;它不自动保证结果非负。CRDT 的收敛条件参见 原作者资料。
Redis HyperLogLog 文档解释的是近似基数统计,不能拿来回答某个用户是否来过。是否接受误差,应按业务用途决定;用于趋势图和用于限额结算的要求不同。
最小异步架构是采集入口持久记录事件,按对象与分桶键分区的消费者执行去重和累加,查询层合并计数桶并返回更新时间;若要求写后立即精确读,应把相应读写留在明确的同步事务边界内。
热点对象可以分桶增量、批量刷写或异步聚合;每种方式都要说明故障时最多丢多少,以及重放如何防重复。去重记录过期后,旧事件重放可能再次累加,因此去重寿命要覆盖消息保留和允许重放窗口。恢复时从事件或关系表重算,抽样比对权威事实与投影差额。
10. 搜索提示:每敲一个字都查询,要控制候选和隐私
搜索提示可能是“前缀补全”“拼写纠错”或“搜索结果预览”,这三种需求不同。这里做公开词库的前缀补全,返回最多 K 条,排序按热度并以词文本打破同分,不把个人历史或敏感查询放进公共候选。
数据包括词条、标准化形式、语言、权重、可见性和版本。GET /suggest?q=...&limit=... 限制输入长度与 K,按用户权限筛候选;日志应避免无期限保存敏感原始输入。前端防抖减少请求,并给每次查询编号,较晚返回的旧查询不能覆盖较新的输入结果。
正在绘制图示…
查看图源代码
flowchart LR
L["许可公开的词条与统计"] --> B["离线清洗、规范化、候选构建"]
B --> V["不可变索引版本 V+1"]
V --> S["校验后原子切换索引指针"]
Q["用户前缀"] --> N["规范化、长度与速率限制"]
N --> I["前缀查找与有界候选"]
S --> I
I --> F["权限过滤、稳定排序、Top K"]
Trie 用字符边逐步定位前缀,再枚举子树;若子树巨大,只做前缀定位还不够,需要缓存节点 Top K 或有界搜索。FST 可以压缩共享路径,工程实现要权衡构建成本、内存和更新方式。数据库前缀索引适合较小词库;搜索引擎的 completion suggester 提供专门结构,具体配置见 Elasticsearch 8.19 Suggesters。
模型刻意用线性扫描作为排名正确性的参照,输入 Go 规范化为 go:
text
suggest=[go golang]它没有声称实现 Trie,也没有做中文分词、拼音、Unicode 归一化或完整大小写折叠。生产标准化必须在构建索引与查询时一致,且与语言需求相符;不能简单删除字符导致两个不同词被错误合并。
| 问题 | 可选处理 | 代价与恢复 |
|---|---|---|
| 热前缀请求集中 | 缓存公开候选,限制候选数量 | 个性化或私有结果不能混用公共缓存 |
| 热度高频变化 | 小增量层叠加周期快照 | 查询要合并和去重 |
| 索引更新中断 | 新版本完整构建后切换,保留上一版 | 占双份空间,但可快速回退 |
| 删除敏感词必须立即生效 | 返回前叠加禁用表,随后重建 | 多一层检查,需定义传播上限 |
短暂失败可返回较旧但仍合规的候选,或者空列表;提示服务不应把主搜索和输入界面一起拖死。
11. 支付与对账:未知结果不能当失败,平衡分录不能当正确业务
这里讨论的是一个商户如何组织支付状态与核对流程,全部示例都是虚构内存数据,没有真实扣款、转账或支付账号操作,也不是可上线的银行账务实现。真实设计还需要渠道契约、合规、财务与审计人员确认。
需求至少要确认支付方式、币种、授权与扣款是否分开、退款和拒付、支付最终状态、渠道幂等寿命与查询方式。最小数据有 payments(id、order_id、amount_minor、currency、state、provider_id)、payment_attempts、webhook_inbox、ledger_entries(transaction_id、account、currency、signed_amount) 和 reconciliation_runs。金额用明确币种的最小单位整数或合适的定点表达;不能用 float64 累加金额,也不能把所有币种一律当两位小数。
创建接口 POST /payments 带业务幂等键;GET /payments/{id} 用于查状态;渠道 webhook 是另一条输入路径,需要验签、去重,并在持久记录后异步处理。客户端跳转回成功页面不能成为唯一成功依据。Stripe 的状态核验文档是一个具体渠道例子,不等于所有渠道都使用相同状态名。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Created
Created --> Pending: 持久记录请求意图
Pending --> Unknown: 超时或连接断开,结果不明
Pending --> Succeeded: 核验渠道成功
Pending --> Failed: 核验终止失败
Unknown --> Succeeded: 查询、通知或对账确认成功
Unknown --> Failed: 渠道确认终止失败
Succeeded --> RefundPending: 明确发起退款流程
RefundPending --> Refunded: 核验退款完成
服务端向渠道发出请求后超时,可能是请求没到,也可能是已扣款但响应丢失。此时写 Unknown,保留原业务键,优先按渠道契约重试同一请求或查询;更换键重新扣款可能制造第二笔交易。幂等键要绑定参数、保留时间与结果语义,见 Stripe 幂等请求及低层错误处理。渠道提供的语义、期限与可重试错误必须逐一核对。
webhook 可能重复或乱序,见 Stripe webhook 文档。事件 ID 去重防止重复处理,同一支付的状态转换还要遵守版本与合法迁移。收到旧的失败事件不能覆盖已确认成功;必要时查询渠道权威对象,不能只按事件到达时间决定最终状态。
模型预置 p1 状态 Unknown、金额 100 个虚构 USD 最小单位;把已核验的成功观察提交给本地 Reconcile 两次:
go
_ = ledger.Reconcile("p1", "fake-charge-1", "USD", 100)
_ = ledger.Reconcile("p1", "fake-charge-1", "USD", 100)text
payment state=succeeded entries=2 balance=0模型写两条带符号分录,总和为零,并用 payment ID 避免重复入账。它只演示“结果从未知变明确后,再以原子、幂等方式登记”。没有实现真实会计科目、余额约束、手续费、多币种、跨支付的渠道交易号唯一约束、持久化与并发事务。分录和为零仅是必要的局部检查:给错用户记账也能平衡,所以金额、币种、订单、商户和渠道交易号都需要核对。
| 对账差异 | 先核对的证据 | 处理方向 |
|---|---|---|
| 渠道成功,本地未知 | 渠道交易号、业务键、金额、币种、商户 | 幂等补记状态与分录 |
| 本地成功,渠道没有匹配记录 | 查询口径、时间窗口、渠道最终状态 | 建异常单,不自动再扣款或抹账 |
| 金额、币种或手续费不一致 | 原订单、渠道明细与结算口径 | 分别定位业务、计费或汇兑差异 |
| 重复支付、退款、拒付 | 原交易关联与后续事件链 | 用独立冲正/退款分录修正并留审计 |
对账不只是比较两边总额。总额相同仍可能一笔多记、一笔漏记;需要逐笔匹配、汇总检查、差异状态、重跑去重和明确的人工处理入口。支付成功、渠道余额变化与银行结算到帐也不是同一时刻。Stripe 余额报告说明了一种结算核对数据来源;实际字段与结算周期以所用渠道为准。
扩容优先按商户或支付 ID 保持单笔状态串行与幂等,而不是把同一笔交易同时丢给多个无协调 worker。渠道故障时限制重试并保留待核验队列;达到处理时限的 Unknown 必须告警,不能悄悄变成 Failed 清掉积压。
12. 通知推送:供应商接受不等于用户已经看到
需求先分事务通知、营销通知和站内消息,再决定优先级、用户偏好、静默时间、语言、渠道和过期时间。本文假设站内是可查询的持久通知,邮件/短信/设备推送只是额外送达路径;营销退订不能被重试机制绕过。
数据至少有 notification_intents(event_id、recipient、type、payload_version)、deliveries(notification_id、channel、state、attempt、provider_id)、preferences 和 devices。POST /notification-intents 由获准业务方调用;GET /notifications 给用户查询。去重键通常由业务事件、接收者和渠道组成,不是只对模板文本做哈希。
正在绘制图示…
查看图源代码
flowchart LR
B["业务事务 + outbox"] --> Q["通知意图队列"]
Q --> P["偏好、静默时间、有效期与去重"]
P --> I[("站内收件箱")]
P --> D["按渠道分开的有界队列"]
D --> S["邮件 / 短信 / 推送适配器"]
S --> V["外部提供方"]
V -->|"接受回执与可用的状态事件"| R["投递状态核对"]
R --> D
状态应区分 queued、sending、provider_accepted、delivered(渠道能证明时)、expired、failed;若渠道支持打开回执,再单独记录 observed/opened。设备离线、token 失效、系统省电、用户关闭权限都可能影响终端收到。FCM 的投递说明可用来核对一种提供方的指标,不能由 API 接受成功推导每个用户看到了内容。
| 失败 | 推荐动作 | 需要防止的事 |
|---|---|---|
| 提供方限流或短暂不可用 | 按渠道退避、抖动和配额重试 | 一个供应商故障耗尽全部 worker |
| 请求超时,结果未知 | 原幂等键重试或查询状态 | 换键重发造成重复通知 |
| 无效 token/永久地址错误 | 标记失效,停止无意义重试 | 每次投递都重复失败 |
| 消息已过期或用户退订 | 发送前重新检查 | 老队列绕过新的偏好与时效 |
| 模板更新后回放旧事件 | 保留模板版本或兼容渲染规则 | 重放出与原业务不同的内容 |
本地模型故意丢掉第一次 ACK,再重投同一个通知:
go
_ = n.Deliver("event-1", true)
_ = n.Deliver("event-1", false)text
notify attempts=2 visible=1这里的“一次可见”成立,因为模型接收方用 event ID 去重。很多邮件、短信接口并不提供满足你需求的持久去重契约;若接受成功后 ACK 丢了,发送方单独一个状态表无法完全消除重复。必须查提供方能力,再在可能漏发与可能重复之间做业务选择。队列的 exactly-once 宣传也不能自动扩展到外部发信副作用,Kafka 设计文档对投递语义边界有具体说明。
大规模通知按租户、优先级和渠道隔离,控制单收件人频率;批量活动分批展开,避免创建意图时一次占住全部内存。恢复从尚未终止的 delivery 重建工作,先查未知结果,再重试;监控最老消息年龄、端到端时效、永久错误率和退订执行情况,而不只看发送 QPS。
把这些题放在一起看
十二个方案反复遇到的几件事很具体:稳定业务键给重试一个身份;原子边界把检查与状态变化放在一起;日志或权威数据让状态可重建;版本与租约区分新旧执行者;有界队列和明确拒绝防止故障扩散。
它们没有消除业务差异。浏览计数允许近似,库存不允许负数;Feed 可以晚一点出现,权限变更却未必能等同样久;通知可重试,支付重试必须维持同一业务意图。设计时先写不变量和结果未知时的动作,再挑组件,才知道每个组件究竟负责守住什么。
依据与本机验证
本文采用的接口、数据表、阈值选择与状态机是教学设计。链接的官方文档、标准与原始论文用于支持具体语义,不意味着对应产品采用了本文完整架构。
- HTTP:RFC 9110、RFC 9111、429 RFC 6585、WebSocket RFC 6455。
- 数据约束:PostgreSQL 18 Constraints、Transaction Isolation。
- 社交与调度:TAO 原始论文、Google SRE Distributed Periodic Scheduling。
- ID:UUID RFC 9562、Snowflake 原始归档代码。
- Redis:脚本原子执行、Sorted sets、INCR、HyperLogLog。
- 收敛与检索:CRDT 原作者资料、Elasticsearch 8.19 Suggesters。
- 支付:Stripe 幂等、状态核验、错误处理、Webhook、余额报告。
- 通知与消息:FCM 投递、Kafka 4.1 设计。
2026-10-13 在 Go 1.27.2、Linux/amd64 实际执行全部模型,十二行输出与断言一致;普通测试、race 和 vet 均通过。并发秒杀、同键异参、时钟倒退、ID 序列耗尽、任务未到期、过期租约确认、支付金额/币种核对、G-counter 重复合并及“先查再写”的受控反例都有测试。没有运行真实集群、数据库事务、分布式分区、支付接口或外部通知请求。模型中的失败是预设状态转换,不是部署级故障注入。
完整模型与测试
下面所有文件放在同一目录。没有外部依赖、监听端口或后台进程,执行结束即释放内存。输入仅为本文固定的小集合;它们不是接受任意用户输入的服务库。
sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...go.mod
go.mod
module example.com/classicmodels
go 1.27.0models.go
代码块 · 350 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"slices"
"strings"
"sync"
)
// These bounded in-memory models do not provide durable or distributed storage.
func allocate(links map[string]string, target string, candidates []string) (string, error) {
for _, code := range candidates {
if _, exists := links[code]; !exists {
links[code] = target
return code, nil
}
}
return "", errors.New("collision budget exhausted")
}
type Sale struct {
mu sync.Mutex
Stock int
Orders map[string]bool
}
func (s *Sale) Reserve(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if ok, exists := s.Orders[key]; exists {
return ok
}
ok := s.Stock > 0
if ok {
s.Stock--
}
s.Orders[key] = ok
return ok
}
func feed(pushed, pulled []int, before, limit int) []int {
seen := map[int]bool{}
out := []int{}
for _, src := range [][]int{pushed, pulled} {
for _, id := range src {
if id < before && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
}
slices.Sort(out)
slices.Reverse(out)
if len(out) > limit {
out = out[:limit]
}
return out
}
type Message struct {
Seq int
Key, Text string
}
type Chat struct {
Messages []Message
Seen map[string]Message
}
func (c *Chat) Send(key, text string) (Message, error) {
if old, ok := c.Seen[key]; ok {
if old.Text != text {
return Message{}, errors.New("payload conflict")
}
return old, nil
}
m := Message{len(c.Messages) + 1, key, text}
c.Seen[key] = m
c.Messages = append(c.Messages, m)
return m, nil
}
func (c *Chat) After(seq int) []Message {
out := []Message{}
for _, m := range c.Messages {
if m.Seq > seq {
out = append(out, m)
}
}
return out
}
type Bucket struct {
Tokens, Capacity, Rate float64
LastMS int64
}
func (b *Bucket) Allow(nowMS int64) (bool, error) {
if nowMS < b.LastMS {
return false, errors.New("clock moved backward")
}
b.Tokens = min(b.Capacity, b.Tokens+float64(nowMS-b.LastMS)/1000*b.Rate)
b.LastMS = nowMS
if b.Tokens < 1 {
return false, nil
}
b.Tokens--
return true, nil
}
type IDGen struct {
Worker, Last, Seq int64
Started bool
}
func (g *IDGen) Next(ms int64) (int64, error) {
if g.Worker < 0 || g.Worker >= 1024 || ms < 0 || ms >= 1<<41 {
return 0, errors.New("out of range")
}
if g.Started && ms < g.Last {
return 0, errors.New("clock moved backward")
}
seq := int64(0)
if g.Started && ms == g.Last {
seq = g.Seq + 1
}
if seq >= 4096 {
return 0, errors.New("sequence exhausted; advance clock")
}
g.Last, g.Seq, g.Started = ms, seq, true
return ms<<22 | g.Worker<<12 | seq, nil
}
type Job struct {
Due, LeaseUntil int64
Epoch int
Done bool
}
func (j *Job) Claim(now, lease int64) (int, bool) {
if lease <= 0 || j.Done || now < j.Due || now < j.LeaseUntil {
return 0, false
}
j.Epoch++
j.LeaseUntil = now + lease
return j.Epoch, true
}
func (j *Job) Ack(epoch int) bool {
if j.Done || epoch != j.Epoch || epoch == 0 {
return false
}
j.Done = true
return true
}
type Score struct {
User string
Points int
}
func ranking(xs []Score) []Score {
out := slices.Clone(xs)
slices.SortFunc(out, func(a, b Score) int {
if a.Points > b.Points {
return -1
}
if a.Points < b.Points {
return 1
}
return strings.Compare(a.User, b.User)
})
return out
}
type Counter struct {
Value int
Seen map[string]bool
}
func (c *Counter) Add(event string, n int) {
if c.Seen[event] {
return
}
c.Seen[event] = true
c.Value += n
}
func mergeG(a, b map[string]int) map[string]int {
out := map[string]int{}
for k, v := range a {
out[k] = v
}
for k, v := range b {
out[k] = max(out[k], v)
}
return out
}
func sum(m map[string]int) int {
n := 0
for _, v := range m {
n += v
}
return n
}
type Suggestion struct {
Text string
Weight int
}
// Linear scan is only a reference for ranking correctness, not a production index.
func suggest(dict []Suggestion, prefix string, k int) []string {
prefix = strings.ToLower(strings.TrimSpace(prefix))
xs := []Suggestion{}
for _, x := range dict {
if strings.HasPrefix(x.Text, prefix) {
xs = append(xs, x)
}
}
slices.SortFunc(xs, func(a, b Suggestion) int {
if a.Weight > b.Weight {
return -1
}
if a.Weight < b.Weight {
return 1
}
return strings.Compare(a.Text, b.Text)
})
if len(xs) > k {
xs = xs[:k]
}
out := []string{}
for _, x := range xs {
out = append(out, x.Text)
}
return out
}
type Entry struct {
Account string
Amount int64
}
type Payment struct {
State string
Amount int64
Currency, ProviderID string
}
type Ledger struct {
Payments map[string]Payment
Posted map[string]bool
Entries []Entry
}
func (l *Ledger) Reconcile(id, providerID, currency string, amount int64) error {
p, ok := l.Payments[id]
if !ok {
return errors.New("unknown payment")
}
if amount <= 0 || amount != p.Amount || currency != p.Currency || providerID == "" {
return errors.New("reconciliation mismatch")
}
if p.ProviderID != "" && p.ProviderID != providerID {
return errors.New("provider ID conflict")
}
if l.Posted[id] {
return nil
}
// One local atomic transition is assumed; this is neither a banking ledger nor a provider call.
l.Entries = append(l.Entries, Entry{"provider-clearing", amount}, Entry{"merchant-payable", -amount})
p.State, p.ProviderID = "succeeded", providerID
l.Payments[id] = p
l.Posted[id] = true
return nil
}
func balance(es []Entry) int64 {
var n int64
for _, e := range es {
n += e.Amount
}
return n
}
type Notification struct {
Seen map[string]bool
Visible int
Attempts int
}
func (n *Notification) Deliver(id string, loseACK bool) error {
n.Attempts++
// Models a receiver that supports persistent dedupe; many providers do not.
if !n.Seen[id] {
n.Seen[id] = true
n.Visible++
}
if loseACK {
return errors.New("ack lost: result unknown")
}
return nil
}
func demos() []string {
out := []string{}
links := map[string]string{"a": "old"}
code, err := allocate(links, "new", []string{"a", "b"})
out = append(out, fmt.Sprintf("shortlink code=%s old=%s err=%v", code, links["a"], err))
sale := &Sale{Stock: 1, Orders: map[string]bool{}}
a, b, c := sale.Reserve("u1"), sale.Reserve("u1"), sale.Reserve("u2")
out = append(out, fmt.Sprintf("sale first=%v replay=%v other=%v stock=%d", a, b, c, sale.Stock))
out = append(out, fmt.Sprintf("feed=%v", feed([]int{7, 4}, []int{9, 7}, 10, 3)))
chat := &Chat{Seen: map[string]Message{}}
m1, _ := chat.Send("client-1", "hi")
m2, _ := chat.Send("client-1", "hi")
out = append(out, fmt.Sprintf("chat seq=%d replay=%d missed=%d", m1.Seq, m2.Seq, len(chat.After(0))))
bucket := &Bucket{Tokens: 2, Capacity: 2, Rate: 1}
r1, _ := bucket.Allow(0)
r2, _ := bucket.Allow(0)
r3, _ := bucket.Allow(0)
r4, _ := bucket.Allow(1000)
out = append(out, fmt.Sprintf("limit=%v %v %v %v", r1, r2, r3, r4))
gen := &IDGen{Worker: 3}
id1, _ := gen.Next(100)
id2, _ := gen.Next(100)
_, err = gen.Next(99)
out = append(out, fmt.Sprintf("ids distinct=%v rollback=%v", id1 != id2, err))
job := &Job{Due: 10}
e1, _ := job.Claim(10, 5)
e2, _ := job.Claim(15, 5)
old, current := job.Ack(e1), job.Ack(e2)
out = append(out, fmt.Sprintf("job old_ack=%v current_ack=%v", old, current))
out = append(out, fmt.Sprintf("ranking=%v", ranking([]Score{{"z", 90}, {"b", 100}, {"a", 90}})))
counter := &Counter{Seen: map[string]bool{}}
counter.Add("e1", 1)
counter.Add("e1", 1)
merged := mergeG(map[string]int{"a": 2}, map[string]int{"a": 1, "b": 3})
out = append(out, fmt.Sprintf("counter dedup=%d merged=%d", counter.Value, sum(merged)))
out = append(out, fmt.Sprintf("suggest=%v", suggest([]Suggestion{{"go", 9}, {"golang", 7}, {"gopher", 5}, {"java", 10}}, "Go", 2)))
ledger := &Ledger{Payments: map[string]Payment{"p1": {State: "unknown", Amount: 100, Currency: "USD"}}, Posted: map[string]bool{}}
_ = ledger.Reconcile("p1", "fake-charge-1", "USD", 100)
_ = ledger.Reconcile("p1", "fake-charge-1", "USD", 100)
out = append(out, fmt.Sprintf("payment state=%s entries=%d balance=%d", ledger.Payments["p1"].State, len(ledger.Entries), balance(ledger.Entries)))
n := &Notification{Seen: map[string]bool{}}
_ = n.Deliver("event-1", true)
_ = n.Deliver("event-1", false)
out = append(out, fmt.Sprintf("notify attempts=%d visible=%d", n.Attempts, n.Visible))
return out
}
func main() {
for _, line := range demos() {
fmt.Println(line)
}
}models_test.go
代码块 · 149 行 · 点击收起 / 展开
go
package main
import (
"reflect"
"sync"
"testing"
)
func TestModels(t *testing.T) {
want := []string{
"shortlink code=b old=old err=<nil>",
"sale first=true replay=true other=false stock=0",
"feed=[9 7 4]",
"chat seq=1 replay=1 missed=1",
"limit=true true false true",
"ids distinct=true rollback=clock moved backward",
"job old_ack=false current_ack=true",
"ranking=[{b 100} {a 90} {z 90}]",
"counter dedup=1 merged=5",
"suggest=[go golang]",
"payment state=succeeded entries=2 balance=0",
"notify attempts=2 visible=1",
}
if got := demos(); !reflect.DeepEqual(got, want) {
t.Fatalf("got %q\nwant %q", got, want)
}
}
func TestConcurrentSale(t *testing.T) {
s := &Sale{Stock: 3, Orders: map[string]bool{}}
var wg sync.WaitGroup
for i := range 100 {
wg.Go(func() { s.Reserve(string(rune(i))) })
}
wg.Wait()
accepted := 0
for _, ok := range s.Orders {
if ok {
accepted++
}
}
if s.Stock != 0 || accepted != 3 {
t.Fatal(s.Stock, accepted)
}
}
func TestFailures(t *testing.T) {
t.Run("shortlink exhaustion preserves data", func(t *testing.T) {
m := map[string]string{"a": "old"}
if _, err := allocate(m, "new", []string{"a"}); err == nil || m["a"] != "old" {
t.Fatal(m, err)
}
})
t.Run("chat same key different body", func(t *testing.T) {
c := &Chat{Seen: map[string]Message{}}
_, _ = c.Send("k", "one")
if _, err := c.Send("k", "two"); err == nil {
t.Fatal("accepted changed payload")
}
})
t.Run("bucket clock rollback", func(t *testing.T) {
b := &Bucket{Tokens: 1, Capacity: 1, Rate: 1, LastMS: 10}
if _, err := b.Allow(9); err == nil {
t.Fatal("clock rollback")
}
})
t.Run("ID exhaustion", func(t *testing.T) {
g := &IDGen{}
for range 4096 {
if _, err := g.Next(10); err != nil {
t.Fatal(err)
}
}
if _, err := g.Next(10); err == nil {
t.Fatal("wrapped sequence")
}
if _, err := g.Next(11); err != nil {
t.Fatal(err)
}
})
t.Run("job not due", func(t *testing.T) {
j := &Job{Due: 10}
if _, ok := j.Claim(9, 5); ok {
t.Fatal("early")
}
if _, ok := j.Claim(10, 5); !ok {
t.Fatal("not claimed")
}
if _, ok := j.Claim(11, 5); ok {
t.Fatal("duplicate lease")
}
})
t.Run("payment mismatch", func(t *testing.T) {
l := &Ledger{Payments: map[string]Payment{"p": {Amount: 100, Currency: "USD"}}, Posted: map[string]bool{}}
if err := l.Reconcile("p", "fake", "EUR", 100); err == nil || len(l.Entries) > 0 {
t.Fatal("posted mismatch")
}
})
t.Run("payment wrong amount leaves state unchanged", func(t *testing.T) {
original := Payment{State: "unknown", Amount: 100, Currency: "USD"}
l := &Ledger{Payments: map[string]Payment{"p": original}, Posted: map[string]bool{}}
if err := l.Reconcile("p", "fake", "USD", 101); err == nil {
t.Fatal("accepted wrong amount")
}
if l.Payments["p"] != original || len(l.Entries) != 0 || len(l.Posted) != 0 {
t.Fatal("rejected observation mutated state")
}
})
t.Run("payment provider conflict after success", func(t *testing.T) {
l := &Ledger{Payments: map[string]Payment{"p": {State: "unknown", Amount: 100, Currency: "USD"}}, Posted: map[string]bool{}}
if err := l.Reconcile("p", "fake-1", "USD", 100); err != nil {
t.Fatal(err)
}
original := l.Payments["p"]
if err := l.Reconcile("p", "fake-2", "USD", 100); err == nil {
t.Fatal("accepted another provider ID")
}
if l.Payments["p"] != original || len(l.Entries) != 2 || balance(l.Entries) != 0 {
t.Fatal("provider conflict mutated state")
}
if err := l.Reconcile("p", "fake-1", "USD", 100); err != nil || len(l.Entries) != 2 {
t.Fatal("duplicate posted", err)
}
})
t.Run("G-counter merge repeat", func(t *testing.T) {
a, b := map[string]int{"a": 2}, map[string]int{"b": 3}
m := mergeG(a, b)
if sum(mergeG(m, b)) != 5 {
t.Fatal("non-idempotent merge")
}
})
}
// Controlled counterexample: no data race, yet a read-check-write schedule oversells.
func TestCheckThenWriteCounterexample(t *testing.T) {
stock := 1
aSaw, bSaw := stock, stock
accepted := 0
if aSaw > 0 {
stock = aSaw - 1
accepted++
}
if bSaw > 0 {
stock = bSaw - 1
accepted++
}
if stock != 0 || accepted != 2 {
t.Fatal(stock, accepted)
}
t.Logf("broken schedule: stock=%d accepted=%d", stock, accepted)
}