构建 Webhook 发送系统:2026 年可靠的回传

2026 年实用指南,介绍如何使用队列、抖动重试、熔断器、幂等性、签名和每个端点的延迟遥测来实现持久的 webhook 和联盟回传交付。
Webhook 发送系统 - 构建 Webhook 发送系统:2026 年可靠的回传

上次更新时间:24年2026月XNUMX日,发布者: 凯撒·菲克森

直接回答: 可靠的 webhook 或联盟回传交付服务需要持久队列、有限制的重试次数(带抖动)、每个端点的熔断机制、重复保护、签名验证以及遥测功能(用于在交付失败前显示交付速度是否变慢)。

此实现有意保持执行模型的精简:仅使用 Python 工作进程和 PostgreSQL 来存储队列、传递历史记录和端点健康状况。对于 B2B SaaS、iGaming 联盟营销运营以及任何需要向外部发送事件且不会将任何一次 HTTP 超时视为转化丢失的产品而言,这是一个实用的起点。

交付合同必须保证什么

通过积极争取让商标与其相匹配的域名优先注册来维护 为何重要 运行检查
持久事件记录 事件在工作进程重启后仍然存在。 每个已接收的包裹都有一个稳定的ID和状态。
已签署的请求 接收方可以验证发送方身份并检测机身是否被篡改。 使用带时间戳的签名,并拒绝过期的请求。
重复保护 否则,重试可能会导致重复转换或更新。 发送事件 ID 并使接收者幂等。
有限制的重试次数 瞬态故障可恢复,不会对性能下降的终端造成过大影响。 减少抖动;在达到记录的尝试次数限制后停止。
端点遥测 单凭队列深度无法发现运行缓慢或出现故障的合作伙伴。 跟踪每个端点的成功率、最早待处理事件和延迟百分位数。

安全性和重复控制优先于重试调整。

将出站请求体视为敏感的操作数据。对请求体进行签名,包含发送时间戳和不可更改的事件 ID,轮换签名密钥,并确保接收应用程序能够安全地忽略同一事件的重复发送。成功的 HTTP 响应并不能证明业务事件只被执行了一次;接收方必须自行判断。

对于联盟营销或 iGaming 回传工作流程,除非明确设置了访问控制和保留规则,否则请勿将点击 ID、转化 ID 和与支付相关的字段记录在日志中。下文提供的 Scaleo 示例仅作为联盟营销回传延迟的示例,不能替代您自身服务之间的交付协议文档。

有用的实现参考资料: Stripe webhook 签名验证, PostgreSQL SELECT 和 SKIP LOCKEDAWS 关于指数退避和抖动的指导.

数据模型

一切都从两个表格开始:一个用于交付队列,一个用于延迟测量。

SQL

-- Pending and in-flight webhook deliveries
CREATE TABLE webhook_queue (
    id              SERIAL PRIMARY KEY,
    endpoint_id     INTEGER NOT NULL,
    endpoint_url    TEXT NOT NULL,
    payload         JSONB NOT NULL,
    
    -- Delivery state
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
        -- pending, in_flight, delivered, failed, dead_letter
    attempt_count   INTEGER NOT NULL DEFAULT 0,
    max_attempts    INTEGER NOT NULL DEFAULT 5,
    
    -- Timing
    created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
    next_attempt_at TIMESTAMP NOT NULL DEFAULT NOW(),
    delivered_at    TIMESTAMP,
    last_error      TEXT,
    
    -- Response tracking
    last_status_code INTEGER,
    last_response_ms INTEGER,  -- response time in milliseconds
    
    INDEX idx_queue_next (status, next_attempt_at)
        WHERE status IN ('pending', 'failed')
);

-- Per-endpoint latency measurements (ring buffer)
CREATE TABLE endpoint_latency (
    id              SERIAL PRIMARY KEY,
    endpoint_id     INTEGER NOT NULL,
    response_ms     INTEGER NOT NULL,
    status_code     INTEGER,
    measured_at     TIMESTAMP NOT NULL DEFAULT NOW(),
    
    INDEX idx_latency_endpoint (endpoint_id, measured_at)
);

