通知服务调用外部渠道时,最难处理的结果是“没有结果”:请求发出去了,响应没回来。渠道可能根本没收到,也可能已经接受并开始外发,只是响应在路上丢了。本地数据库能把“同一事件只生成一条逻辑通知”做成硬约束,却无法替外部渠道保证“用户只收到一次”。本篇用一个最小通知服务把这两件事分开:逻辑去重由唯一约束负责,外部重复由渠道契约决定,结果未知要作为一种持久状态记录下来,并且有出口。

需求与范围

业务方提交事件,例如“订单 ord-1001 已发货”,通知服务按用户偏好为每个启用渠道生成投递任务,由工作进程领取后调用渠道。要满足的条件如下。

  • 同一 (user_id, event_key) 无论被提交多少次、多少并发,只产生一条逻辑通知。
  • 每条逻辑通知在每个启用渠道上最多一条任务;同一任务在同一时刻最多有一个有效令牌;被取代的持有者无法再改写任务状态,但它已发出的外部调用无法撤回。
  • 每次调用渠道都留下一条投递记录,结果未知的调用能在表里查到,不被后续结果覆盖。
  • 超时后的重试策略取决于渠道能力:支持幂等键就带同一键重发;不支持幂等键但可查询就先查询;两者都不具备就停止自动处理。
  • 领取前偏好被关闭则不发送;通知过了有效期则不发送。

排除项:投递回执、终端设备确认、渠道侧限流协商、模板渲染、退订链接。“SENT”在本篇只表示渠道接受了请求,与用户看到消息之间还隔着渠道自己的投递链路。

教学规模假设(不对应任何具体产品):每分钟数百条逻辑通知,每条平均 1–2 个启用渠道,单个 PostgreSQL 实例,若干个无状态工作进程。在这个量级下,任务表加行锁领取足够,不需要额外的消息队列;需要改变这个判断的触发条件是单表领取的锁竞争或扫描成本成为瓶颈,本篇不测这一点。

外部渠道能承诺什么

先看标准里的接受语义。RFC 8030(Web Push)第 5 节规定,推送服务以 201 Created 表示消息被接受,并明确写出这“does not indicate that the message was delivered to the user agent”。同一 RFC 第 6.2 节要求用户代理以 DELETE 确认,以保证消息“at least once”送达;推送服务没收到确认时 SHOULD 继续重试到过期,也可以提前停止。“至少一次”是确认机制要达到的目标,不是协议保证;重复则是协议允许的结果。

RFC 8030 给了两件与重复有关的工具,适用范围都有限。第 5.2 节的 TTL 头是必填项,TTL 到期后推送服务“MUST NOT attempt to deliver”;服务可以缩短 TTL 并在响应里返回实际值,而且不负责计入应用服务器自己的发送耗时。第 5.4 节的 Topic 只替换“outstanding”的消息,即仍存储在服务里、尚未投递的那一份;已经推到设备上的消息不会被撤回。所以在超时后带同一个 Topic 重发,只能在原消息还没投出时把它替换掉,原消息已投出时用户照样收到两份。RFC 8030 本身没有定义请求级幂等键,POST 每次都创建新的消息资源。

邮件协议对同一问题写得更直接。RFC 5321 第 4.2.5 节规定,服务器在 DATA 结束后返回 2yz 就承担了投递责任;返回 4yz 或 5yz 则服务器不得再投递,责任仍在客户端。第 4.5.3.2.6 节解释 DATA 结束后等待 250 的超时为什么要设到 10 分钟:此时的伪超时“would typically result in delivery of multiple copies of the message”,因为服务器已经接受了。第 6.1 节从服务器一侧再提一次:为了避免超时造成重复,服务器必须尽量缩短对结束符的响应时间,并引用 RFC 1047 讨论这个问题(RFC 1047 本篇只取书目信息)。这两条把“接受之后、确认之前”的窗口标成了重复的来源,同时说明拒绝发生在接受之前时,重试是安全的。

HTTP 的通用规则在 RFC 9110 第 9.2.2 节:客户端不应自动重试非幂等方法,除非有办法知道请求语义实际上幂等,或者有办法检测原请求没有生效。前一种办法对应幂等键,Idempotency-Key 请求头目前只是 IETF 草案(draft-ietf-httpapi-idempotency-key-header-07,摘要描述其用途为让 POST、PATCH 容错);后一种办法对应按业务引用查询。

