在 WhatsApp 多账号管理场景下,消息从网关进入业务系统后往往要经历账号切换、网络重连、失败重试等环节。只要其中一个环节出现抖动,就可能出现同一条消息被投递两次,或者消息到达顺序与发送顺序不一致的问题。这篇文章分享我们在实际落地过程中用到的一套去重与顺序保证方案,思路不复杂,但细节容易踩坑。
核心结论
消息去重靠全局唯一消息 ID + 幂等写入 ;顺序保证靠发送端单调序列号 + 接收端按序重组。两者都依赖一个轻量级的中央索引层来协调多个账号之间的状态。
为什么多账号场景下更容易乱序和重复
WhatsApp 多账号架构里,一个业务账号通常对应一个独立的会话实例。当某个实例因为网络波动断开重连时,常见的情况有:
- 网关已经收到消息回执,但业务层因为超时触发了重发,导致同一条消息被处理两次;
- 多个账号并行处理消息,后发送的消息反而先进入消费队列;
- 客户端切换账号后,本地缓存的状态没有同步,造成重复拉取。
这些问题在单账号场景下可以通过本地锁简单解决,但到了多账号环境,必须引入一个跨账号的协调机制。
设计一:去重层
我们的去重策略分两层:
- 生成端去重 :每条消息在业务系统产生时就分配一个全局唯一
msg_id,规则为biz_type:account_id:client_seq:timestamp,保证同一账号同一秒内不会重复。 - 消费端幂等 :消息进入处理队列前,先在 Redis 中检查
msg_id是否已存在。存在则直接丢弃,不存在才允许消费。
python
import hashlib
import time
def make_msg_id(account_id: str, client_seq: int) -> str:
ts = int(time.time())
raw = f"wa:{account_id}:{client_seq}:{ts}"
return hashlib.sha256(raw.encode()).hexdigest()[:24]
class DedupLayer:
def __init__(self, redis_client):
self.r = redis_client
self.ttl = 7 * 24 * 3600 # 保留 7 天
def is_duplicate(self, msg_id: str) -> bool:
key = f"msg:dedup:{msg_id}"
exists = self.r.set(key, "1", nx=True, ex=self.ttl)
return exists is None
这里的关键点是 TTL 要覆盖业务上可能出现的最大重试窗口。我们设 7 天,是因为 WhatsApp 消息回执偶尔会出现跨天延迟;窗口太短会导致过期后重复消费。
设计二:顺序保证层
去重解决的是"不要重复处理",顺序保证解决的是"按正确顺序处理"。
我们在每个账号上维护一个单调递增的 client_seq,发送端严格按顺序分配。接收端用 Redis Sorted Set 暂存消息,score 就是 client_seq,消费线程只消费当前期望 seq 之前连续到达的消息。
python
class OrderBuffer:
def __init__(self, redis_client, account_id: str):
self.r = redis_client
self.account_id = account_id
self.seq_key = f"msg:seq:{account_id}"
self.buf_key = f"msg:buf:{account_id}"
def push(self, msg_id: str, seq: int, payload: str):
self.r.zadd(self.buf_key, {payload: seq})
self.r.set(f"msg:id:{msg_id}", payload, ex=7*24*3600)
def consume(self, handler):
expected = int(self.r.get(self.seq_key) or 1)
while True:
items = self.r.zrange(self.buf_key, 0, 0, withscores=True)
if not items:
break
payload, seq = items[0]
if int(seq) != expected:
break
handler(payload)
self.r.zrem(self.buf_key, payload)
self.r.incr(self.seq_key)
expected += 1
这个 buffer 有两个好处:
- 乱序到达的消息不会提前消费,而是等待前面缺失的 seq 补齐;
- 重连后只要 seq 连续,就可以自动追回顺序。
与账号路由结合
在多账号管理工具中,消息首先根据目标客户的归属关系路由到具体账号,再由该账号的 seq 体系保证顺序。WADesk 的实践中,账号路由层会先把消息写入账号独立的待发送队列,然后由账号工作线程按 seq 顺序取出、调用网关。这样可以避免多个账号之间互相抢占顺序。
对于已经发出的消息,如果网关返回"发送中"但没有回执,我们不会立即重发,而是进入补偿队列,等待一个可配置的"静默期"后再判断是否需要补发。补发时仍然使用原来的 msg_id,因此即使网关实际已经送达,也不会造成重复。
异常场景处理
- seq 跳号:如果消费端发现期望 seq 长时间未到达,会触发告警并进入人工排查流程。通常原因是发送端崩溃导致 seq 分配不连续,需要通过持久化 seq 生成器来避免。
- Redis 故障:去重和顺序层都依赖 Redis,一旦 Redis 不可用,系统会降级为只去重不保序,优先避免重复消息影响客户体验。
- 账号切换:当客户被重新分配到另一个账号时,新账号从初始 seq=1 开始,避免与旧账号 seq 冲突。
落地时的三个注意点
- seq 生成器必须持久化 :如果发送端进程重启后从 1 开始重新计数,接收端会把新消息当成旧消息丢弃。我们把
client_seq写入了 RocksDB,每次分配前先刷盘。 - 去重 key 的粒度要合适 :按账号维度去重会漏掉跨账号重复,按全局维度去重又可能误判。我们最终采用
biz_type + account_id + client_seq三段组合,既保证准确性,又避免不同业务互相影响。 - 顺序 buffer 需要兜底清理:如果某条消息长期缺失,buffer 会无限膨胀。我们给每条待排序消息加了 5 分钟超时,超时未补齐则触发告警并跳过该 seq,由人工确认后决定是否补发。
可观测性建议
建议至少监控以下指标:
- 重复消息拦截数
- seq 等待队列长度和等待时长
- 补发次数与补发成功率
- 各账号发送速率与失败率
WADesk 内部会按账号维度输出这些指标,帮助运营团队快速定位是网关抖动还是路由策略问题。
写在最后
消息去重与顺序保证并不是高并发场景才有的需求。只要你的系统涉及多账号、重试、网络波动,就早晚会遇到这些问题。提前把 msg_id 和 client_seq 设计好,比事后再补要省事得多。如果你也在做 WhatsApp 相关的多账号消息系统,希望这套思路能帮你少踩几个坑。
