出价更新必须用broadcast(),不能靠前端轮询;服务端写入成功后立即广播结构化消息,广播逻辑须独立于onMessage,配合version字段防乱序,并通过数据库行级锁与三重校验保障一致性。

出价更新必须用 broadcast(),不能靠前端轮询
轮询在拍卖场景下会直接导致状态错乱:用户A出价后,用户B可能还在请求上一轮数据,看到的仍是旧价格。Workerman 的 broadcast() 是唯一可靠方式——服务端在数据库写入成功后,立刻向该商品所有在线连接推送结构化消息,比如 {"type":"price_update","item_id":101,"price":2850,"bidder":"张三","version":127}。
关键点在于:不要在 onMessage 回调里做推送逻辑;它只负责接收出价请求并校验。真正的广播必须由独立定时器或事件触发器驱动,否则并发高时会阻塞整个进程。
- 每次出价成功后,服务端生成单调递增的
version字段,前端只接受version > localStorage.getItem('last_version')的消息,避免网络乱序导致价格跳变 - 广播前过滤掉无变化的数据(如价格变动
- 若某商品当前无任何 WebSocket 连接,
broadcast()不会报错但也不发送,需配合兜底拉取机制
倒计时同步必须以服务端时间为准,不能用 setInterval 递归
前端用 setInterval(() => { sec-- }, 1000) 看似简单,但页面切换、系统休眠、浏览器节流都会让倒计时漂移。真实拍卖中,3 秒误差就可能让“最后一秒出价”失效。
正确做法是首次加载时请求接口(如 /api/auction/123),拿到 end_time(ISO 格式)或 remaining_seconds,然后每 500ms 用本地时间重算:remaining = Math.max(0, serverEnd - Date.now() + offset)。
- offset 是首次请求时计算出的服务器与本地时间差,后续不再更新,防止抖动
- 倒计时归零瞬间,除了禁用按钮,必须调用
AbortController.abort()清除所有待发的出价请求 - WebSocket 断连重连后,前端应主动 GET
/api/auction/123/status拉取最新剩余秒数和最高价,不依赖广播补帧
409 Conflict 和 422 Unprocessable Entity 要分开处理
这两个状态码常被前端混为一谈,结果用户反复点击“出价”,界面却没反馈。其实它们代表完全不同的失败原因:
409 Conflict 表示“你慢了”——别人已出更高价或拍卖已结束。此时不该弹错误提示,而应静默拉取最新状态,最多重试 2 次(间隔 1.5s),失败再提示“已被抢先”。
422 Unprocessable Entity 表示“你错了”——比如出价低于加价步长、非整数、未登录等。响应体里通常有 errors.bid 或 message 字段,前端要解析并展示具体原因,而不是笼统说“出价失败”。
- 提交前本地校验
min_increment(从接口预取),能减少无效请求,但不能替代服务端校验 - 对
409响应,立即重置倒计时显示,并触发一次全量状态拉取,避免仅靠广播漏掉关键变更 - 不要用按钮
disabled属性判断是否可出价——DevTools 可轻易绕过;一切以 JS 的isBidding状态为准
服务端必须做三重防护,前端限制只是障眼法
真实系统里,光靠前端禁用按钮、加防抖、限制输入框数值,毫无意义。攻击者分分钟绕过。所有校验必须落在服务端,且至少三层:
第一层是时间戳校验:拒绝处理早于当前服务器时间 500ms 的出价请求,防止客户端伪造时间抢占;第二层是版本号比对:每次广播附带 version,客户端只接受严格大于本地缓存值的消息;第三层是最终状态兜底:WebSocket 断连重连后,强制拉取 /api/auction/123/latest(含当前价 + 最近 3 条出价记录),与服务端快速对齐。
- 数据库写入必须加行级锁(如
SELECT ... FOR UPDATE),避免并发出价覆盖 - 加价步长(
min_increment)不能硬编码在前端,必须随商品信息一起下发,且服务端再次校验 - 广播消息里不要包含敏感字段(如用户手机号、完整 IP),只传必要渲染信息
倒计时和出价更新看似两个独立功能,但在 Workerman 里它们共享同一套连接生命周期和状态管理逻辑。最容易被忽略的是:断连重连后的状态恢复不是“重新连上就行”,而是必须主动发起一次精准的状态快照拉取——广播只保增量,不保全量。

















