webhook handler 必须在3秒内返回200:先用io.readall(io.limitreader(r.body, 1mb))读原始字节,再hmac.equal验签,立即w.writeheader(http.statusok),业务逻辑异步处理,幂等性检查必须在异步任务中基于event_id或idempotency_key完成。

Webhook handler 必须在 3 秒内返回 200
第三方(Shopify、GitLab、Alertmanager 等)普遍要求 Webhook 响应 ≤ 3–5 秒,超时即重试。任何 DB 查询、HTTP 调用、JSON 解析都不能留在 http.HandleFunc 里——否则一次慢查询就触发上游重试风暴。
正确做法是:读完原始 body → 验签 → 立即 w.WriteHeader(http.StatusOK) → 启动异步任务处理业务逻辑。
- 用
io.ReadAll(io.LimitReader(r.Body, 1024*1024))限制最大读取 1MB,防内存炸裂 - 必须校验
Content-Type,非application/json或application/x-www-form-urlencoded直接http.Error(w, "bad content-type", http.StatusBadRequest) - 别用
json.NewDecoder(r.Body).Decode()后就返回 200 —— body 已被消耗,后续验签会读到空字节
验签必须用 hmac.Equal 且拼接原始 payload
GitHub 用 X-Hub-Signature-256,Slack 用 X-Slack-Signature,Stripe 用 Stripe-Signature。签名不是可选功能,是安全底线;不校验等于把支付/订单/事件入口裸奔在公网。
常见错误是:用 hmac.Sum256().String() 得到 base64,但上游给的是 hex 编码;或忽略换行符、自动 strip whitespace,导致重算签名不匹配。
- 从 header 取签名值:
r.Header.Get("X-Hub-Signature-256"),去掉前缀"sha256=" - 用
io.ReadAll(r.Body)一次性读出原始字节(只此一次!) - 用相同密钥和算法重算:
h := hmac.New(sha256.New, []byte(secret)),h.Write(payloadBytes) - 比对必须用
hmac.Equal(computed, expected),严禁用==(防时序攻击) - 若含时间戳(如 Slack 的
X-Slack-Request-Timestamp),需检查是否在 5 分钟窗口内
幂等性必须在异步任务里查 event_id 或 idempotency_key
Handler 层不做幂等判断。因为上游重试时,你返回了 200,但异步任务还没开始执行;如果此时在 handler 里查 DB 并“提前标记已处理”,反而会漏掉真实事件。
真正可靠的幂等点只有一个:异步 worker 拿到事件后,先用 event_id 或 idempotency_key 查库确认是否已处理。没查到才执行业务逻辑,查到了直接跳过。
- DB 表需建唯一索引:
UNIQUE INDEX idx_event_id ON webhook_events(event_id) - 插入时用
INSERT IGNORE或ON CONFLICT DO NOTHING(PostgreSQL)避免报错中断 - 不要依赖 handler 返回状态做去重——它不参与业务决策,只负责“收件成功”信号
- 支付类场景尤其注意:不能仅凭
return_code=SUCCESS就更新订单,必须验签 + 幂等键双重校验
异步任务需控制 goroutine 泄漏与并发水位
用 go func() { ... }() 最简,但极易失控:上游突发 1000 个请求,你就起 1000 个 goroutine,无缓冲 channel 会阻塞,有 buffer 又可能爆内存。
生产环境推荐带缓冲的 channel 或轻量 worker pool,而不是依赖 runtime.GOMAXPROCS 自动调度。
- 定义固定大小的 worker pool:
sem := make(chan struct{}, 10),每个任务前sem ,结束后 <code> - 或用带 buffer 的 channel 接收事件:
events := make(chan []byte, 100),配合固定数量的 for-range goroutine 消费 - 所有异步任务必须设 context timeout,比如
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - 别在 goroutine 里直接调
time.Sleep做延迟重试——要用time.AfterFunc或独立定时器,避免阻塞 worker
关键点在于:验签和幂等不是两个独立动作,而是一体两面。验签保真,幂等保重;前者防伪造,后者防重放。两者都必须落在异步边界之内,且不能共享 handler 的 request context 生命周期。











