把状态塞进令牌:一次「累计驻留 30 秒」的心跳协议拆解

举报
yd_254390150 发表于 2026/11/09 11:18:14 2026/11/09
【摘要】 同一份 session_id 连发心跳,第一拍之后 accumulated_ms 全是 0——因为它是签名过的状态令牌,进度和上一拍时间都编码在里面,必须链式传递新令牌。顺带看看用无状态换存储、用带宽换运维的取舍,以及它为什么能顺手防住后台挂机。

需求:怎么证明一个用户真的「看」了 30 秒

做在线学习、案例浏览这类场景,几乎一定会遇到同一个需求:判断用户是否真的在页面上停留了足够长的时间。任务描述写得很轻松——「打开任意案例详情页并保持浏览 30 秒」——但落到实现上,这句话根本没法直接执行。

「打开页面」是客户端能自证的事,而「保持浏览 30 秒」是服务端需要相信的事。用户完全可以在打开页面的瞬间就切走,或者干脆不开浏览器,直接用脚本发一个「我看完了」的请求。所以真正的问题不是「怎么计时」,而是怎么让服务端在不信任客户端的前提下,仍然愿意相信这段时长的真实性。

把任务接口拉出来,服务端把整个评分模型都写在响应里了:

{
  "task_key": "case-view-daily",
  "evaluator": "dwell",
  "period": "DAILY",
  "points_hint": 10,
  "need_seconds": 30,
  "max_gap_ms": 15000,
  "today_status": "todo",
  "elapsed_seconds": 0
}

三个字段定义了整套规则:evaluator: dwell 说明按累计驻留时长判定;need_seconds: 30 是门槛;max_gap_ms: 15000 是心跳间隔上限。不是「打开页面就算」,而是「要有持续的心跳证据,且相邻两拍间隔不能超过 15 秒」。

协议本身很朴素

整个交互只有两个接口:

# 1) 建立浏览会话
POST /api/growth/tasks/case-view-daily/sessions
body: {"case_id": "c5f78fbd5a5e48d98857520b3c8f6565"}
→ {"session_id": "<JWT>", "need_seconds": 30, "max_gap_ms": 15000, "server_time": ...}

# 2) 每隔 < 15 秒发一次心跳
POST /api/growth/sessions/{session_id}/heartbeat
body: {"session_id": "<当前 JWT>"}
→ {"session_id": "<新 JWT,累计时长已更新>", ...}

看起来平平无奇。但如果按最常见的直觉去实现——建一次会话,然后拿同一个 session_id 连续发心跳——会得到一个非常诡异的结果:

心跳 #1  accumulated_ms = 10070
心跳 #2  accumulated_ms = 0
心跳 #3  accumulated_ms = 0
心跳 #4  accumulated_ms = 0
心跳 #5  accumulated_ms = 0

只有第一拍累加,之后全部归零。发五拍、每拍间隔 10 秒,按说累计该有 50 秒,实际永远停在 10 秒出头,任务状态纹丝不动。

把 session_id 解开就明白了

第一反应通常会是「服务端在按 IP 或按会话做去重」,于是去查请求头、加延时、换 case_id——全是错的方向。真正的线索在那个看起来像随机 ID 的字符串里:它其实是个 JWT。

{
  "accumulated_ms": 0,
  "case_id": "c5f78fbd5a5e48d98857520b3c8f6565",
  "domain_id": "01a13f7c...",
  "iam_id": "01a13f7c...",
  "last_beat_at": 1793674430626,
  "nonce": "9SmJKAKkIE0",
  "task_key": "case-view-daily",
  "exp": 1793676230631
}

session_id 不是会话标识,而是一个签名过的状态载体。进度(accumulated_ms)和上一拍时间(last_beat_at)直接编码在令牌里,服务端不需要为每个用户存任何东西。