实验用两种本地替身渠道表达这两条路,契约由本项目自定,只对替身成立。

表 33-1:假渠道契约

渠道 模式 接受时机 重复请求 查询
push IDEMPOTENT 先记录接受,再按脚本延迟或挂起响应 同一 Idempotency-Key 返回原消息号,replayed=true,不新增外发 不提供(405)
sms QUERYABLE 同上 忽略幂等键,每次接受都新增外发 GET /messages?ref= 返回已接受条数
两者 — 503、400 在记录接受之前返回 — 查询可脚本化返回 503

“503 在接受之前”是替身的承诺。真实渠道若不做这个承诺,503 也要按结果未知处理,代码里在这一分支留了注释。

接口与数据模型

服务接口只有两个入口:Notifier.create(user, eventKey, body, ttlSeconds, maxAttempts) 返回 (notificationId, created, tasks);工作进程循环调用 Dispatcher.runOnce,每次完成一次“领取—调用—收尾”。人工出口是 Dispatcher.resolveManually(taskId, finalState, note)。

四张表分别承担不同的事实。

  • channel_pref(user_id, channel):用户在每个渠道上的地址与开关,带版本号。
  • notification:逻辑通知,UNIQUE (user_id, event_key) 是逻辑去重的唯一依据,expires_at 是有效期。
  • delivery_task:每条逻辑通知在每个渠道上的任务,UNIQUE (notification_id, channel);state 受 CHECK 约束限定在六个值内;attempt_token 标识当前持有的那一次尝试;outcome_uncertain 记录“上一次尝试可能已被接受”;needs_review 表示已停止自动调度、等待人工。另有 CHECK 要求 SENDING 状态必须同时带租约到期时间和令牌。
  • delivery_attempt:每次尝试一行,attempt_token 唯一,outcome 记录本次观察,late_outcome 记录被拒绝的迟到观察。

任务状态只描述“本服务对这次投递知道多少”,投递记录保留每一次观察的原文。结果未知时,任务表说“不确定”,记录表说“为什么不确定”。

状态机

图 33-1:渠道任务状态机(括号内为前置条件或观察结果)

stateDiagram-v2
    [*] --> PENDING: 创建通知且偏好启用
    PENDING --> SENDING: 领取(next_attempt_at 已到,行锁成功)
    UNKNOWN --> SENDING: 领取(未停放;不可幂等渠道动作改为 QUERY)
    SENDING --> SENDING: 租约过期后被重新领取(换新令牌,outcome_uncertain=true)
    SENDING --> SENT: ACCEPTED / ACCEPTED_REPLAY / QUERY_FOUND(令牌匹配)
    SENDING --> PENDING: 接受前 503 或 QUERY_NOT_FOUND,且预算未尽、无未知前史
    SENDING --> UNKNOWN: TIMEOUT / IO_ERROR / QUERY_FAILED
    SENDING --> FAILED: 400,或预算用尽且确定未接受(无未知前史)
    SENDING --> UNKNOWN: 有未知前史时遇到 503 / 400(预算用尽或 400 → needs_review)
    PENDING --> SUPPRESSED: 领取时偏好已关闭(无未知前史)
    PENDING --> FAILED: 领取时已过期(无未知前史)
    SENDING --> UNKNOWN: 预算用尽且仍未知 → needs_review,停止调度
    UNKNOWN --> UNKNOWN: 领取时偏好关闭或已过期(有未知前史)→ needs_review,停止调度
    UNKNOWN --> SENT: 人工核对(仅 needs_review 时,一次)
    UNKNOWN --> FAILED: 人工核对(仅 needs_review 时,一次)
    SENT --> [*]
    FAILED --> [*]
    SUPPRESSED --> [*]

拒绝路径有三条:领取查询找不到符合条件的行时返回空;收尾 UPDATE 的令牌或状态不匹配时影响 0 行,观察只写入 late_outcome;人工处理的 UPDATE 要求 state = 'UNKNOWN' AND needs_review,第二次调用影响 0 行。

正常路径与领取

创建通知在一个事务里完成:

1
2
3
4
INSERT INTO notification(user_id, event_key, body, expires_at)
VALUES (?, ?, ?, now() + make_interval(secs => ?))
ON CONFLICT (user_id, event_key) DO NOTHING
RETURNING id

