Webhook、WebSocket 还是轮询?按失效方式选
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 频道」这个需求足够常见,为它让人自己搭一座桥没什么道理。每个账号最多可以注册五个端点。
一遍选完
- 一分钟延迟能接受吗? → 轮询。到这就停,你要维护的代码最少。
- 要秒级,而且你有常驻进程? → WebSocket,带重连和有上限的退避。
- 要秒级,但消费端是 serverless 或第三方工具? → Webhook。
- 停机期间能丢事件吗? → 不能的话,在你选定的方式之外加一个定期轮询,去重。
第一天就要 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 额度、不绑卡。