7 分钟阅读

设计一个生产级 Webhook 网关:HookRelay 架构复盘

最近开源了 HookRelay,一个用 Go 写的 Webhook 网关。这篇文章不是使用文档,而是复盘整个设计过程——为什么这样设计,有哪些取舍,踩了哪些坑。

问题的起源:Webhook 投递有多难?

Webhook 看起来简单:第三方服务 POST 一个请求到你的接口,你处理就行。但当你真正在生产环境接入 GitHub、Stripe、Slack 等多个服务时,问题就来了:

1. 可靠性问题:你的处理服务挂了怎么办?Webhook 发过来没人接,这条事件就丢了。大部分平台只重试有限次数,超时就永久丢失。

2. 签名验证各不相同

  • GitHub 用 X-Hub-Signature-256,HMAC-SHA256
  • Stripe 用 Stripe-Signature,带时间戳防重放
  • 你自己的内部服务可能用 Bearer Token

3. 扇出问题:一个 GitHub push 事件,你可能需要同时通知 CI 系统、消息推送、日志系统。如果直接在接收端处理,耦合严重。

4. 可观测性缺失:哪条 Webhook 处理失败了?重试了几次?失败原因是什么?没有可视化就是黑盒。

HookRelay 要解决的核心问题是:让 Webhook 的接收和处理解耦,同时保证至少一次投递语义

整体架构

外部服务
  │ HTTP POST
  ▼
┌─────────────────┐
│   Receiver      │  ← 验证签名、快速响应 202
│   (无状态)      │
└────────┬────────┘
         │ 写入 PostgreSQL
         ▼
┌─────────────────┐
│   Event Queue   │  ← PostgreSQL SKIP LOCKED 实现的队列
│   (持久化)      │
└────────┬────────┘
         │ 轮询/消费
         ▼
┌─────────────────┐
│   Dispatcher    │  ← 路由规则匹配、扇出、重试
│   (有状态)      │
└────────┬────────┘
         │ HTTP POST
         ▼
    目标服务们

这个架构的核心是:Receiver 必须无状态且极速,任何耗时操作都不应该在这里发生。接收到 Webhook 后,验证签名、写数据库、返回 202,整个过程目标 < 50ms。

签名验证:如何统一处理各种格式

这是最繁琐的部分。我抽象了一个 Verifier 接口:

type Verifier interface {
    Verify(r *http.Request, body []byte) error
}

针对不同平台实现不同的验证器:

// HMAC 验证器(适用于 GitHub、GitLab 等)
type HMACVerifier struct {
    secret    string
    header    string  // e.g. "X-Hub-Signature-256"
    algorithm string  // e.g. "sha256"
}
 