插入成功的事务紧接着按启用偏好写入任务;没有拿到 id 的事务用新语句读取已存在的行并直接返回,不写任务。并发时,后到的 INSERT 会在唯一索引上等待先到事务的结果(PostgreSQL 16 INSERT 参考页说明带唯一索引的插入可能被并发会话阻塞;13.2.1 节说明 Read Committed 下 DO NOTHING 可能因另一事务的结果而不插入,即使该结果对本语句快照不可见)。随后的 SELECT 是一条新语句,在 Read Committed 下能看到已提交的那一行。这条保证的成立条件是唯一约束存在且事务级别为 Read Committed,观察位置是 N1 中 notification 行数与 created=true 的调用数,失败边界是有人绕过 create 直接写任务表,或者把 event_key 设计成每次请求都不同。

领取查询是整个调度正确性的第一处检查:

1
2
3
4
5
6
7
8
9
10
11
SELECT t.id, t.state, t.channel, t.attempt_count, t.outcome_uncertain,
(t.state = 'SENDING') AS lease_expired,
coalesce(p.enabled, false), p.address, n.body, n.expires_at <= now()
FROM delivery_task t
JOIN notification n ON n.id = t.notification_id
LEFT JOIN channel_pref p ON p.user_id = n.user_id AND p.channel = t.channel
WHERE (t.state IN ('PENDING', 'UNKNOWN') AND t.next_attempt_at <= now())
OR (t.state = 'SENDING' AND t.lease_until < now())
ORDER BY t.id
LIMIT 1
FOR UPDATE OF t SKIP LOCKED

SKIP LOCKED 让并发领取者跳过已被锁住的行(PostgreSQL 16 SELECT 参考页的 Locking Clause 一节说明它提供不一致视图,适合多个消费者访问队列式表);WHERE 中的状态与时间条件保证领取到的行确实可执行。偏好开关和有效期在同一事务里读取,因此“领取前关闭偏好”在领取时生效。失败边界:偏好只在领取事务里读一次,channel_pref 行没有加锁,领取提交到发出请求之间关闭偏好,本次仍会发送。领取成功后,同一事务把任务改成 SENDING、写入新令牌与租约、插入一条投递记录,然后提交,提交之后才发 HTTP 请求。HTTP 调用不在数据库事务内,这样慢渠道不会长时间持有行锁;代价是调用期间的保护只剩租约和令牌。

方案比较:超时之后怎么办

表 33-2:结果未知后的候选处理

方案 外部重复 前提 本篇实验
直接重发 渠道已接受时必然重复 无 NC2:逻辑通知 1 条,替身外发 2 次
带同一幂等键重发 渠道按键返回原结果,不新增外发 渠道承诺按键去重,且键在保留期内 T1、P1
先查询再决定 查到即收敛为 SENT;查不到才重发 查询结果与接受记录一致,且查询时原请求已结束 T2、T3
带同一 Topic 重发(RFC 8030) 只替换尚未投递的那一份 推送服务支持 Topic,原消息仍 outstanding 未实现
停止自动处理,人工核对 不新增外发 有人能去渠道侧核对 T3、B2

前两行决定了一条边界:逻辑去重在本库内成立,与外部是否重复无关。NC2 的输出把两件事并排打印出来:

1
2
3
4
5
CASE NC2 :: 负向对照:不支持幂等键的渠道超时后直接重发
ok 投递记录 (expected=[TIMEOUT, ACCEPTED], actual=[TIMEOUT, ACCEPTED])
ok 逻辑通知仍只有 1 条 (expected=1, actual=1)
ok 任务状态 (expected=SENT, actual=SENT)
ok negative control exposes: 逻辑去重成立但替身外发次数=2

“查询后再决定”还有一个容易忽略的前提。替身在响应之前同步记录接受,所以超时后的查询一定能看到已接受的那一份。真实渠道的查询可能有延迟,原请求也可能仍在途中,此时“查不到”不能推出“没接受”。在没有这一承诺的渠道上,QUERY_NOT_FOUND 应该等原请求的最长处理时间过后再查,或者直接按结果未知停放。

关键实现

动作选择只看两个事实:上一次尝试是否可能已被接受,渠道是否支持幂等键。

1
2
Channel ch = channels.get(channel);
String action = (uncertain && !ch.idempotent() && policy == Policy.SAFE) ? "QUERY" : "SEND";

uncertain 来自任务表的 outcome_uncertain,或者来自本次领取发现租约过期(上一个持有者失联,它的调用可能已被接受)。幂等渠道在不确定时仍然 SEND,因为幂等键 "ntf-" + ref 与任务绑定,跨尝试不变。

