Webhook 是一种 HTTP 回调集成方式:事件来源向接收者配置的地址发送通知,接收者依据内容触发处理。它减少主动查询的等待,但不是可靠消息协议的统一标准;重试、签名、顺序、历史查询与成功状态码都由具体平台契约决定。
以订单状态通知为例,来源创建事件 E42,通过 HTTP 发给接收端;接收端先把事件可靠保存,再由 worker 更新本地视图。即使来源随后重发 E42,或者 E43 比 E42 先到,处理也必须保持正确。
问题 对应设计
谁发来的,正文有无改动 原始字节验签与密钥管理
何时可以返回成功 可靠接收边界
一件事通知两次怎么办 稳定事件 ID 与业务幂等
顺序不一致怎么办 资源版本、状态条件或查询当前状态
平台重试结束后漏了怎么办 投递历史、补偿与对账
回调地址或跳转链接能否信任 地址来源、权限与出口约束
接收端:把 HTTP 应答与业务完成分开
先可靠接收,再异步处理;每个确认都对应明确的责任
事件来源
按平台契约投递
接收端
验签 / 校验 / Inbox
Worker
执行业务并记录结果
Inbox 持久化成功 → 返回 2xx
此时承诺的是已接收,不是业务全部完成
业务事务成功 → 标记完成
失败保留待重试或人工处理状态
返回 2xx 后再只写内存队列,会留下进程崩溃导致事件丢失的窗口。
接收路径可以限制请求大小和类型,验证签名与必要时效,解析并校验事件,再将原始内容和处理状态写入持久 Inbox。成功提交后快速返回平台认可的 2xx,worker 异步消费。这样既避免在 HTTP 超时内执行所有复杂工作,也保留崩溃后的恢复入口。
若先返回成功,再尝试向内存队列放事件,进程在中间退出就可能永久漏掉事件。若必须依赖数据库或队列持久化才能恢复,提交失败时不能假装接收成功。另一方面,已经可靠入队的重复事件可以返回成功,避免来源继续无意义重试。
GitHub 文档将超过 10 秒无响应视为一次投递失败,并明确不会自动重投失败的 webhook ;可通过人工或自建程序重投。Stripe 有自己的自动重试与补发规则。不能写成“所有非 2xx 都按 5、25、125 秒退避,失败多次自动禁用”。GitHub 失败投递 、Stripe Webhooks
验签:先处理原始字节,再理解 JSON
HMAC 以共享 secret 为 key,以平台规定的待签字节为 message。GitHub 的 X-Hub-Signature-256 使用 sha256= 加十六进制摘要;另一些平台会把时间戳、版本或其他字段一起签入。因此不能把所有平台统一实现为 HMAC(body)。GitHub 验签
同一个 JSON 对象可以有不同空格、键顺序和转义表示。若框架先解析再序列化,即使对象内容相同,字节也可能不同;验签时应保留协议要求的原始 request body。下面以 GitHub 公布的测试向量验证 HMAC 算法,再检查正文被改动时拒绝。
import hashlib , hmac , json
secret = b " It's a Secret to Everybody"
body = b ' Hello, World!'
expected = ' 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'
assert hmac . new ( secret , body , hashlib . sha256 ) . hexdigest ( ) == expected
def verify ( raw , signature ) :
if not signature . startswith ( ' sha256=' ) :
return False
value = signature [ 7 : ]
if len ( value ) != 64 or any ( ch not in ' 0123456789abcdef' for ch in value ) :
return False
calculated = hmac . new ( secret , raw , hashlib . sha256 ) . hexdigest ( )
return hmac . compare_digest ( calculated , value )
assert verify ( body , ' sha256=' + expected )
assert not verify ( body + b ' ' , ' sha256=' + expected )
a , b = b ' {"a":1,"b":2}' , b ' { "b": 2, "a": 1 }'
assert json . loads ( a ) == json . loads ( b )
assert hmac . digest ( secret , a , ' sha256' ) != hmac . digest ( secret , b , ' sha256' )
print ( ' 公开 HMAC 向量、篡改拒绝和 JSON 字节差异通过' )
这段代码只演示该签名格式,不是所有 provider 的通用 SDK。真实接收器还要处理重复签名头、轮换期间的多个有效密钥,以及平台具体的签名版本;比较时避免使用会泄漏前缀匹配时间的简单循环。
签名有效为什么还需要防重放
攻击者若得到一份有效旧请求,HMAC 校验仍可能成功。平台若把投递时间签入,应根据可信时钟检查时间窗口;若协议没有签名时间戳,不能自创一个未被签名的头作为证明。消息事件发生时间与本次投递时间也不同,合法补发可能携带很早发生的事件。
重试窗口、人工重放窗口与去重记录保留期要配合。只保留五分钟的去重键,却允许管理员一个月后补发同一事件,就不能指望仍然阻止重复业务效果。
字节真实性、事件去重与业务版本处理,三项检查各司其职
验签与时效
确认原始字节来自可信密钥,拒绝不合约请求
Inbox 去重
同一个来源 / 租户 / 事件 ID 不重复入队
业务状态条件
旧版本不能覆盖新版本,外部副作用需要幂等契约
HMAC 本身不防重放;投递 ID 也不一定等于稳定的业务事件 ID。
三种 ID:业务事件、投递尝试与资源
事件 ID 表示一次事件事实,投递尝试 ID 用来追踪 HTTP 发送,资源 ID 表示被影响的订单或告警。同一事件可能有多次尝试;同一资源又会产生多个不同事件。具体字段是否重投时保持不变要看平台,不能仅凭名称含 delivery 就作判断。
GitHub 的 X-GitHub-Delivery 可用于识别同一次投递及其重投;Stripe 建议记录已处理 event ID,并指出某些场景可能出现不同 Event 对象对应相关业务变化。幂等键应依据平台事件语义和业务动作确定,而不是把所有来源拼成一个无作用域的 UUID 集合。GitHub 最佳实践 、Stripe 重复事件
GitHub 的这类正文 HMAC 不覆盖任意 HTTP 头,因此 Delivery 头有助处理正常重复投递,但不能独自阻止持有有效旧正文的攻击者改头重放。敏感动作还要依据受签正文中的业务身份与操作条件去重;不要给未签名头赋予它没有的密码学保证。
去重与业务提交:必须覆盖崩溃窗口
在本地数据库内更新视图时,可以把 Inbox 状态变更与业务修改放在同一个事务中。以下局部模型用唯一键去重,并在注入崩溃时确认“事件已处理”不会先于业务效果独立提交。示例是内存 SQLite 的事务语义验证,不模拟磁盘故障或实际并发部署。
import sqlite3
con = sqlite3 . connect ( ' :memory:' )
con . execute ( ' CREATE TABLE inbox (source TEXT , event TEXT , PRIMARY KEY (source,event))' )
con . execute ( ' CREATE TABLE counter (value INTEGER NOT NULL )' )
con . execute ( ' INSERT INTO counter VALUES (0 )' )
con . commit ( )
def apply ( source , event , crash = False ) :
with con :
cursor = con . execute ( ' INSERT OR IGNORE INTO inbox VALUES (?,?)' , ( source , event ) )
if cursor . rowcount == 0 :
return ' duplicate'
con . execute ( ' UPDATE counter SET value= value+ 1 ' )
if crash :
raise RuntimeError ( ' 提交前崩溃模型' )
return ' applied'
try :
apply ( ' provider:tenant-a' , ' E42' , crash = True )
except RuntimeError :
pass
assert con . execute ( ' SELECT count (* ) FROM inbox' ) . fetchone ( ) [ 0 ] == 0
assert con . execute ( ' SELECT value FROM counter' ) . fetchone ( ) [ 0 ] == 0
assert apply ( ' provider:tenant-a' , ' E42' ) == ' applied'
assert apply ( ' provider:tenant-a' , ' E42' ) == ' duplicate'
assert con . execute ( ' SELECT value FROM counter' ) . fetchone ( ) [ 0 ] == 1
print ( ' 去重与业务效果共同回滚、重试一次生效通过' )
生产中的异步 Inbox 通常有 pending/processing/done 等状态及重试次数,不能仅因查到一行就跳过未完成任务。并发 worker 还需要领取任务、租约或行锁等机制,避免同时执行同一条消息。
调用外部扣款、发短信或控制设备时,对方副作用不在本地数据库事务内。需要使用对方支持的幂等键、操作查询与补偿;Outbox 可以避免本地状态更新后漏发命令,却不会让任意外部 HTTP 操作自动 exactly-once。
崩溃发生在两次写入之间,会重做还是漏做?
三种策略都遇到同一个崩溃点,但恢复后分别可能得到两次效果、零次效果和一次效果。重点不是把去重表放在“前面”还是“后面”,而是两项持久状态有没有共同的提交边界。
正在呈现知识画面 重新加载
崩溃发生在两次写入之间,会重做还是漏做? 两项状态必须共同提交或共同回滚。业务先写可能重复,标记先写可能吞掉未执行操作;事务只覆盖同一本地数据库。
{"id":"inbox-atomic-crash","title":"崩溃发生在两次写入之间,会重做还是漏做?","summary":"两项状态必须共同提交或共同回滚。业务先写可能重复,标记先写可能吞掉未执行操作;事务只覆盖同一本地数据库。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"effect\" data-i18n=\"effect\"></button><button data-mode=\"marker\" data-i18n=\"marker\"></button><button data-mode=\"atomic\" data-i18n=\"atomic\"></button></div><div class=\"actions\"><button id=\"crash\" data-i18n=\"crash\"></button><button id=\"retry\" data-i18n=\"retry\"></button><button id=\"reset\" data-i18n=\"reset\"></button></div><div class=\"track-row\"><span data-i18n=\"done\"></span><div id=\"states\" class=\"track\"></div></div><div class=\"metrics\"><div><span data-i18n=\"count\"></span><strong id=\"count\"></strong></div><div><span data-i18n=\"done\"></span><strong id=\"done\"></strong></div></div><p id=\"last\" class=\"message\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}let mode='atomic',count=0,done=false,crashed=false,last='∅';function draw(){set('count',count);set('done',done?'E42':'∅');cells('states',[{text:'effect '+count,cls:count===1?'on':count>1?'gold':'empty'},{text:'dedup '+Number(done),cls:done?'on':'empty'}]);set('last',last);$('#crash').disabled=crashed;$('#retry').disabled=!crashed;$('#states').dataset.snapshot=JSON.stringify({count,done});pressed(mode)}function reset(){count=0;done=false;crashed=false;last='∅';draw()}$('#crash').onclick=()=>{crashed=true;if(mode==='effect')count++;if(mode==='marker')done=true;last=mode==='atomic'?'ROLLBACK':'CRASH after first committed write';draw()};$('#retry').onclick=()=>{if(done)last='SKIP: E42 recorded';else{count++;done=true;last='COMMIT: effect + marker'}draw()};$('#reset').onclick=reset;document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{mode=b.dataset.mode;reset()});draw();","audio":false,"strings":{"atomic":"共同事务","count":"持久业务效果次数","crash":"执行到崩溃点","done":"持久去重标记","effect":"先业务后标记","heading":"崩溃发生在两次写入之间,会重做还是漏做?","intro":"选择业务先写、标记先写或事务,再执行崩溃与重试。","marker":"先标记后业务","mode":"提交策略","reset":"重置","retry":"恢复并重试E42","scope":"固定事件E42,每次业务效果+1。首次强制在第一步后崩溃;事务方案回滚未提交修改。重试完整执行,不模拟外部API或持久存储设备故障。","scopeLabel":"模型范围与约定"}}
乱序:先决定事件代表快照还是变更
如果 E42 表示订单版本 8、E43 表示版本 9,E43 先到时,本地已记录版本 9,再收到版本 8 不应直接覆盖成旧状态。可采用带条件版本更新;若来源只提供“资源有变更”通知,则依据可信资源 ID 查询当前状态再更新。
事件时间戳不总是严格递增版本号,不同资源的顺序也未必有关。删除、取消、恢复和重建的语义应由业务状态机明确,不能把所有事件简单按“最后收到者胜出”处理。Stripe 明确不保证事件按生成顺序送达。Stripe 事件顺序
Webhook 之外仍应保留修复路径:定期对照来源状态或历史事件,找出缺口并重放。轮询不是必然低效,低频对账可以弥补 webhook 的重试耗尽、配置错误和临时停机。
发送端:事件创建、退避与地址边界
自己设计发送服务时,应让业务变更与待发送事件共同持久化,再由发送器发 HTTP。超时只表示没有看到确认,目标可能已完成;每次尝试记录事件 ID、attempt、响应与下次时间,使用有上限并带抖动的退避,避免故障恢复后同时重试。
可配置 callback URL 是服务器的出站访问能力,需要按照产品允许的目标范围验证 scheme、解析结果和重定向后的实际地址,并通过出口约束执行。只在注册时检查一次字符串,不足以处理 DNS 地址变化;回调响应也不应被当作任意新 URL 继续访问。
Grafana 到消息机器人:保留告警语义,再转换卡片
告警通知可能是一组 alerts,包含 firing 与 resolved、标签、注释和来源链接。将它映射成机器人卡片时,需要决定聚合方式、恢复通知、按钮目标与正文长度,而不是只取数组第一项就当作整个告警组。Grafana 通知模板
如果目标是把按钮指向 Loki 查询,应将可控的日志查询链接放在明确字段中,再由模板或适配服务编码为目标 JSON。当前 Grafana 文档支持 webhook Custom Payload 模板,具体可用性取决于版本与部署;模板序列化函数比手工拼接引号更可靠。机器人还可能要求独立的 token、时间戳签名或业务响应码,不能认为 Grafana 的 HMAC 配置自动兼容另一平台。自定义 JSON Payload
原有 DingDing 按钮行为属于具体集成实现,升级后应按实际版本检查,不能把“singleURL 永远硬编码”写成协议性质。若内置接入点无法满足所需 URL 或签名,可以由适配服务完成字段映射,并将机器人 API 的非成功业务结果纳入重试状态;HTTP 200 也可能装着应用级错误。
排查一条通知时,沿事件 ID 追踪来源创建、HTTP 投递、Inbox 提交、worker 状态和最终外部结果。只看接收端返回 200,无法判断用户最终看到的卡片是否正确。