Webhook、WebSocket 还是轮询?按失效方式选

16 min readSocialAPI 工程团队

Webhook、WebSocket 还是轮询?按失效方式选

搜「Twitter webhook」的人,心里想的是 Stripe 或 GitHub 那套:注册一个 URL,有事就收 POST,完事。

这套在 X 的公开 API 里对任意账号是不存在的。X 的 Account Activity API 确实有 webhook,但只覆盖通过 OAuth 授权过你的 App 的账号——你自己的号,或者走完你登录流程的用户。想要「这个公开账号发推时通知我」,X 没给你任何可以注册的东西。

第三方服务商可以补上这块,我们也补了。所以真正的问题不是 webhook 能不能做到,而是三种方式里,哪一种的失效方式你能接受。

只想看对比的话,下面那张表就是全文。有个免费 demo 端点,可以先摸清响应结构再决定架构。


三个选项

轮询 WebSocket Webhook
X 官方直供(公开账号) 有 无 无
经第三方服务商 有 有 有
典型延迟 轮询间隔的一半 秒级 秒级
谁持有连接 没人 你 对方
失效方式 漏掉两次轮询之间的事件 连接被断 你的端点挂了
你重启后能否自愈 天然可以 需要重连逻辑 天然可以
NAT 内网 / localhost 可用 可以 可以 不行——要公网 URL
适配 Serverless 可以 很差 可以

最后一行通常就是决定项。WebSocket 要一个长生命周期进程,Lambda 和云函数没有。如果你的消费端是 serverless——或者 Zapier、n8n、一个 Telegram 机器人——webhook 不是偏好问题,而是唯一对得上的形状。


轮询:适用场景比大家承认的多

轮询的名声比它应得的差。如果你的容忍度是「一两分钟内收到」,它更简单、更好推理,而且出故障时什么都不用做就能恢复。

import requests, time

API = "https://api.socialapi.tech/v1"
HEADERS = {"X-API-Key": "your_key"}
seen = set()

def poll(username, interval=60):
    while True:
        r = requests.get(f"{API}/user/last_tweets",
                         params={"username": username, "limit": 20},
                         headers=HEADERS, timeout=30)
        if r.status_code == 429:          # 是被限流,不是坏了
            time.sleep(interval * 2); continue
        r.raise_for_status()

        for t in reversed(r.json().get("data") or []):
            if t["id"] not in seen:
                seen.add(t["id"])
                handle(t)                 # 旧的先处理,顺序才读得通
        time.sleep(interval)

这段里有三处不那么显眼的细节。

reversed()——接口返回的是新的在前,所以直接遍历会把事件倒着推给你。做通知器没人察觉,但凡是要还原对话线的,这个 bug 要找很久。

那个 seen 集合——轮询窗口天生重叠。同一条推你会在连续两次轮询里都看到,去重是你的事。

遇到 429 退避而不是报错——限流是暂时的,正确反应就是晚点再来。三种限流怎么区分,在「rate limit exceeded」到底是什么意思里讲过。

轮询真正会崩的地方:一个账号 90 秒内发五条,而你 5 分钟轮一次、limit=20。这五条你能全收到。但把发推频率再提高,或者把 limit 降下来,你就开始丢掉爆发的中段——悄无声息地丢,日志里什么都看不出来。


WebSocket:延迟最低,连接归你管

WebSocket 把延迟从分钟压到秒。代价是你现在拥有一条迟早会断的连接——不是「可能」,是「一定」。网络会重置,负载均衡会回收,容器会漂移。

import json, time, websocket   # websocket-client

def run(url, headers, on_tweet):
    backoff = 1
    while True:
        try:
            ws = websocket.create_connection(url, header=headers, timeout=30)
            backoff = 1                      # 只在真连上之后才重置
            while True:
                msg = ws.recv()
                if not msg:                  # 服务端关闭了
                    break
                on_tweet(json.loads(msg))
        except Exception as e:
            print("stream dropped:", e)
        finally:
            try: ws.close()
            except Exception: pass
        time.sleep(backoff)
        backoff = min(backoff * 2, 60)       # 要有上限