-- Endpoint health state (circuit breaker)
CREATE TABLE endpoint_health (
    endpoint_id         INTEGER PRIMARY KEY,
    endpoint_url        TEXT NOT NULL,
    state               VARCHAR(20) NOT NULL DEFAULT 'closed',
        -- closed (healthy), open (broken), half_open (testing)
    consecutive_failures INTEGER NOT NULL DEFAULT 0,
    failure_threshold   INTEGER NOT NULL DEFAULT 10,
    last_failure_at     TIMESTAMP,
    last_success_at     TIMESTAMP,
    opened_at           TIMESTAMP,  -- when circuit opened
    cooldown_seconds    INTEGER NOT NULL DEFAULT 300,  -- 5 min before half_open
    
    -- Latency stats (updated periodically)
    p50_ms              INTEGER,
    p95_ms              INTEGER,
    p99_ms              INTEGER,
    sample_count        INTEGER NOT NULL DEFAULT 0
);

关于这个模式,有三点需要注意。

首先, webhook_queue 表格使用 next_attempt_at 使用列而不是单独的调度机制。工作进程轮询满足特定条件的行。 status IN ('pending', 'failed') AND next_attempt_at <= NOW()这是一个简易的延迟队列,每分钟处理约 10,000 条消息时运行良好。超过这个数量,就需要换用专业的消息代理。

第二,该 endpoint_latency 表充当环形缓冲区。我会定期清除超过 24 小时的行。延迟百分位数 endpoint_health 这些数据是根据这个滚动窗口计算出来的——它们代表的是近期的行为,而不是历史平均值。

第三,该 endpoint_health 该表实现了断路器状态机。更多详情请见下文。

送货员

核心工作循环的设计刻意保持简洁。复杂性体现在重试逻辑和熔断机制上,而不是交付路径本身。

蟒蛇

import requests
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from datetime import datetime, timedelta

DB_DSN = "postgresql://user:pass@localhost/webhooks"

def get_connection():
    return psycopg2.connect(DB_DSN)

def deliver_webhooks(batch_size=50):
    """
    Fetch pending webhooks and attempt delivery.
    Uses SELECT FOR UPDATE SKIP LOCKED for safe concurrent workers.
    """
    conn = get_connection()
    cur = conn.cursor(cursor_factory=RealDictCursor)
    
    try:
        cur.execute("""
            SELECT id, endpoint_id, endpoint_url, payload, 
                   attempt_count, max_attempts
            FROM webhook_queue
            WHERE status IN ('pending', 'failed')
              AND next_attempt_at <= NOW()
            ORDER BY next_attempt_at ASC
            LIMIT %s
            FOR UPDATE SKIP LOCKED
        """, (batch_size,))
        
        rows = cur.fetchall()
        
        for row in rows:
            # Check circuit breaker before attempting
            if is_circuit_open(cur, row['endpoint_id']):
                # Don't attempt delivery — reschedule for after cooldown
                reschedule_for_cooldown(cur, row['id'], row['endpoint_id'])
                continue
            
            # Attempt delivery and measure latency
            result = attempt_delivery(
                row['endpoint_url'], 
                row['payload']
            )
            
            # Record latency measurement regardless of success/failure
            record_latency(
                cur, 
                row['endpoint_id'], 
                result['response_ms'], 
                result['status_code']
            )
            
            if result['success']:
                mark_delivered(cur, row['id'], result)
                record_success(cur, row['endpoint_id'])
            else:
                handle_failure(
                    cur, row['id'], row['endpoint_id'],
                    row['attempt_count'], row['max_attempts'],
                    result
                )
        
        conn.commit()
    
    except Exception as e:
        conn.rollback()
        raise
    finally:
        cur.close()
        conn.close()