func (v *HMACVerifier) Verify(r *http.Request, body []byte) error {
    sig := r.Header.Get(v.header)
    if sig == "" {
        return ErrMissingSignature
    }
    // 移除前缀 "sha256="
    parts := strings.SplitN(sig, "=", 2)
    if len(parts) != 2 {
        return ErrInvalidSignatureFormat
    }
 
    mac := hmac.New(sha256.New, []byte(v.secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
 
    // 注意:必须用 hmac.Equal 防止时序攻击
    if !hmac.Equal([]byte(parts[1]), []byte(expected)) {
        return ErrSignatureMismatch
    }
    return nil
}

时序攻击值得单独讲一下:普通的 == 比较字符串时,一旦发现不匹配就立即返回,攻击者可以通过测量响应时间逐字节暴力破解签名。hmac.Equal 保证无论匹配还是不匹配,都执行相同数量的操作,消除时序差异。

Stripe 的签名格式更复杂,带有时间戳防重放:

// Stripe 签名:t=timestamp,v1=signature
func (v *StripeVerifier) Verify(r *http.Request, body []byte) error {
    header := r.Header.Get("Stripe-Signature")
    
    parts := parseStripeHeader(header)
    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil {
        return ErrInvalidTimestamp
    }
    
    // 防重放:5分钟容忍窗口
    if time.Now().Unix()-ts > 300 {
        return ErrReplayAttack
    }
    
    payload := fmt.Sprintf("%d.%s", ts, body)
    // 后续 HMAC 验证...
}

为什么用 PostgreSQL 而不是 Redis/Kafka 做队列

这是最多人质疑的设计决策。为什么不用 Redis 或 Kafka?

Redis 的问题:Redis 的持久化(RDB + AOF)不是真正的 durability。在极端情况下(如 OS crash 在 fsync 之前),数据可能丢失。对于 Webhook 事件,丢失是不可接受的。当然,你可以用 Redis Streams + AOF always,但此时 Redis 的复杂度已经不低于 PostgreSQL。

Kafka 的问题:Kafka 是优秀的,但运维成本高。对于一个 Webhook 网关,你不需要每秒百万级吞吐,你需要的是简单、可靠、可运维。Kafka 的 consumer group、offset 管理、partition 设计,会让一个本来简单的项目变成基础设施负担。

PostgreSQL SKIP LOCKED

-- 这是 HookRelay 的核心队列实现
BEGIN;
 
SELECT id, source, payload, target_url, retry_count
FROM webhook_events
WHERE status = 'pending'
  AND next_retry_at <= NOW()
ORDER BY created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;  -- 关键:跳过其他 worker 已锁定的行
 
-- 处理完成后
UPDATE webhook_events SET status = 'delivered' WHERE id = $1;
 
COMMIT;

SKIP LOCKED 是 PostgreSQL 9.5 引入的特性,专为队列场景设计。多个 Dispatcher 并发消费时,每个 worker 只会拿到没被其他 worker 锁定的行,天然避免了重复处理。

这个方案的上限:单 PostgreSQL 实例的队列,在我的测试中(4核 8G),可以稳定处理 ~2000 events/s,对绝大多数 Webhook 场景绰绰有余。如果真的需要更高吞吐,才值得引入 Kafka。

指数退避重试:细节决定成败

重试看起来简单,但有几个坑:

func (d *Dispatcher) calculateNextRetry(retryCount int) time.Time {
    // 指数退避:1s, 2s, 4s, 8s, 16s, 30s(上限)
    backoff := math.Pow(2, float64(retryCount))
    delay := time.Duration(backoff) * time.Second
    
    // 上限 30 分钟,防止无限增长
    if delay > 30*time.Minute {
        delay = 30 * time.Minute
    }
    
    // Jitter:加入随机抖动,防止"惊群效应"
    // 大量任务同时重试会造成目标服务雪崩
    jitter := time.Duration(rand.Intn(int(delay / 4)))
    delay += jitter
    
    return time.Now().Add(delay)
}

Jitter 为什么重要:想象 1000 个 Webhook 任务都在 T 时刻重试,目标服务会在那一刻收到 1000 个并发请求,可能直接打挂。加入随机抖动后,这 1000 个请求会分散在一个时间窗口内,给目标服务喘息的机会。

死信队列:超过最大重试次数(默认 10 次)后,事件移入 dead_letter_events 表,不再自动重试,但保留完整的失败历史,支持人工或程序触发重新投递。

Payload 转换:JMESPath + Go Template

不同系统对 Webhook payload 的格式要求不同。HookRelay 支持两种转换方式:

# 规则配置示例
transforms:
  - type: jmespath
    expression: "{event: event_type, repo: repository.full_name, ref: ref}"
  
  - type: go_template  
    template: |
      {
        "text": "{{ .repository.full_name }} pushed to {{ .ref }}"
      }

JMESPath 适合结构化提取,Go Template 适合需要格式重新组装的场景。两种可以串联,先提取再格式化。

性能数据

在 4 核 8G 的机器上,单实例 HookRelay 的实测数据:

| 指标 | 数值 | |------|------| | 接收吞吐 | ~8000 req/s | | 投递吞吐 | ~2000 events/s | | P99 接收延迟 | < 15ms | | 内存占用(空载) | ~25MB |

接收吞吐远高于投递吞吐,这是故意设计的——接收是无状态的,可以无限横向扩展;投递受限于 PostgreSQL 队列,是系统的瓶颈点,也是最需要关注的地方。

还没解决的问题

1. Webhook 去重:如果第三方平台因为网络问题重发了同一条 Webhook,HookRelay 会重复投递。需要在源头加幂等键(X-Webhook-ID 之类),并在 DB 层做唯一约束。

2. 顺序保证:同一个源的 Webhook 不保证顺序(因为并发消费)。对于需要顺序处理的场景(如账户余额变更),需要在应用层自己处理。

3. 多租户:目前没有租户隔离,所有规则都在一个全局命名空间。

这些都是后续版本要做的,但我不想为了"设计完美"而推迟开源,先把核心功能做稳。

GitHub: ThReeIOne/hookrelay