退避要在成功连上之后重置,不是在收到消息之后。 收到消息就重置,意味着一条每隔几秒抖一次的连接会一直猛敲服务端;在连接成功时重置才能真正退避。

真正决定你架构的问题:断开那 60 秒里的事件怎么办?纯推流不会补发。如果缺口不可接受,就在推流之外跑一个低频轮询,按推文 ID 去重——多写几行,延迟和完整性同时拿到。


Webhook:当你没法持有连接

如果你的消费端开不了长连接,服务端侧的 webhook 就是对的形状。你注册一个 URL,新推以 POST 送达。

下面是一个像样的实现该处理的事——这张清单也适合用来对照任何服务商,包括我们:

签名。 你的端点在公网上,一定会被探。我们对 {timestamp}.{body} 做 HMAC-SHA256,放在 X-SocialAPI-Signature: sha256=…,旁边带 X-SocialAPI-Timestamp。先验签再信 payload,并且拒掉时间戳过旧的请求——这才是阻止别人把截获的请求过一阵再重放给你的那一步。

import hmac, hashlib

def verify(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(),
                        f"{timestamp}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)

用 compare_digest,不要用 ==。字符串比较会在第一个不同的字节处提前返回,只要对方有耐心测响应时间,签名就会被一个字符一个字符地试出来。

重试。 一次部署三十秒,没有重试就是这个窗口里的事件全丢。我们按 1s / 3s / 7s 退避重试后才放弃,每次投递超时 6 秒。这个问题值得问任何服务商——「我们只 POST 一次」是个真实存在的回答,也是个真实存在的局限。

你这一侧的幂等。 重试按定义就会产生重复。每次投递带一个稳定的 X-SocialAPI-Delivery 作为 ID,按它或按推文 ID 做键,让 handler 跑两遍也没事。这和轮询那个 seen 集合是同一种纪律,只是换到了线路的另一端。

一个实务说明:我们支持直接填 Telegram bot token 并在内部解析成正确的 URL——「把新推发进 Telegram 频道」这个需求足够常见,为它让人自己搭一座桥没什么道理。每个账号最多可以注册五个端点。


一遍选完

  1. 一分钟延迟能接受吗? → 轮询。到这就停,你要维护的代码最少。
  2. 要秒级,而且你有常驻进程? → WebSocket,带重连和有上限的退避。
  3. 要秒级,但消费端是 serverless 或第三方工具? → Webhook。
  4. 停机期间能丢事件吗? → 不能的话,在你选定的方式之外加一个定期轮询,去重。

第一天就要 webhook 的项目,大多数在第 1 步就被解决了。真正需要它的,答案在第 3 步——而识别特征很明确:他们的消费端没有任何进程能持住一条 socket。

两条投递通道我们都在跑:WebSocket 订阅按账号按小时计费,webhook 则是上面描述的签名和重试行为。实时流展示那篇讲前端那一侧,监控 X 关键词讲查询那一侧。用 Google 一键注册送 $0.20 额度、不用信用卡——足够把两条都接上,看哪条合适。


常见问题

Twitter 有 webhook API 吗? X 的 Account Activity API 支持 webhook,但只覆盖通过 OAuth 授权过你的 App 的账号。对任意公开账号,X 没有原生 webhook——这个缺口就是第三方服务商在填的。

怎么在某人发推时收到通知? 按间隔轮询他的时间线、用 WebSocket 订阅,或者在服务商那注册一个 webhook。公开账号三种都行,后两种能做到个位数秒级。

webhook 和 WebSocket 有什么区别? 方向和连接归属。webhook 是服务商往你的公网 URL 发 POST;WebSocket 是你的客户端持着一条连接。WebSocket 在 NAT 后面能用,webhook 需要可达端点但你重启不影响它。

公开推文能用 webhook 吗? X 官方不行,但通过提供这项能力的服务商可以。我们提供,带 HMAC 签名和自动重试。

怎么验证 webhook 签名? 用你的密钥对 {timestamp}.{body} 重算 HMAC-SHA256,和签名头做恒定时间比较。同时拒掉时间戳过旧的请求以防重放。

为什么要用 compare_digest 而不是 ==? 普通字符串比较在第一个不同字节就退出,响应时间会把签名一个字符一个字符地泄露出去。恒定时间比较消掉这条侧信道。

我的 webhook 端点挂了会怎样? 取决于服务商。我们按 1s/3s/7s 退避重试,每次投递超时 6 秒。一定要问清楚——「只 POST 一次」是个常见且后果严重的答案。

webhook handler 需要幂等吗? 需要。重试和重连都会产生重复。按投递 ID 或推文 ID 做键,让第二次执行变成空操作。

WebSocket 流有多快? 秒级而不是分钟级。要问清服务商说的「实时」是真推送还是接口背后在高频轮询——这个差别在延迟和成本上都会体现。

轮询是不好的做法吗? 不是。只要一分钟延迟可接受,轮询就是正确选择。失效方式更少,重启自动恢复,排查也容易得多。

一个账号该多久轮一次? 按它的发推频率和你的延迟容忍度定。60 秒适合大多数监控场景。账号活跃的话,要确保页大小能覆盖两次轮询之间的爆发。

为什么同一条推我收到了两次? 轮询窗口重叠、推流重连会重放、webhook 会重试——三种都会产生重复,按推文 ID 去重。

WebSocket 需要公网 URL 吗? 不需要。客户端是向外建连接,所以在笔记本上、NAT 后面、没有入站端口的容器里都能用。

能在 serverless 函数里跑 WebSocket 吗? 很难——函数生命周期短,而推流要常驻进程。用 webhook,这正是它存在的场景。

推流断开期间的推文去哪了? 纯推流不会补发。缺口要紧的话,旁边跑一个低频轮询并去重。

推流重连该怎么处理? 指数退避 + 上限,只在成功连上之后重置。收到消息就重置会让抖动的连接猛敲服务端。

能把推文直接发到 Telegram 或 Discord 吗? 可以,这是标准的 webhook 目标。我们支持直接填 Telegram bot token 并自行解析——这个场景常见到让人自己搭桥属于浪费。

最多能注册几个 webhook 端点? 我们这边每账号五个。够你分开 dev/prod,再加几个通知目标。

能订阅话题标签而不是账号吗? 推流和 webhook 的常规单位是账号订阅。话题标签或搜索词请按间隔轮询搜索端点——见监控 X 关键词。

推流和轮询的计费怎么比? 轮询是按请求收费,不管有没有新内容;推流一般是按订阅账号的时长收费。一个安静但要持续盯的账号,推流通常更划算;偶尔查一次的话,轮询更划算。

用了推流或 webhook 还需要处理限流吗? 投递本身需要得少了,因为你不再反复发请求。但旁边那些 REST 调用仍然需要退避,订阅管理本身也是普通 HTTP。

既要快又要保证不漏怎么办? 两条一起跑:推送拿延迟,低频轮询补缺口,按 ID 去重。这是标准做法,代价是几行代码而不是一套架构。

接到生产环境之前怎么测 webhook? 先注册一个请求检查类的 URL 并触发测试投递,这样你能看到真实的请求头和 body。我们有测试端点正是为此——拿真实投递验证你的验签代码,不要拿手工构造的。

webhook 该在处理之前还是之后返回 200? payload 一落到持久存储就立刻返回 200,然后异步处理。 先干活再响应会有超时风险,而超时会触发重试,于是同一个事件你收到两遍。

我的端点不是挂了、只是慢,会怎样? 超过超时就按投递失败处理——我们是每次尝试 6 秒。慢的 handler 和死掉的 handler 在发送方看来一模一样,这正是「先确认、后干活」的理由。


按失效方式选,不要按延迟数字选——这是这篇唯一值得带走的想法。想拿真实流量把两条投递通道都试一遍,用 Google 注册送 $0.20 额度、不绑卡。