def attempt_delivery(url, payload):
    """
    Fire the webhook and measure response time.
    Returns dict with success, status_code, response_ms, error.
    """
    start = time.monotonic()
    
    try:
        response = requests.post(
            url,
            json=payload,
            timeout=15,          # 15 second hard timeout
            headers={
                'Content-Type': 'application/json',
                'User-Agent': 'WebhookDelivery/1.0',
                'X-Delivery-Timestamp': str(int(time.time()))
            }
        )
        
        elapsed_ms = int((time.monotonic() - start) * 1000)
        
        return {
            'success': 200 <= response.status_code < 300,
            'status_code': response.status_code,
            'response_ms': elapsed_ms,
            'error': None if response.ok else f"HTTP {response.status_code}"
        }
    
    except requests.Timeout:
        elapsed_ms = int((time.monotonic() - start) * 1000)
        return {
            'success': False,
            'status_code': None,
            'response_ms': elapsed_ms,
            'error': 'timeout_15s'
        }
    
    except requests.ConnectionError as e:
        elapsed_ms = int((time.monotonic() - start) * 1000)
        return {
            'success': False,
            'status_code': None,
            'response_ms': elapsed_ms,
            'error': f'connection_error: {str(e)[:200]}'
        }

SELECT FOR UPDATE SKIP LOCKED 该子句对于运行多个工作实例至关重要。 SKIP LOCKED两个工作进程会阻塞在同一行上。有了它,每个工作进程都会获取不同的待处理 Webhook 批次。这样,只需启动更多工作进程即可实现横向扩展。

time.monotonic() 打电话代替 time.time() 这是故意的。 time.time() 在 NTP 调整期间可能会出现倒退。 time.monotonic() 绝不会倒退,这在测量亚秒级延迟时非常重要。

使用指数退避和抖动的重试逻辑

当交付失败时,重试时间决定了您的系统是能够优雅地恢复,还是会产生一连串的请求,从而冲击正在承受压力的终端。

蟒蛇

import random

def calculate_next_attempt(attempt_count, base_delay=30, max_delay=3600):
    """
    Exponential backoff with full jitter.
    
    Attempt 1: 0-30s
    Attempt 2: 0-60s  
    Attempt 3: 0-120s
    Attempt 4: 0-240s
    Attempt 5: 0-480s (capped at max_delay)
    
    Full jitter prevents thundering herd when an endpoint
    recovers and hundreds of retries fire simultaneously.
    """
    exponential_delay = base_delay * (2 ** attempt_count)
    capped_delay = min(exponential_delay, max_delay)
    jittered_delay = random.uniform(0, capped_delay)
    
    return datetime.utcnow() + timedelta(seconds=jittered_delay)


def handle_failure(cur, webhook_id, endpoint_id, 
                   attempt_count, max_attempts, result):
    """
    Handle a failed delivery attempt.
    Either retry with backoff or move to dead letter queue.
    """
    new_attempt_count = attempt_count + 1
    
    if new_attempt_count >= max_attempts:
        # Exhausted retries — dead letter
        cur.execute("""
            UPDATE webhook_queue 
            SET status = 'dead_letter',
                attempt_count = %s,
                last_error = %s,
                last_status_code = %s,
                last_response_ms = %s
            WHERE id = %s
        """, (
            new_attempt_count, result['error'],
            result['status_code'], result['response_ms'],
            webhook_id
        ))
    else:
        # Schedule retry with backoff
        next_attempt = calculate_next_attempt(new_attempt_count)
        cur.execute("""
            UPDATE webhook_queue
            SET status = 'failed',
                attempt_count = %s,
                next_attempt_at = %s,
                last_error = %s,
                last_status_code = %s,
                last_response_ms = %s
            WHERE id = %s
        """, (
            new_attempt_count, next_attempt,
            result['error'], result['status_code'],
            result['response_ms'], webhook_id
        ))
    
    # Update circuit breaker
    record_failure(cur, endpoint_id)