第二处检查在收尾。观察结果映射成新状态后,只有令牌仍是当前令牌时才写入:

1
2
3
4
5
6
7
UPDATE delivery_task
SET state = ?, outcome_uncertain = ?, provider_msg_id = coalesce(?, provider_msg_id),
last_error = ?, lease_owner = NULL, lease_until = NULL,
next_attempt_at = CASE WHEN ? IN ('PENDING', 'UNKNOWN') AND NOT ?
THEN now() + make_interval(secs => ?::float8 / 1000) END,
needs_review = ?, updated_at = now()
WHERE id = ? AND state = 'SENDING' AND attempt_token = ?

影响 0 行说明这次尝试已被新的领取取代,代码只把观察写入该尝试的 late_outcome 并返回 STALE。超时、IO 错误、查询失败映射为 UNKNOWN 且 outcome_uncertain=true;预算用尽时 park 为真,next_attempt_at 置空、needs_review 置真,领取查询从此不会再选中它。

“未发送”的终态只能写给确定没有被接受过的任务。领取时发现偏好关闭或通知过期,如果此前有结果未知的尝试,就不能写 SUPPRESSED 或 FAILED,否则可能已外发的通知会被记成未发送;此时停放在 UNKNOWN:

1
2
3
4
String why = !enabled ? "PREF_DISABLED" : "TTL_EXPIRED";
// 之前的尝试可能已被渠道接受:此时不能写成“未发送”的终态,只停止自动调度并交人工核对。
String terminal = uncertain ? "UNKNOWN" : (!enabled ? "SUPPRESSED" : "FAILED");
if (uncertain) why = why + "_UNCERTAIN";

B2 用例覆盖这个分支:sms 通知 TTL 2 秒,第一次调用在替身接受后超时,重试间隔 2500 毫秒,到下次领取时通知已过期。

1
2
3
4
5
6
CASE B2 :: 结果未知之后 TTL 到期:不写成 FAILED,停在 UNKNOWN 交人工核对
ok 投递记录 (expected=[TIMEOUT, TTL_EXPIRED_UNCERTAIN], actual=[TIMEOUT, TTL_EXPIRED_UNCERTAIN])
ok 任务状态 (expected=UNKNOWN, actual=UNKNOWN)
ok needs_review (expected=t, actual=t)
ok 过期后不再查询或发送 (expected=0, actual=0)
ok 替身外发次数(第 1 次尝试已被接受) (expected=1, actual=1)

替身确实外发过 1 次,任务表没有把它说成 FAILED。

同样的规则也适用于收尾。接受前的 503 或 400 只说明“这一次”没有被接受,证明不了之前那次超时的请求没被接受。finish 读取领取时写入的不确定标记,有未知前史时,503/400 保留标记,预算用尽或遇到 400 就停放:

1
2
3
4
5
6
7
8
9
case "HTTP_5XX", "REJECTED" -> {
if (priorUncertain) {
state = "UNKNOWN"; uncertain = true; park = !budgetLeft || ob.outcome().equals("REJECTED");
} else if (ob.outcome().equals("HTTP_5XX")) {
state = budgetLeft ? "PENDING" : "FAILED"; uncertain = false;
} else {
state = "FAILED"; uncertain = false;
}
}

这一分支是独立审稿时发现的:先前的代码在 503 时清除标记,push 第 1 次超时(替身已接受)、第 2 次 503 且预算用尽,任务会被记为 FAILED。B3 构造了这个序列。修正后的输出:

1
2
3
CASE B3 :: 结果未知之后的 503:拒绝只说明这一次未被接受,预算用尽时停在 UNKNOWN,不写成 FAILED
ok 任务状态(第 1 次可能已被接受) (expected=UNKNOWN, actual=UNKNOWN)
ok 替身外发次数(第 1 次尝试已被接受) (expected=1, actual=1)

用修正前的 Dispatcher 运行同一用例,得到 FAIL 任务状态(第 1 次可能已被接受) (expected=UNKNOWN, actual=FAILED),原始输出保存在 evidence/33/pre-fix-output.txt。QUERY_NOT_FOUND 仍会清除标记,这只在“查询立即可见”的替身契约下成立,前文已说明。

故障反例

超时:接受之后响应丢失

图 33-2:T1/T2 中接受后超时的事件序列(客户端超时 400 ms,替身延迟 1200 ms)