于是「复用旧令牌」的后果就清楚了:服务端解签拿到的 last_beat_at 是第一拍的时间。当第五拍到达时,它算出「距上一拍已经过去 40 秒」,超过 max_gap_ms 的 15 秒上限,于是判定这一段驻留作废,增量为零。令牌里的 accumulated_ms 也仍然是旧的 0,所以怎么发都推进不了。

正确做法是链式传递——每次心跳的响应都返回一个新令牌,下一次必须用这个新的:

bean = create_session(case_id)
sid = bean["session_id"]

while elapsed < 30:
    r = post_heartbeat(sid)          # body 里的 session_id 就是当前这个
    sid = r["session_id"]            # ★ 关键:立刻换成新的
    elapsed = r["accumulated_ms"] / 1000

改成链式之后立刻正常:

心跳 #1  accumulated_ms = 9068
心跳 #2  accumulated_ms = 18128
心跳 #3  accumulated_ms = 27187
心跳 #4  accumulated_ms = 36246
★ 已达 30 秒
[最终] today_status = done   completed_at = 1793674538182

间隔压到 9 秒左右,四拍就能稳稳跨过 30 秒门槛。

这个设计其实很讲究

一开始我只觉得它别扭——为什么要把状态塞进令牌里来回传?想清楚之后会发现这是一个相当克制的取舍。

一、防挂机。只记录「何时打开过页面」是不够的,用户开完就切走照样能拿分。要求持续心跳、且间隔超时即作废,等价于要求「页面在被观测期间一直活着」。后台标签页会被浏览器节流,心跳自然稀疏,正好被这道规则挡掉——这大概是设计者顺手得到的好处。

二、无状态服务端。时长不落库,而是编码进签名令牌由客户端回传。服务端每拍只做三件事:解签、校验时间差、重新签一个。省掉了「会话表 + 过期清理定时任务」这一整套基础设施,也不存在「会话记录残留几百万条」的运维问题。对于每天几十万人打卡的功能,这个成本差异很实在。

三、不可伪造。客户端改不了 accumulated_ms,因为令牌有签名。想凭空跳到 30 秒,得有服务端的密钥。这比「前端上报一个 duration 字段」强得多。

四、令牌自带过期。exp 让「开一个会话放着,隔天再来发心跳」不成立,会话窗口天然收窄。

本质上这是一种用带宽换存储的做法:每拍多传几百字节,换取服务端零状态。当状态很小(一个整数加一个时间戳)、而请求频率可控时,这笔交易是划算的。

但代价也真实存在

不理解这套语义而按直觉写代码,就会掉进前面那个坑,而且症状极具误导性:请求返回 200、响应体结构完全正常、accumulated_ms 字段也存在,只是值不动。

更麻烦的是它的失败方式——不是报错,而是静默地什么都不发生。这类 bug 的排查成本远高于抛异常的那种,因为你手上没有任何错误信息可以追,只有一个「该变大却没变大」的数字。

所以用这类接口时,有三个习惯值得养成:

  • 先读判定参数,再写实现。evaluator / need_seconds / max_gap_ms 这三个字段当时就摆在接口响应里,我却在「等 40 秒」上耗了一轮才发现任务压根不是按打开页面判定的。
  • 看响应里有没有「需要回传的东西」。如果接口返回了一个看起来像 ID 但长度可疑的字符串,解开看看——很可能它不是资源标识,而是状态。
  • 把「动作成功」和「状态推进」分开验证。200 只证明请求被受理,不证明业务前进了。要有独立的读接口(这里是 GET /api/growth/tasks/<key>)做对账。

小结

「累计驻留 30 秒」这种看似简单的需求,背后其实是在回答一个更本质的问题:如何在一个不可信的客户端上,建立一个服务端愿意采信的时间证明。用签名状态令牌 + 心跳间隔上限,是其中一种相当优雅的答案——它把「证明」的成本从服务端存储转移到了往返传输上。

而作为调用方,最该记住的一条是:当接口返回的东西不是数据而是一个带状态的信物时,它就必须被当作接力棒传下去。拿着发令枪跑完全程,是跑不完的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。