为什么选择全抖动而不是去相关抖动或等抖动?AWS 发布了权威分析。全抖动(在 0 到指数上限之间随机化)能使所有客户端的总完成时间最短。等抖动(在上限的一半到上限之间随机化)更为保守,但清除重试积压的速度较慢。对于拥有多个独立端点的 Webhook 交付,全抖动是最佳选择,因为每个端点的重试都是独立的——无需进行协调。

断路器:停止敲击断裂的端点

熔断机制可以防止系统将资源浪费在持续故障的端点上。如果没有它,一个失效的端点会累积数百个待处理的重试请求,每个请求都会在 15 秒后超时——这会消耗你的工作资源,导致永远无法成功的请求被提交。

蟒蛇

def is_circuit_open(cur, endpoint_id):
    """
    Check if the circuit breaker is open (endpoint is broken).
    If open and cooldown has passed, transition to half_open.
    """
    cur.execute("""
        SELECT state, opened_at, cooldown_seconds
        FROM endpoint_health
        WHERE endpoint_id = %s
    """, (endpoint_id,))
    
    row = cur.fetchone()
    if not row:
        return False  # no health record = assume healthy
    
    if row['state'] == 'closed':
        return False
    
    if row['state'] == 'open':
        # Check if cooldown period has elapsed
        if row['opened_at'] and row['cooldown_seconds']:
            elapsed = (datetime.utcnow() - row['opened_at']).total_seconds()
            if elapsed >= row['cooldown_seconds']:
                # Transition to half_open — allow one probe
                cur.execute("""
                    UPDATE endpoint_health
                    SET state = 'half_open'
                    WHERE endpoint_id = %s
                """, (endpoint_id,))
                return False  # allow the probe delivery
        return True  # still in cooldown
    
    if row['state'] == 'half_open':
        return False  # allow probe delivery
    
    return False


def record_failure(cur, endpoint_id):
    """
    Record a delivery failure. Open circuit if threshold reached.
    """
    cur.execute("""
        UPDATE endpoint_health
        SET consecutive_failures = consecutive_failures + 1,
            last_failure_at = NOW()
        WHERE endpoint_id = %s
        RETURNING consecutive_failures, failure_threshold, state
    """, (endpoint_id,))
    
    row = cur.fetchone()
    if not row:
        # Create health record on first failure
        cur.execute("""
            INSERT INTO endpoint_health (endpoint_id, endpoint_url, 
                consecutive_failures, last_failure_at)
            VALUES (%s, '', 1, NOW())
            ON CONFLICT (endpoint_id) DO UPDATE
            SET consecutive_failures = endpoint_health.consecutive_failures + 1,
                last_failure_at = NOW()
        """, (endpoint_id,))
        return
    
    if row['state'] == 'half_open':
        # Probe failed — re-open circuit with longer cooldown
        cur.execute("""
            UPDATE endpoint_health
            SET state = 'open',
                opened_at = NOW(),
                cooldown_seconds = LEAST(cooldown_seconds * 2, 3600)
            WHERE endpoint_id = %s
        """, (endpoint_id,))
    
    elif (row['state'] == 'closed' and 
          row['consecutive_failures'] >= row['failure_threshold']):
        # Threshold reached — open circuit
        cur.execute("""
            UPDATE endpoint_health
            SET state = 'open',
                opened_at = NOW(),
                cooldown_seconds = 300  -- reset to 5 minutes
            WHERE endpoint_id = %s
        """, (endpoint_id,))


def record_success(cur, endpoint_id):
    """
    Record a delivery success. Close circuit if half_open.
    """
    cur.execute("""
        UPDATE endpoint_health
        SET consecutive_failures = 0,
            last_success_at = NOW(),
            state = 'closed'
        WHERE endpoint_id = %s
    """, (endpoint_id,))

大多数实现都忽略了半开状态到全开状态的升级机制,以及双倍冷却时间。如果在探测期间(半开状态)某个端点发生故障,你肯定不希望在 5 分钟后重试。因为该端点仍然处于故障状态。应该将冷却时间加倍至 10 分钟,然后是 20 分钟,最终上限为 1 小时。这样可以防止熔断机制变成周期性的频繁故障。