sequenceDiagram
    participant W as 工作者
    participant DB as PostgreSQL
    participant P as push(幂等键)
    participant S as sms(可查询)
    W->>DB: 领取,SENDING,令牌 k1
    W->>P: POST /send,Idempotency-Key=ntf-task-N
    P-->>P: 记录接受 push-msg-x,延迟响应
    Note over W: 400 ms 超时
    W->>DB: 收尾(k1):UNKNOWN,outcome_uncertain=true
    W->>DB: 领取,令牌 k2,动作 SEND
    W->>P: POST /send,同一键
    P-->>W: 200 replayed=true,id=push-msg-x
    W->>DB: 收尾(k2):SENT
    Note over W,S: sms 渠道同样超时后,第二次领取动作为 QUERY
    W->>S: GET /messages?ref=task-M
    S-->>W: 200 found=1
    W->>DB: 收尾:SENT,provider_msg_id 写回

T1 的投递记录是 [TIMEOUT, ACCEPTED_REPLAY],替身收到 2 次 /send、外发 1 次;T2 是 [TIMEOUT, QUERY_FOUND],外发 1 次。这两条保证的成立条件分别是渠道按键去重、查询与接受一致;观察位置是 delivery_attempt.outcome 序列与替身的 acceptedFor(ref) 计数;替身契约换成真实渠道后,失败边界由真实渠道的文档决定,本实验不覆盖。

查询也不可用

T3 让 sms 超时后两次查询都返回 503。三次尝试用完预算后:

1
2
3
4
5
6
ok   投递记录 (expected=[TIMEOUT, QUERY_FAILED, QUERY_FAILED], actual=[TIMEOUT, QUERY_FAILED, QUERY_FAILED])
ok 任务状态 (expected=UNKNOWN, actual=UNKNOWN)
ok needs_review (expected=t, actual=t)
ok 不再自动调度 (expected=0, actual=0)
ok 工作进程不再领取该任务 (expected=NOTHING, actual=NOTHING)
ok 替身外发次数(没有盲目重发) (expected=1, actual=1)

这里没有用“重试到成功为止”掩盖不确定性。停在 UNKNOWN 的任务有明确的查询条件(state = 'UNKNOWN' AND needs_review),可以接入告警或工单。

崩溃:持有者在调用中途被杀

P1 用独立 JVM 运行工作者 w-p1-a,替身对它的请求先记录接受再挂起 60 秒。父进程确认替身已接受后发送 SIGKILL:

1
2
3
4
ok   kill 之前替身已接受外发 (expected=1, actual=1)
ok w-p1-a 以非零码退出(SIGKILL)
ok kill 后任务停在 SENDING (expected=SENDING, actual=SENDING)
ok 租约未过期时探测领取得到空结果

子进程 w-p1-b 随后打印:

1
2
3
proc W w-p1-b CLAIMED task=36 attempt=2 action=SEND
proc W w-p1-b CALLED outcome=ACCEPTED_REPLAY msg=push-msg-0026
proc W w-p1-b RESULT SENT

进程死亡不会把任务改回 PENDING,任务停在 SENDING 直到租约(3000 ms)过期。新进程 w-p1-b 领取时把第一次尝试的记录补成 LEASE_EXPIRED,并把本次视为结果未知;push 渠道支持幂等键,于是带同一键重发,得到原消息号。若这是 sms 任务,同一位置的动作会是 QUERY。租约时长需要大于客户端超时加收尾耗时,否则持有者还在正常等待时任务就会被别人领走,下面的 P2 故意制造这种重叠。P1 中 w-p1-a 的租约 3000 ms 小于它的客户端超时 30000 ms,同样存在重叠,只因持有者已被杀才没有表现出来。

暂停:旧持有者迟到

P2 设租约 1000 ms、客户端超时 1500 ms,让 w-p2-a 在替身接受后被 SIGSTOP。租约过期后,主进程中的 w-p2-b 领取并完成,令牌已更换;随后 SIGCONT,w-p2-a 醒来时它的 HTTP 调用已超时,拿着旧令牌收尾:

图 33-3:P2 中迟到收尾被令牌拒绝的时间线

1
2
3
w-p2-a  领取(令牌 k1, 租约 1 s) ── POST 被接受 ── [SIGSTOP] ·························· [SIGCONT] TIMEOUT ── 收尾(k1) → 0 行 → STALE
w-p2-b 租约过期 ── 领取(k2, uncertain) ── 同键重发 → ACCEPTED_REPLAY ── 收尾(k2) → SENT
delivery_task.state SENDING(k1) ───────────────────── SENDING(k2) ──────────────── SENT ───────────────────────── SENT
1
2
proc W w-p2-a CALLED outcome=TIMEOUT msg=null
proc W w-p2-a RESULT STALE

