麻将桌上的并发控制:扫码进房、乐观版本与零和结算

「零碎百宝箱」微信小程序有个麻将计分的工具,四个人同时看着同一张计分表,其中两个人可能在一秒内提交不同结果。麻将计分看起来只是加减法,真正的难点却是共享状态:谁在房间、当前是第几局、谁有权修改,以及重复操作发生时相信谁。

一、为什么麻将不能照搬普通记录同步

倒数日和健康打卡可以采用"本地先写、稍后上云",因为冲突通常只涉及同一个人在不同设备上的修改。麻将房间则完全不同:

  • 多名成员同时观察同一份成员列表和流水;
  • 一次提交会改变所有人的总分;
  • 两人重复提交同一局不能产生两条流水;
  • 结算后所有客户端必须停止继续记分;
  • 服务端必须验证总分,而不能相信某一台手机计算的最终结果。

因此麻将工具是项目中唯一的"联机优先"工具:服务端是事实来源,客户端只保存身份和展示状态,不在离线时伪造一个可稍后合并的房间。

在「零碎百宝箱」的一众轻量工具中,它也是最接近实时协作系统的一个。

二、房间模型:状态、流水与归档分离

服务端没有把整场牌局塞进一个 JSON 字段,而是拆成几类关系:

erDiagram ROOM ||--o{ MEMBER : contains ROOM ||--o{ ROUND : has ROUND ||--o{ ROUND_SCORE : contains SETTLEMENT ||--o{ ROUND : archives SETTLEMENT ||--o{ SETTLEMENT_SCORE : snapshots ROOM { string roomid PK bigint version } ROUND { int id PK string roomid int circle int settlement_id FK } ROUND_SCORE { int round_id FK string openid int score int seat }

Round.settlement_id 为空代表进行中,结算时只需把这些局挂到新建的 Settlement,无需把数据从"当前表"搬到"历史表"。SettlementScore 保存结算时每个人的总分快照,后续个人战绩可以从 (openid, settlement_id) 索引直接查询。

这种"流水保留细节,结算保存快照"的结构兼顾了两种读取:牌桌页面按局展示,历史页面按场聚合。

三、扫码进房只是入口,身份仍来自 JWT

分享房间时,服务端调用微信 getwxacode 生成带房间路径的小程序码。微信 access token 缓存在进程内,并提前五分钟过期,避免每次生成二维码都重新换 token,也降低在过期边界使用旧 token 的概率。

二维码只携带房间入口,不携带可信用户身份。用户打开小程序后仍经过统一微信登录,服务端从 JWT 得到 openid。首次建档时系统从麻将牌名和称谓中随机生成昵称,例如"东风侠";之后用户可修改并持久化。

这条边界很重要:二维码适合传递"去哪个房间",JWT 才回答"你是谁"。如果把 openid 或成员权限直接放在二维码参数中,任何人都可以构造链接冒充成员。

四、低频实时:带版本号的轮询

牌局计分不需要每秒数十次更新,WebSocket 会引入连接管理、断线恢复和网关配置等额外成本。项目采用每隔一段时间拉取房间快照的方式,并用递增版本号减少无效传输。

客户端第一次请求:

http 复制代码
GET /room/state?roomid=ABCD

服务端返回成员、流水、累计分和 version=12。下一次客户端带上版本:

http 复制代码
GET /room/state?roomid=ABCD&v=12

如果房间没有变化,只返回:

json 复制代码
{ "version": "12", "changed": false }

成员加入、离开、提交、撤销或结算时,服务端统一调用 _touch_room 增加版本。这样一次轮询就能判断是否需要替换整个页面状态,避免旧实现分别请求成员和流水。

这个方案非常适合"更新频率低、允许秒级延迟、状态快照不大"的协作场景。若未来要加入出牌过程、聊天或观战,再考虑 WebSocket 才有实际收益。

五、一次记分同时使用乐观检查和悲观锁

客户端提交一局时必须带上自己看到的房间版本:

json 复制代码
{
  "roomid": "ABCD",
  "version": "12",
  "scores": [20, -10, -5, -5]
}

服务端处理流程如下:

sequenceDiagram participant A as 成员 A participant B as 成员 B participant DB as MySQL A->>DB: 锁定 room,期望 version=12 B->>DB: 锁定 room,等待 DB-->>A: 当前 version=12 A->>DB: 写入第 N 局,version=13,提交 DB-->>B: 获得锁,当前 version=13 Note over B: 期望版本不匹配,拒绝提交

这里同时用了两种控制:

  • 乐观版本发现客户端依据的状态已经过期,给出"房间状态已变动,请刷新";
  • 数据库行锁把同一房间的写操作串行化,确保检查版本、生成局号和写入流水处于同一事务。

此外,(roomid, circle) 唯一约束是最后一道数据库防线。即使应用层出现遗漏,两个相同局号也无法同时落库。

只使用版本号而不加锁,会出现两个事务同时读到 12、都通过检查的问题;只使用行锁而不要求客户端版本,则第二个提交可能在不知情的情况下基于旧成员顺序写入。两者解决的不是同一个问题。

六、零和约束必须由服务端验证

每局的所有得分必须相加为零:

python 复制代码
if len(scores) != len(members):
    raise BusinessError('成员数量发生变化')
if sum(scores) != 0:
    raise BusinessError('得分合计须为 0')

零和检查既是业务规则,也是数据完整性检查。没有它,少填一个负分就会凭空制造总分,历史统计将永久失真。

服务端把数组按当前座位映射为 RoundScore,而不是接受客户端传入任意 openid。这样客户端只能为当前房间成员填分,不能给房间外用户构造流水。

累计分直接由数据库聚合:

sql 复制代码
SELECT openid, SUM(score)
FROM round_scores
JOIN rounds ON rounds.id = round_scores.round_id
WHERE roomid = ? AND settlement_id IS NULL
GROUP BY openid;

最终结算只接收倍率,不接收总分。服务端重新聚合进行中流水,乘以限制范围内的倍率后写入 SettlementScore。客户端即使篡改页面显示,也无法改变历史结算结果。

七、撤销和结算也必须面对并发

撤销接口只允许删除最后一局,并可携带客户端看到的 circle 做一致性校验:

  • 服务端最后一局比参数小,说明别人已经撤销;
  • 服务端最后一局比参数大,说明别人又提交了新局;
  • 只有相等时才删除目标流水。

结算同样先锁住房间,并检查是否已经存在结算记录。它在一个事务内完成:

  1. 聚合所有未归档局;
  2. 创建结算与个人总分快照;
  3. 将进行中局的 settlement_id 指向本次结算;
  4. 清空成员房间关系;
  5. 增加房间版本并提交。

如果另一个成员同时点击结算,后获得锁的请求会看到房间已经结算并失败,不会产生两份历史。

"解散"和"结算"也被明确区分:没有任何记分时可以解散且不产生历史;已有流水时必须结算。这个限制避免误操作把已经发生的牌局直接删除。

八、从流水生成个人战绩

结算快照使"按人查战绩"变得简单。服务端可以计算总分、场次、胜场和对局天数,还能按同桌成员筛选。

项目还计算了 withGuys:当一场中有多个赢家或输家时,按照正分总额的占比,把自己的输赢近似分摊到对手身上,用于回答"我主要赢了谁、输给了谁"。这不是财务结算,只是基于零和得分的统计解释,因此使用整数四舍五入即可。

业务统计应从不可变的结算流水推导,而不是在每次操作时维护一堆容易漂移的累计字段。前者查询略复杂,却更容易校验和重算。

九、适用边界与演进方向

版本轮询不是实时协作的通用答案,但在牌桌计分中非常合适:

  • 房间人数少;
  • 写入频率低;
  • 秒级同步可以接受;
  • 每次状态快照体积有限。

后续若提高规模,可以继续演进:为长轮询增加 ETag 语义;把历史流水分页;给房间设置过期清理;对二维码生成做持久缓存;为冲突响应增加稳定错误码,让前端自动刷新后提示用户重新确认。

这套实现最值得复用的地方,是没有把"多人同步"简单理解为多发几次请求。共享状态必须定义事实来源、版本、事务边界和不变量。只要这四件事清晰,轮询也能构成可靠的协作系统。

相关推荐
卸任7 小时前
Vibe coding工具使用心得
前端·ai编程
brien98 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance8 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M8 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写8 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李8 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易8 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding8 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马8 小时前
前沿视界
前端
kyriewen9 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员