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

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

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

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

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

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

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

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

服务端没有把整场牌局塞进一个 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 语义;把历史流水分页;给房间设置过期清理;对二维码生成做持久缓存;为冲突响应增加稳定错误码,让前端自动刷新后提示用户重新确认。

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

相关推荐
巴勒个啦1 小时前
从需求到上线:记录一次完全由 AI 辅助完成的小产品全流程
java·前端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer
前端·vue.js·react.js
hunterandroid1 小时前
Room 并发写入与事务一致性:从数据竞争到可靠落地
前端
天才熊猫君2 小时前
自动给所有 catch 块补上错误上报:从原理到落地
前端·javascript
朱涛的自习室2 小时前
Munk AI 桌面端「预告」
android·前端·人工智能
程序员包打听2 小时前
从 npx 到 moonx,moonbit 的野心与展望
前端·后端
laboratory agent开发2 小时前
工具调用失败后怎么办?重试分层与降级回路的三种路线
服务器·前端·网络
IMPYLH2 小时前
HTML 的 <dialog> 元素
前端·html
AI编程实验室2 小时前
用 npm + Three.js 做一颗西瓜:把夏天的清凉感放进浏览器
前端·后端·ai编程