延迟百分位跟踪

平均值会骗人。一个平均响应时间为 200 毫秒的终端,可能 95% 的情况下响应时间为 50 毫秒,而剩下的 5% 情况下响应时间为 3,000 毫秒。平均值看起来似乎没问题。但 P95 指标揭示了一个影响每 20 次交付中 1 次的问题。

蟒蛇

def record_latency(cur, endpoint_id, response_ms, status_code):
    """
    Record a latency measurement and update percentile stats.
    """
    cur.execute("""
        INSERT INTO endpoint_latency 
            (endpoint_id, response_ms, status_code)
        VALUES (%s, %s, %s)
    """, (endpoint_id, response_ms, status_code))


def update_latency_percentiles(cur, endpoint_id, window_hours=24):
    """
    Calculate P50, P95, P99 from the rolling window.
    Uses PostgreSQL's percentile_cont for exact percentiles.
    """
    cur.execute("""
        SELECT 
            COUNT(*) as sample_count,
            percentile_cont(0.50) WITHIN GROUP 
                (ORDER BY response_ms) AS p50,
            percentile_cont(0.95) WITHIN GROUP 
                (ORDER BY response_ms) AS p95,
            percentile_cont(0.99) WITHIN GROUP 
                (ORDER BY response_ms) AS p99
        FROM endpoint_latency
        WHERE endpoint_id = %s
          AND measured_at >= NOW() - INTERVAL '%s hours'
    """, (endpoint_id, window_hours))
    
    row = cur.fetchone()
    
    if row and row['sample_count'] > 0:
        cur.execute("""
            UPDATE endpoint_health
            SET p50_ms = %s,
                p95_ms = %s,
                p99_ms = %s,
                sample_count = %s
            WHERE endpoint_id = %s
        """, (
            int(row['p50']), int(row['p95']), 
            int(row['p99']), row['sample_count'],
            endpoint_id
        ))
    
    return row


def get_slow_endpoints(cur, p95_threshold_ms=2000):
    """
    Find endpoints whose P95 latency exceeds the threshold.
    These are candidates for investigation or circuit opening.
    """
    cur.execute("""
        SELECT endpoint_id, endpoint_url, 
               p50_ms, p95_ms, p99_ms, sample_count,
               state, consecutive_failures
        FROM endpoint_health
        WHERE p95_ms > %s
          AND sample_count >= 20  -- need sufficient samples
        ORDER BY p95_ms DESC
    """, (p95_threshold_ms,))
    
    return cur.fetchall()

PostgreSQL的 percentile_cont 是一个有序集聚合函数,用于计算精确的百分位数。对于大型数据集,您应该切换到 percentile_disc (返回实际观测值而非插值)或使用 t-digest 近似值。对于 24 小时窗口的 webhook 发送,原始数据的精确百分位数在每个端点约 100,000 万次测量值以内都足够快。

get_slow_endpoints 我每 15 分钟运行一次此函数进行计划检查。P95 值超过 2 秒的端点会被标记出来进行调查。P95 值超过 5 秒的端点的熔断阈值会被降低——在熔断器打开之前,允许的连续失败次数会减少,因为每次失败的交付都会占用一个工作线程,直到超时为止。

监控 Webhook 交付健康状况

这是我每五分钟运行一次的监控查询。它会生成整个交付管道的单行运行状况摘要:

SQL