主进程随后检查:

1
2
3
ok   w-p2-a 的收尾被拒绝 (expected=W w-p2-a RESULT STALE, actual=W w-p2-a RESULT STALE)
note w-p2-a 迟到观察 late_outcome=TIMEOUT
ok 任务状态未被迟到结果改写 (expected=SENT, actual=SENT)

NC3 把同一个迟到观察按“只看 id”的写法重放一次,影响 1 行,SENT 被改回 UNKNOWN。保护来自 attempt_token = ? 这一个条件,事务本身并不阻止这种覆盖。

并发领取

C1 让 8 个线程同时领取 1 条任务,只有 1 个成功;4 个线程排空 20 条任务,合计 20 次尝试、替身新增 20 次外发、没有任务的 attempt_count 不等于 1。NC1 换成无锁的 SELECT 后按 id UPDATE,两个线程读到同一行,UPDATE 合计影响 2 行,同一任务外发 2 次。

恢复流程

结果未知后的恢复按以下顺序执行,每一步都对应表里的一个可查询条件。

  1. 进程崩溃或暂停:不需要额外的恢复脚本。租约过期后,领取查询中的 state = 'SENDING' AND lease_until < now() 会选中该任务,领取事务把未结束的尝试记为 LEASE_EXPIRED,并把本次尝试标为结果未知。
  2. 结果未知且预算未尽:按渠道能力选择 SEND(同一幂等键)或 QUERY,每次都是新令牌、新记录。
  3. 预算用尽,或在未知状态下遇到偏好关闭、通知过期:停放在 UNKNOWN,needs_review = true,next_attempt_at 为空。
  4. 人工核对:操作员到渠道侧确认后调用 resolveManually,写入 SENT 或 FAILED 并追加一条 MANUAL_* 记录。T3 中第二次人工处理返回 0,说明重复调用不会把同一任务改两次。并发时依赖 Read Committed 下 UPDATE 等锁后重新求值 WHERE(13.2.1 节),本实验只测了串行重复调用。

恢复不会撤回已经送达的消息。如果渠道两次都接受了,本服务能做的是在记录里留下两次接受的证据,供下游去重或补偿。

证据与限制

运行入口是 examples/notification/run.sh,它启动端口 55433 上的 PostgreSQL 16.15 集群,重建库,编译并运行全部用例,最后停集群。本次运行退出码为 0,输出末行为:

1
SUMMARY passed=76 failed=0

表 33-3:用例与类别

用例 类别 结果
N1 并发创建、N2 正常投递 真实组件 + 替身 PASS
C1 并发领取;NC1 无锁领取(负向) 真实组件 PASS;NC1 暴露重复外发
T1、T2、T3 超时与查询;NC2 直接重发(负向) 真实组件 + 替身 PASS;NC2 暴露外发 2 次
F1 503 与 400;B1 偏好关闭与过期;B2 未知后过期;B3 未知后 503 真实组件 + 替身 PASS(B3 在修正前的代码上 FAIL)
P1 SIGKILL;P2 SIGSTOP/SIGCONT 多进程集成 PASS
NC3 只按 id 收尾(负向) 真实组件 暴露 SENT 被改回 UNKNOWN

这些结果只证明本地替身契约下的行为。替身没有实现 RFC 8030 的 TTL 头、Topic、投递回执,也没有到设备的链路;它的“503 在接受之前”和“查询立即可见”是人为设定的承诺,真实渠道需要逐项核对文档。幂等键存放在替身进程内存中,不涉及渠道侧的键保留期。所有进程共用同一数据库时钟判断租约,不覆盖时钟漂移、网络分区和数据库切换。C1 中各线程的尝试数分布(数值见 output.txt 的 note 行)每次运行都会变化,不作为公平性结论。完整限制见 evidence/33/LIMITS.md。

本批导航

  • 上一篇:32 点赞事实与计数校准
  • 下一篇:34 可撤销短链与访问统计
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/notification/run.sh
  • 证据目录:batch-3/evidence/33/
  • 本篇工程与证据快照:backend-systems-33.tar.gz(SHA-256 见同目录 backend-systems-33.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。