简介:DY人气协议上线后即设定为1000人的起始规模,每天都会根据运营状态产生不同变化。该协议项目代码以压缩包形式提供,整体仅有3个文件,大小约3KB,轻量而聚焦,适合有开发基础、想了解人气榜单背后实现逻辑的读者快速上手。包里包含三部分内容:inscode承担配置功能,html可用于页面展示,gitignore便于维护代码仓库的忽略规则,三者共同构成一个精简的项目骨架。目前已有222人学习下载,反映出该协议具备一定的关注度与实际应用价值。通过研读这份源码,读者能直接定位协议启动初期的人数控制参数,并梳理每日变化背后的基本调整思路;同时,由于协议每天都有不同变化,还可以将代码逻辑与实际观察到的数据对照,理解初始人数与动态调整之间的因果关系;尽管代码量不大,但对于希望复现类似机制、做二次开发或验证算法效果的技术人员来说,是一份便捷的参考样例。
1. DY人气协议是什么:一条WebSocket链路上的人气判定逻辑
“DY人气协议上线”这句话在技术群里经常被当成黑话,第一次看到的人会以为是什么玄学。其实它讲的是把直播间的人气变动逻辑拆成一组可以模拟的协议交互:进房、ack、心跳、行为上报,缺一个环节,系统都不会把你当成一个“活人”。我最早接触这个方向是在做直播链路自动化验证,发现人气值根本不是黑匣子,而是一串WebSocket消息和定时上报的组合。这个方案适合三类人:做协议逆向和报文解析的、给直播间做自动化压测的,以及想搞明白在线人数到底怎么算的从业者。下面从抓包拆包开始,把协议结构、最小代码、参数调优和上线排障一次讲透。
2. 拆包:一份人气协议报文里同时藏着三种协议
2.1 先分清主链路:HTTP、WebSocket 和 Protobuf 各管哪一段
抓包之前最重要的一件事,是把协议层次分清。你用 Wireshark 看一条直播间数据流,会发现前几个包是 TCP 三次握手和 TLS 握手,之后是一条 WebSocket 长连接,长连接里跑的不是纯文本,而是一坨 Protobuf 二进制。很多新手看到二进制就懵,然后去分析 HTTP 层,结果方向完全错了。
人气协议链路的主流路径是:先用 HTTP/HTTPS 协议从房间接口拉一次房间信息,拿到 room_id 和连接凭据;然后升级到 WSS,也就是 WebSocket over TLS;最后在 WSS 里面定时吐出心跳和事件。三步缺一不可。抓包工具有两种选择:有图形界面的抓包工具适合手机端,监听本机回环流量;Wireshark 适合看完整链路报文。不管用哪个,关键是抓到的 URL 和 Header 要完整保留,尤其是 WebSocket 握手时的请求头,后面写代码时要原样贴进去。
抓包时打开直播间停留一分钟,等上 10 到 20 秒,你会看到三类帧:控制帧、状态推送帧、事件帧。控制帧主要负责 ack 和连接参数协商;状态推送帧里的 WebcastRoomUserSeqMessage 带的就是实时在线人数区间;事件帧则是点赞、聊天、进房这类行为上报。这个分类一旦建立,后面解析任何直播间的协议报文都能直接套用。
常见误用是只盯着 HTTPS 接口看,以为“上线人气”就是把某个 HTTP 请求多打几次。实际上 HTTP 只负责拉取房间配置,真正让人气动起来的是 WebSocket 长连接上的持续消息。所以在拆包阶段,优先把 WebSocket 帧过滤出来,HTTP 部分只要确认 room_id、user_id、签名参数来源即可。
2.2 人气判定的核心:WebcastRoomUserSeqMessage 与事件帧的关系
WebcastRoomUserSeqMessage 是直播间 WebSocket 通道里周期性出现的状态帧,服务端用它广播当前房间的在线人数区间。它里面的核心字段是 seq、online_count,以及一个 user_id 池。客户端只要维持连接并按时回 ack,就会一直出现在这个 user 池里。注意,这个帧只能说明“服务端认为你在线”,不能说明“你是活跃用户”。
活跃判定靠的是另外的事件帧:点赞、进入直播间、发言、上下麦等。任何一个事件帧都是一次 Protobuf 编码的 Message,消息头里包含 message_type、log_id、timestamp 和 payload。做协议报文解析时,先看 message_type,再按对应的 proto 定义解析 payload,不要把不同事件的 payload 混进同一套结构里。
这里有一个非常典型的误用:很多人在帖子里看到 WebcastLikeMessage,就以为只需要发点赞消息。但点赞事件帧需要携带 target_user_id,而且必须在进房事件之后发送。顺序错了,服务端会认为这是一条无上下文的上报,直接丢弃,同时可能把整条连接的可信度调低。人气协议的真实判定逻辑,不是单点触发,而是时序关联:进房、注册、心跳、行为事件,四个消息的时间差和顺序共同决定一个用户是否被计入真实人气。
2.3 一次完整的人气上报顺序:进房→注册→ack→业务心跳→行为事件
协议报文的上报顺序,常见做法是如下五步:连接 WebSocket → 收到服务端 ack → 发送 register 进房注册 → 启动业务心跳循环 → 在心跳间隙随机抛行为事件。这个顺序在服务端是有状态校验的,不是“发得越多越好”。我见过有人把注册、点赞、进房事件在同一秒内全部抛出,结果服务端只回了一个忽略包。
步骤 | 报文方向 | 目的 | 失败表现 连接 WebSocket | 客户端→服务端 | 建立长连接 | 握手阶段直接断开 收到服务端 ack | 服务端→客户端 | 确认连接可用 | 之后所有事件无回执 发送 register 进房注册 | 客户端→服务端 | 标记用户进入房间 | 在线人数不增加 业务心跳 | 客户端→服务端 | 维持会话活性 | 连接被 idle 断开 行为事件 | 客户端→服务端 | 计入活跃权重 | 在线时长不计入或为 0
上线阶段的代码里,顺序不能靠人盯着看,而是要在程序设计上把状态机串起来:CONNECTED → REGISTERED → ACKED → ACTIVE。每一步收到预期回执后再进入下一步,回执超时则重连,而不是把全部消息并发发出去。这个状态机是后面写代码的主骨架。
3. 落地成代码:一份最小可运行的人气协议项目长什么样
3.1 项目文件怎么切:五个文件,别把逻辑堆进 main.py
一份最小可上线的项目,常见做法是切成五个文件,职责分开。切文件不是为了好看,而是因为后面调整协议版本、修改心跳参数、排查线上日志时,能直接定位到模块。所有人气协议项目踩坑踩到最后,都会发现一个问题:当时图省事把连接和上报写在一起,结果一个字段变化就要重新上线整个文件。
config.yaml 管所有可调参数,ws_client.py 只管建立 WSS 连接,proto_codec.py 负责 Protobuf 消息编解码,heartbeat.py 管业务心跳和行为事件循环,main.py 负责拉起协程和状态流转。proto_codec.py 是这里面最容易被低估的文件,因为线上版本的 payload 经常升级,独立模块后只需要替换 proto 编译产物和编解码函数。
3.2 连接代码:先能把一条 WSS 长连接稳定建立起来
人气协议的第一步是建立 WebSocket 长连接。下面这份代码只做一件事:用指定的 room_id 连上直播间的 push-stream 通道,等待服务端首帧回包。这里不处理签名参数,因为不同客户端的签名算法不一致,正式对接时通常单独封装。
# ws_client.py # 只负责建立一条 WSS 连接,并兼容房间号与游标的注入 import asyncio import ssl import websockets WS_BASE = "wss://webcast-hl.douyin.com/webcast/ws/push-stream/" async def create_ws(room_id: str, cursor: int = 0): # 实际线上请求需要拼接动态签名参数,这里展示的是链路最小结构 url = f"{WS_BASE}?room_id={room_id}&cursor={cursor}" headers = { # User-Agent 尽量与抓包时的客户端保持一致,不要用库默认值 "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", "Accept-Language": "zh-CN,zh;q=0.9", } ctx = ssl.create_default_context() ctx.check_hostname = True # 正式环境不要关闭证书校验,关闭会被中间链路直接拦掉 return await websockets.connect( url, additional_headers=headers, ssl=ctx, ping_interval=30, ping_timeout=10, open_timeout=10, )这段代码里几个参数要解释清楚。room_id 来自进房前 HTTP 接口返回的房间信息;cursor=0 表示从头开始接收该房间的消息流,断线重连时会把 cursor 更新为最新位置。ping_interval=30 是 websockets 库底层发送 WebSocket ping 帧的间隔,不是业务心跳,它只保证 TCP 连接不被中间设备切断。open_timeout=10 控制握手超时,超过 10 秒没握手成功就抛出异常,重试策略在 main 层做。
3.3 业务心跳代码:区分“底层保活”和“业务上报”
底层 ping 帧只能证明 TCP 连接还通,不能证明客户端已经被服务端标记为活跃。人气协议里的业务心跳需要在上层单独维护,负载里通常要带时间戳和房间号。下面这个协程就是业务心跳的最小实现。
# heartbeat.py # 业务心跳:服务端通过它判断这条连接对应的用户还“活着” import asyncio import json import time async def business_heartbeat(ws, interval: int): while True: # 业务心跳的时间戳建议以服务端返回时间为基准,不要用本机时间 payload = json.dumps({ "type": "ack", "timestamp": int(time.time()), "room_id": ws.room_id, }) await ws.send(payload) await asyncio.sleep(interval)interval 参数建议从 30 秒起步。低于 20 秒会产生过多冗余帧,服务端会认为客户端异常活跃,反而降低权重;高于 60 秒则容易触发服务端的 idle timeout,连接被静默回收。实际取值应该在抓包数据里找服务端主动断连的最短时间,然后取它的百分之七十作为本地上限。
3.4 行为事件上报:先包 PushFrame 再包 Message
点赞、进房、发言这类行为事件在线上基本都是 Protobuf 编码,而且普遍是两层封装:外层是 PushFrame,内层是具体 Message。下面是编码思路的示意代码,重点看封装顺序。
# proto_codec.py # 以点赞事件为例,展示 Protobuf 消息的封装顺序 def build_like_message(user_id: str, room_id: str, server_time: int) -> bytes: # 用 protobuf 编译后的消息类赋值,这里省略编译产物定义 msg = LikeMessage() msg.user_id = int(user_id) msg.room_id = int(room_id) msg.timestamp = server_time # 常见做法是先包一层 PushFrame,再包一层 Message frame = PushFrame() frame.payload_type = "msg" frame.log_id = generate_log_id() # 每次事件生成新的 log_id frame.payload = msg.SerializeToString() return frame.SerializeToString()这段代码里最容易忽略的是 log_id。log_id 用于服务端链路追踪,每次事件都必须重新生成,不能用同一个值重复发送。如果连续事件携带相同 log_id,服务端会判定为重放,直接丢弃后续所有事件。另一个连带的点是 server_time 要用服务端推送帧里的时间戳回填,而不是用本机系统时间,这个在后面的踩坑章节会展开。
3.5 main 里的状态流转:注册成功之后再开心跳
连接、心跳、事件上报都齐了之后,main.py 要负责把它们串成状态机。最常见的错误是连接一建立就立刻启动业务心跳和事件循环,跳过了注册步骤。
# main.py import asyncio from ws_client import create_ws from heartbeat import business_heartbeat from proto_codec import build_like_message async def run_one(room_id: str, cfg: dict): ws = await create_ws(room_id, cfg["cursor"]) # 先注册再开业务心跳,顺序反了会被服务端忽略 await send_register(ws, room_id, cfg["user_id"]) asyncio.create_task(business_heartbeat(ws, cfg["heartbeat_interval"])) asyncio.create_task(behavior_loop(ws, cfg["behavior"])) async for frame in ws: # 接收服务端推送的同时,还能响应控制帧 await handle_frame(ws, frame)send_register 在收到服务端注册回执前,不应该启动心跳任务。asyncio.create_task 创建的是并发任务,心跳和事件循环互不阻塞;async for frame in ws 负责持续读取服务端推送,控制帧的数据在这里处理。如果服务端下发了新的心跳间隔参数,也要在这里实时更新到业务心跳协程里,而不是重启连接。
4. 上线前必须调好的三个参数:并发、间隔、设备指纹
4.1 并发数量:单机跑 5 条连接起步,30 条之后收益断崖
并发数量是上线最容易拉满、也最容易翻车的参数。人气协议里的并发不等于同时开多少个浏览器,而是一条条 WSS 连接同时维持。不同房间的推送频率不一样,在线人数越高的房间,每秒广播帧越多,客户端处理不过来就会积压,积压一旦超过服务端容忍的延迟上限,连接就会被判定异常。
常见做法是单机先跑 5 条连接,观察 CPU 和内存占用,再逐步加到 10、20。超过 30 条之后,单进程的 EventLoop 和内存增长都不再线性,收益明显下降。另一个容易被忽略的问题是连接建立的时间:不要在同一秒内建立全部连接,启动时给每条连接加 0 到 8 秒的随机延迟,否则服务端对同一出口 IP 的并发握手请求会做限流,表现就是整批连接全部掉线。
4.2 时间间隔:固定间隔是最明显的机器特征
固定间隔最大的问题是规律性太强。服务端的行为识别模型不需要多复杂,只要统计相邻两个事件的时间差,发现它们几乎一样,就会把整条链路的权重降下来。常见做法是给间隔取一个带上下界的随机分布,比如点赞事件在 25 到 45 秒之间均匀采样,浏览事件在 60 到 120 秒之间均匀采样。
# configuration.py # 事件间隔不要写固定值,用均匀分布采样 import random def next_like_interval() -> float: return random.uniform(25, 45) def next_view_interval() -> float: return random.uniform(60, 120)为什么不建议用正态分布?因为人的行为在统计上更接近长尾分布,有人密集操作,有人长时间挂机,均匀分布已经足够模拟这种特征。正态分布反而会让人群行为过于集中在中位数附近。还有一点:相邻两次事件的间隔如果小于 5 秒,服务端会直接判定为异常操作,即使有随机性也要设置下限保护。
4.3 设备指纹:藏在 Header 和 TLS 握手里的隐形参数
设备指纹不是只在登录时校验。头部字段里的 User-Agent、Accept-Language、Sec-Fetch-*,TLS 握手里的 ClientHello 顺序和扩展列表,这些组合在一起就形成一个唯一指纹。最常见做法是把抓包得到的请求头原样保存成模板,只修改 session 相关字段,其余一概不动。很多人的第一反应是用库默认 UA,结果握手成功但状态上报全部被忽略。
参数维度 | 建议做法 | 常见翻车点 User-Agent | 原样复制抓包客户端的 UA | 使用 Python requests 默认 UA TLS 指纹 | 使用系统库默认 SSLContext | 自定义 cipher 列表反而异常 事件时间戳 | 以服务端推送帧时间为准 | 使用本机系统时间导致事件被丢弃 log_id | 每次事件生成新值 | 复用同一个 log_id 被判重放
并发、间隔、指纹这三类参数是互相影响的。并发提高后,行为事件间隔要适当拉大,否则总事件量异常;指纹信息变了,之前调好的并发策略也要重新验证。上线前不要一次性改三个参数,一次只动一个变量,才能定位是哪个参数触发了服务端策略调整。
5. 血泪踩坑:协议本地正常、一上线就失效的 4 个常见问题
5.1 现象:心跳有 ack,在线人数却纹丝不动
连接建立了,业务心跳也发了,服务端每条 ack 都正常回,但房间在线人数完全不变。这个问题出现率极高,原因是只发了底层心跳,没有完成进房注册。人气计数至少依赖“进房消息 + 注册 + ack”三个步骤,只发 ack 只能证明链路是活的,不能证明用户进入了房间并开始计算人气。
解决方法是把上报顺序对齐到状态机:连接建立后先发送 register 事件,等收到注册回执,再启动业务心跳。判断顺序对不对,直接看日志里有没有出现 register 成功回执。如果只有 ack 没有 register,那就是少了关键一步。
5.2 现象:报错 WebSocket connection closed before handshake
握手阶段直接断开,常见于代码部署到新环境后。原因有两类:一类是请求 URL 的参数顺序和抓包时不一致,服务端对 query 参数的排序有校验;另一类是 UA 或 Header 缺了关键字段,被中间链路识别为非常规客户端。
解决方法是打开抓包工具,把握手请求的完整 URL 和 Header 逐字符复制进代码模板,不要自己拼参数顺序。尤其是 query 里的签名参数,在线生成后要原样拼接,签名有效期内尝试重连是没用的。
5.3 现象:多个连接在同一秒被全部重置
一批连接同时掉线,时间点高度一致,原因通常是同一出口 IP 的并发连接数超过了阈值。服务端对同 IP 的握手频率和连接数有限流,表现就是整批连接被重置,而不是逐条失败。
解决方法是把启动错峰:每条连接在启动前随机等待 0 到 8 秒,并限制同一 IP 的并发上限。还有一个关键点:重连时不要所有连接一起重连,要给每条连接独立设置指数退避,退避基数从 3 秒开始,最大到 120 秒。
5.4 现象:本地能跑,服务器上运行报 SSL 证书错误
同一个代码,本地正常,服务器上报 CERTIFICATE_VERIFY_FAILED 或 WRONG_VERSION_NUMBER。原因一般是服务器环境的 SSL 上下文与本地不一致,最常见的是没带 SNI hostname,或者系统的 CA 证书路径不完整。
解决方法是显式创建 ssl.SSLContext,设置 check_hostname=True 并指定 hostname;不要为了省事关闭证书校验。关闭校验看似能绕过本地证书错误,但在部署到正式链路之后,反而会被中间设备直接拦截,报错更隐蔽。
5.5 现象:事件上报成功,但在线时长统计为 0
事件都发出去了,回执也正常,但看板上的在线时长全是 0。多数情况下不是协议本身错了,而是时间戳基准不对。服务端会对事件时间戳做过期和超前校验,客户端系统时间与服务端时间差超过一定范围,事件会被静默丢弃;Docker 容器默认 UTC 时区也会导致时间错位。
解决方法是统一时间基准:在收到服务端推送帧时记录 server_time,后续事件的时间戳全部基于这个值计算偏移,而不是直接取本机系统时间。部署时同步校准容器时间,避免时区导致的时间偏移。
6. 验证协议上线是否成功:三个指标加一个日志
协议跑起来之后,先别急着加并发,先看三个指标。第一个是消息回执率:已收到回执的事件数除以已发送事件数,低于 90% 就说明有事件被静默丢弃,去查时间戳和 log_id。第二个是连接保持时长中位数:稳定连接超过 10 分钟才算合格,如果大多数连接在 3 分钟内被断开,问题大概率在心跳间隔或状态机顺序上。第三个是事件时间戳偏差:日志里事件时间和服务端推送时间差超过 5 秒就要告警。
验证时我会在日志里记录一个 JSON Lines 结构,每条连接一行,包含事件名、时间戳、房间号、用户 ID、回执状态和耗时。运行 30 分钟后,用统计脚本把回执率、掉线时间点、事件间隔分布全部拉出来看。这个日志要留够 7 天,因为策略调整往往不是即时生效。
进阶用法是把这个协议封装成独立进程,按房间维度拆分节点,每个节点用配置文件描述房间号和参数策略。房间数增多后,要单独监控单条连接的推送帧积压量,如果积压超过 500 帧,说明客户端处理能力跟不上,需要降低该房间的并发数。我自己的习惯是每次上线前都先在目标房间跑 30 分钟冷数据,确认回执率和连接时长达标后再放大规模;任何一次“上线”如果没有拿日志做闭环验证,都只是连上了而已。希望帮到你。
本文还有配套的精品资源,点击获取