SELECT
    -- Queue depth
    COUNT(*) FILTER (WHERE status = 'pending') AS pending,
    COUNT(*) FILTER (WHERE status = 'failed') AS awaiting_retry,
    COUNT(*) FILTER (WHERE status = 'in_flight') AS in_flight,
    COUNT(*) FILTER (WHERE status = 'dead_letter') AS dead_letter,
    
    -- Delivery rate (last hour)
    COUNT(*) FILTER (
        WHERE status = 'delivered' 
        AND delivered_at >= NOW() - INTERVAL '1 hour'
    ) AS delivered_last_hour,
    
    -- Failure rate (last hour)
    COUNT(*) FILTER (
        WHERE status IN ('failed', 'dead_letter')
        AND created_at >= NOW() - INTERVAL '1 hour'
    ) AS failed_last_hour,
    
    -- Oldest undelivered
    MIN(created_at) FILTER (
        WHERE status IN ('pending', 'failed')
    ) AS oldest_pending,
    
    -- Average delivery latency (last hour, successful only)
    AVG(last_response_ms) FILTER (
        WHERE status = 'delivered'
        AND delivered_at >= NOW() - INTERVAL '1 hour'
    ) AS avg_delivery_ms_last_hour

FROM webhook_queue;

oldest_pending 在这个查询中,值是最重要的指标。如果它比最大重试窗口(所有退避延迟的总和)还要旧,则说明结构上存在问题——可能是工作进程卡住了,端点被黑洞吞噬了,或者队列增长速度超过了清空速度。

我针对以下三种情况发出警报:死信数量增加(端点永久失效且无人调查)、待处理队列超过 30 分钟(投递滞后)以及 每个端点的 P95 延迟超过阈值,表明交付可靠性下降 第三个是预警信号——延迟增加先于故障发生。一个原本响应时间为 200 毫秒的端点,如果开始需要 3 秒才能响应,则说明它即将超时。

死信队列不仅仅是存储系统

大多数团队都会使用死信队列,将其作为一张表,用于存放失败的 Webhook 数据。他们会在事件响应期间偶尔检查一下。这是一种浪费。

死信队列是你最有价值的调试数据集。每一行都代表系统多次尝试投递但最终放弃的邮件。死信的模式能告诉你一些成功指标永远无法提供的信息。

蟒蛇

def analyze_dead_letters(cur, hours=24):
    """
    Analyze recent dead letter entries for patterns.
    Returns per-endpoint failure analysis.
    """
    cur.execute("""
        SELECT 
            endpoint_id,
            endpoint_url,
            COUNT(*) AS dead_count,
            
            -- Most common error
            MODE() WITHIN GROUP (ORDER BY last_error) AS primary_error,
            
            -- Most common status code
            MODE() WITHIN GROUP (ORDER BY last_status_code) 
                AS primary_status_code,
            
            -- Timing
            MIN(created_at) AS first_dead,
            MAX(created_at) AS last_dead,
            
            -- Average attempts before giving up
            AVG(attempt_count)::INTEGER AS avg_attempts
            
        FROM webhook_queue
        WHERE status = 'dead_letter'
          AND created_at >= NOW() - INTERVAL '%s hours'
        GROUP BY endpoint_id, endpoint_url
        ORDER BY dead_count DESC
        LIMIT 20
    """, (hours,))
    
    return cur.fetchall()

当我审查死信时,我会寻找三种模式。

集群故障: 同一终端在同一小时内出现 50 个死信,表示该终端已宕机且未在重试窗口期内恢复。措施:延长重试窗口或实施手动重新排队。

状态码模式: 401/403 死信数量激增表示端点已轮换凭据,但无人更新 webhook 配置。429(请求过多)错误数量激增表示您已超过其速率限制,需要进行限流。

逐步积累: 单个端点每天出现 2-3 个死信,均匀分布。这是最隐蔽的模式——端点大部分时间都能正常工作,但会间歇性地出现故障,随着时间的推移耗尽重试次数。修复方法通常是增加重试次数。 max_attempts 针对特定端点或减少超时时间。

运行 Worker

将所有内容串联起来的主回路:

蟒蛇

import signal
import sys

running = True

def shutdown_handler(signum, frame):
    global running
    running = False
    print(f"Received signal {signum}, shutting down gracefully...")

signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)

def main():
    print("Webhook delivery worker starting...")
    
    while running:
        try:
            deliver_webhooks(batch_size=50)
        except Exception as e:
            print(f"Worker error: {e}")
            time.sleep(5)  # back off on errors
            continue
        
        # Update latency stats every 100 iterations
        # (cheap operation, doesn't need to run every loop)
        if int(time.time()) % 100 == 0:
            conn = get_connection()
            cur = conn.cursor(cursor_factory=RealDictCursor)
            try:
                cur.execute(
                    "SELECT DISTINCT endpoint_id FROM endpoint_health"
                )
                for row in cur.fetchall():
                    update_latency_percentiles(cur, row['endpoint_id'])
                conn.commit()
            finally:
                cur.close()
                conn.close()
        
        # Poll interval — 500ms keeps latency low without
        # hammering the database
        time.sleep(0.5)

    print("Worker shut down cleanly.")

if __name__ == '__main__':
    main()

SIGTERM 处理程序对于容器化环境中的正常关闭至关重要。当 Kubernetes 发送 SIGTERM 信号时,工作进程会完成当前批次的操作,提交事务并退出。如果没有它,数据行就会卡在容器中。 in_flight 状态为:没有工作进程正在处理它们。

这个系统无法做到的事情(以及当你需要更多功能时)

此实现方案在单个 PostgreSQL 实例上使用 2-3 个工作进程,每分钟最多可处理约 10,000 条订单。超过此限制,需要进行三项更改。

首先,将 PostgreSQL 队列替换为 Redis Streams 或 RabbitMQ。 SELECT FOR UPDATE SKIP LOCKED 这种模式在高吞吐量下会导致队列表写入争用。专用消息代理可以消除这种争用。

其次,添加基于端点的速率限制。某些接收端点有速率限制(例如每分钟 100 个请求,每小时 1,000 个请求)。如果没有客户端速率限制,您将超出其配额并收到 429 错误。为每个端点实现一个令牌桶。

第三,添加请求签名。有效负载上的 HMAC-SHA256 签名可以让接收端验证 webhook 是否来自您的系统,以及是否在传输过程中被篡改。对于任何发送财务数据的 webhook 系统来说,这都是基本要求。

本文介绍的系统是基础架构。它能够处理所有 Webhook 发送系统(无论规模大小)都需要解决的难题,例如重试逻辑、熔断机制、延迟测量和死信分析。您需要添加的具体组件(例如消息代理、速率限制器和请求签名)则取决于您的吞吐量和安全需求。

最重要的部分恰恰是大多数团队会忽略的部分:衡量交付系统本身的性能。如果你无法回答“过去 24 小时内 P95 交付到 X 端点的延迟是多少”,那么你就是在盲目操作。首先要构建监控系统,其他一切都会随之而来。

Webhook 交付常见问题解答

webhook 发送方是否应该承诺只发送一次?

通常情况下不需要。发送方应该让重试过程可见,并提供稳定的事件 ID;接收方应该保证处理过程的幂等性,以便同一个事件可以多次传递而不会造成业务结果的重复。

哪些失败需要重试?

仅对合约中归类为暂时性故障(例如网络错误、超时和特定服务器响应)进行重试。对于格式错误的请求、身份验证失败或其他永久性错误,如果没有明确的补救措施,请勿反复重试。

团队何时应该摆脱基于数据库的队列?

当衡量出的争用情况、积压队列的积压时间、吞吐量或运维恢复需求表明数据库队列不再满足交付合同时,就应该进行迁移。容量决策应基于观察到的工作负载,而不是笼统的请求速率声明。

上一篇文章

2026 年最佳 iGaming 联盟营销追踪软件

下一篇

人工智能在网络博彩中的应用:ChatGPT 在赌场中的应用案例

凯撒·菲克森
作者:

凯撒·菲克森

我是一名iGaming数据分析师,专注于分析和解读与在线游戏平台、博彩活动以及市场趋势相关的数据。我分析玩家行为、游戏表现和收入趋势,以优化游戏体验和商业策略。

预约演示
步骤 1 的3
谢谢——您已加入队列。
NowG解决方案工程师将在一个工作日内与您联系,安排现场演示。
索引