ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java实现游戏护航系统:状态机与高并发抢单设计实践

Java实现游戏护航系统:状态机与高并发抢单设计实践 这段时间在逛技术社区的时候经常看到有人讨论陪玩平台的“护航”订单经常出现打手接了单却没动静、用户等半天不知道进度、出了问题平台方也拿不出有效仲裁依据这类问题。做了这么多年Java后端我第一反应是——这根本不是单纯的运营问题而是技术架构没跟上业务形态。很多团队把护航服务做成了一个普通订单系统订单状态全靠人肉修改实时会话完全没有落库打手和用户之间只能靠加微信联系出了纠纷平台拿不出任何有效记录来仲裁。我最近正好完整落地了一套基于Java的“游戏打手陪玩护航服务”系统从订单撮合、实时状态流转、护航会话管理到争议处理全部走了一遍。这篇文章我打算把整套系统的设计思路、核心源码逻辑、高并发抢单的分配策略以及我在压测和上线过程中踩过的坑都摊开来讲清楚。如果你正在做陪玩平台、服务撮合类产品或者单纯想看看一个“看起来很简单”的业务在Java技术栈下能挖出哪些深度问题这篇文章应该能给你不少参考。1. 护航服务的业务本质为什么它不能当“普通订单系统”来做先花点篇幅把业务本身掰开揉碎。护航服务听起来是个游戏圈的黑话实际场景是用户在某款竞技游戏里想上分但自身段位和技术有限于是付费请高段位的“打手”陪自己组队由打手在游戏内带领节奏、保证胜率用户获得游戏体验的提升。这套服务在电商视角下似乎就是“下单-接单-完成-结算”但真要把体验做好底层逻辑完全不一样。1.1 普通订单模型在护航场景下的三大断裂点第一状态实时性要求完全不同。普通电商订单一天变一两次状态没问题护航订单从匹配到开局到结算中间还有“等待打手确认进入游戏”“对局进行中”“对局结束待确认”等多个中间态用户需要实时感知就像打车App里看司机到没到、车走到哪了一样的强烈实时诉求。第二订单和会话强绑定。普通商品发完货订单就基本结束了但护航订单的核心交付过程是“打手和用户在游戏内外持续互动的那段时间”这段会话的每一步都应该是留存数据。一旦后续产生“打手根本没好好打”“用户中途恶意取消”这类纠纷平台必须能拿出可审计的会话轨迹。第三双边撮合的高并发瞬时性。一个用户需求发布出去可能同时有几十上百个打手在抢而一个订单只能被一个打手成功接下这个“唯一成功”的实时抢单逻辑对系统的并发控制、原子性保障要求比普通电商下单要高很多。电商里库存只要不减扣失败就行这里则是所有抢单者只能有一个成功的“全局唯一”约束。1.2 核心角色与业务闭环整个系统里有三类角色用户下单方发布护航需求选择游戏、模式、段位区间支付费用。打手服务方抢单、开启护航会话、上报每次对局结果。平台运营管理方处理争议、冻结/解冻结算资金、记录双方评价。核心闭环是用户创建订单→订单进入可抢池→打手抢单成功→双方建立护航会话→打手上报每局结果胜/负/掉线/挂机→用户确认或发起争议→结算打手收益。为了让这个闭环在代码层面不失控我花了很大精力在“状态机”和“会话轨迹”上这也是整篇文章最值得Java开发们细看的部分。2. 技术选型与整体架构Netty长连接撑起实时交互先交代一下最终的技术组合基础框架Spring Boot 2.7 MyBatis Plus存储用MySQL 8.0订单/用户/结算明细 Redis 6.x抢单队列、在线状态、幂等标识实时通信层用Netty自建长连接服务网关层用Spring Cloud Gateway做路由和鉴权。没有引入太重的中间件Kafka都没用因为业务体量在初期没必要但架构上提前留好了扩展位。2.1 为什么实时层不直接用WebSocket而是上了Netty很多人会问既然要实时通信直接用WebSocket不就行了我在早期原型阶段确实先用了WebSocket但很快在压测阶段发现问题——当长连接数量上来之后WebSocket在连接管理、心跳策略、协议扩展上的灵活性都不够而且它和业务代码耦得太深想单独把“会话服务”沉淀成可复用能力很难。Netty的好处是能自己掌控连接生命周期自定义协议头把“业务心跳”和“对局状态上报”两个数据通道彻底分开。我最终的协议设计是一个轻量二进制头魔数版本消息类型消息ID长度 JSON体。魔数用来快速过滤非法连接消息ID全局唯一方便做幂等和链路追踪这在整个护航会话数据审计里帮了大忙。2.2 模块划分不要让实时通信把业务拖垮整套代码我拆成了四个核心服务订单服务order-service订单创建、状态流转、结算触发。匹配服务match-service抢单池、打手筛选排序、队列分发。会话服务session-serviceNetty长连接、会话轨迹记录、局内状态上报。用户服务user-service账号、钱包、打手资质审核。服务间通过OpenFeign同步调用RabbitMQ异步事件解耦比如订单状态变更之后要通知会话服务开启护航会话就是靠事件驱动的不搞同步强依赖。有一点我必须强调——实时通信层最忌讳把业务逻辑直接写死在Netty的ChannelHandler里我们所有从连接收到的消息都先转成内部事件扔进Disruptor队列再由业务线程池消费Netty的IO线程池只负责编解码和收发保持绝对轻量。2.3 核心数据模型设计简版数据库表设计是整个项目的基石这里列最核心的三张表结构。订单表escort_order字段类型说明idbigint订单号雪花算法user_idbigint下单用户gamer_idbigint抢单打手初始为空game_typevarchar游戏类型rank_levelvarchar目标段位区间order_statustinyint状态机枚举值versionint乐观锁版本号create_timedatetime创建时间会话轨迹表escort_session_log字段类型说明idbigint主键order_idbigint关联订单session_idvarchar会话IDaction_typevarchar动作类型开局/胜/负/掉线/申诉contentvarchar动作描述create_timedatetime发生时间打手钱包流水表escort_gamer_wallet_log就不多展开了无非是冻结、入账、退款几个流水类型注意流水必须有唯一幂等键否则结算环节一定出乱子。3. 护航状态机从“可抢”到“已完结”的每一步都留痕状态机是我这次项目最核心的部分也是最容易被人忽略的部分。很多半路出家的团队做这类系统订单状态就写几个常量到处散落着if (order.getStatus() 1)这种硬编码判断改一个状态要全局搜索三小时。我用了一套显式的状态机引擎把状态流转集中管理所有非法流转直接抛异常从根上杜绝状态乱跳。3.1 状态定义与流转路径护航订单的完整生命周期如下PENDING待支付用户下但还没付钱。WAIT_MATCH可抢支付成功进入抢单池。MATCHED已匹配打手抢单成功等待双方就绪。IN_PROGRESS护航中打手确认开局对局进行中。FINISHED已完成订单确认完成进入结算。CANCELED已取消用户支付前取消。REFUNDING退款中出现争议或打手未履约走退款流程。流转路径上我用一个枚举定义所有合法性判断核心代码如下public enum OrderStatus { PENDING(0, 待支付), WAIT_MATCH(1, 可抢), MATCHED(2, 已匹配), IN_PROGRESS(3, 护航中), FINISHED(4, 已完成), CANCELED(5, 已取消), REFUNDING(6, 退款中); private final int code; private final String desc; private static final MapInteger, SetInteger ALLOWED_TRANSITIONS new HashMap(); static { ALLOWED_TRANSITIONS.put(PENDING.code, Set.of(WAIT_MATCH.code, CANCELED.code)); ALLOWED_TRANSITIONS.put(WAIT_MATCH.code, Set.of(MATCHED.code, CANCELED.code)); ALLOWED_TRANSITIONS.put(MATCHED.code, Set.of(IN_PROGRESS.code, REFUNDING.code, CANCELED.code)); ALLOWED_TRANSITIONS.put(IN_PROGRESS.code, Set.of(FINISHED.code, REFUNDING.code)); ALLOWED_TRANSITIONS.put(REFUNDING.code, Set.of(CANCELED.code, FINISHED.code)); } public static boolean canTransition(int from, int to) { SetInteger allowed ALLOWED_TRANSITIONS.get(from); return allowed ! null allowed.contains(to); } }3.2 状态流转服务乐观锁是底线保障光有枚举还不够接口层还必须处理并发。我的做法是状态流转统一走OrderStateMachineService更新SQL强制带version乐观锁条件更新行数为0说明已经有别的线程抢先改了状态直接返回冲突异常。Transactional(rollbackFor Exception.class) public void transition(Long orderId, int fromStatus, int toStatus, String operator) { EscortOrder order orderMapper.selectById(orderId); if (order null) { throw new BizException(订单不存在); } if (!OrderStatus.canTransition(fromStatus, toStatus)) { throw new BizException(非法状态流转: fromStatus - toStatus); } int rows orderMapper.updateStatusWithVersion(orderId, fromStatus, toStatus, order.getVersion()); if (rows 0) { throw new BizException(操作过于频繁请刷新后重试); } // 状态流转成功后记录审计轨迹 sessionLogRepository.save(new SessionLog(orderId, ADMIN_ACTION, 状态变更: fromStatus - toStatus, operator)); }updateStatusWithVersion对应的SQL是UPDATE escort_order SET order_status #{toStatus}, version version 1 WHERE id #{orderId} AND order_status #{fromStatus} AND version #{version}这套设计的价值在线上第一次出现纠纷的时候就体现出来了。用户发起退款申请理由是“打手只打了一把就跑了”而打手声称自己完成了约定局数。运营后台能直接调出这个订单的完整状态流转轨迹——什么时候匹配成功、什么时候开局、上报过几局结果、什么时候断连每一环都有日志记录谁在撒谎一目了然。3.3 护航会话的实时状态处理订单状态机解决的是宏观生命周期问题但护航过程中打手会不断上报局内事件比如开局成功、本局胜利、本局失败、队友挂机、遇到对手队伍等。这些事件的处理如果不加控制就会出现“订单已经FINISHED了打手的上报消息还在往库里写”的错乱。我的做法是所有会话事件先走Redis的会话状态校验再异步落库。具体逻辑是在Redis里以session:{orderId}为key维护一个HSET里面存当前会话状态和局数统计。每次打手上报事件时先用Lua脚本原子地校验当前状态是否允许该上报动作防止网络延迟导致乱序事件把会话搞乱了。-- 会话事件上报校验脚本 local state redis.call(HGET, KEYS[1], state) if state IN_PROGRESS or state MATCHED then return redis.call(HINCRBY, KEYS[1], ARGV[1], 1) else return -1 end这套机制上线之后再也没出现过“已经结算完的订单还能写入战绩”这类脏数据问题。4. 高并发抢单一个订单只能被一个人抢到的工程实现抢单是整个系统里技术挑战最密集的环节。场景描述起来很简单用户付完钱订单进入WAIT_MATCH状态成百上千个打手在App上刷单谁手快谁接单。但技术上要做到“同一时刻大量并发请求仅一人成功”并且还必须解决重复点击、网络重试、超时补偿等一系列问题。4.1 基于Redis Lua的抢单原子操作最开始有人提方案说用数据库的SELECT ... FOR UPDATE行锁来实现抢单。我直接否了——MySQL行锁在高并发下扛不住而且如果持有连接时间过长比如抢单后还要做打手资质校验、钱包冻结数据库连接池分分钟被拖垮。最终方案是基于Redis的原子抢单。核心思想是每个可抢订单在Redis里维护一个抢单标识打手抢单时执行一个Lua脚本脚本内完成“读状态→判断可抢→写入打手ID”这一系列动作由于Lua脚本在Redis中是原子执行的所以不会存在两个打手同时读到一个“可抢”状态的情况。-- 抢单核心脚本 if redis.call(EXISTS, KEYS[1]) 0 then return 0 end local orderInfo redis.call(HGETALL, KEYS[1]) local status orderInfo[2] -- 假设字段顺序为: status, gamerId if status ~ 1 then return -1 -- 订单不可抢 end redis.call(HSET, KEYS[1], gamerId, ARGV[1]) redis.call(HSET, KEYS[1], status, 2) -- 置为已匹配 return 1Lua脚本执行成功返回1后再异步把结果落库。这里要注意一个重要细节Redis里的状态变更和MySQL的最终一致性问题。我的方案是先在Redis完成抢占再异步发送MQ消息去更新数据库订单状态同时保留定时任务做对账扫描那些“Redis里显示已抢成功但数据库还是可抢状态”的订单自动补偿更新。4.2 抢单后的防重复处理和幂等设计抢单还有一个隐蔽的坑是网络重试。打手端的App收到超时响应后通常会自动重试但第一次调用其实已经成功了这时候如果不做幂等控制就会出现一个打手重复抢到单的幻觉。我的做法是每个抢单请求必须带上一个由客户端生成的requestIdUUID服务端在Redis里用SETNX request:{requestId}做幂等标识设置5秒过期。同一requestId的重复请求直接返回第一次的执行结果不会触发第二次抢单逻辑。还有一个值得注意的边界是“打手自己重复抢同一单”。在抢单Lua脚本里我额外加了一个条件——如果当前订单的gamerId已经是这个打手则直接返回“已抢到”不再重复执行抢占逻辑。这避免了打手断网重连后以为没抢到又重新点了一次结果被判定非法抢单的尴尬。4.3 智能匹配策略的取舍最早版本我是纯“先到先得”谁手快谁赢。但产品经理很快提出一个需求能不能让高段位、高评价、低接单率的打手优先获得订单而不是每次都拼手速后来我在抢单流程里加了一个权重评分逻辑打手抢单时不再直接触发抢占而是先写入一个ZSET有序集合score 打手综合评分然后由匹配服务每秒从ZSET里取出最高分打手进行“确认抢占”。ZSET 定时驱逐机制的组合可以让优质打手获得更多订单而且不会损失“抢单”的互动体验感。这套逻辑要让用户看不出“被分配”的痕迹我用的方案是打手抢单后前端先显示“已进入排队”其实打手自己是无法感知排序的。1秒后如果抢到了展示“抢单成功”如果被别人抢走展示“手慢了”。体验上完全保留了抢单的紧张感但实际分配是按权重来的。4.4 压测数据Redis方案和DB方案对比为了让大家直观看到差距放一组我本地压测的数据。模拟环境一台4核8G服务器1000个打手同时抢10个订单。方案QPS峰值平均响应时间抢单成功率数据一致性MySQL行锁方案约400890ms98.2%强一致Redis Lua方案约780023ms99.96%最终一致对账修正Redis Lua 智能权重约750031ms99.92%最终一致对账修正这组数据足以说明问题。对抢单这种场景来说Redis方案不仅在性能上碾压在用户体验上也更平滑哪怕极端情况下Redis宕机导致数据回退最多也就是让打手重新抢一次配合MQ对账兜底业务损失可控。5. 护航会话里的实时通信细节与踩坑记录写到这里整篇文章最硬核的部分要来了。实时通信这一层我做的时候真的是踩坑无数有些坑如果不写出来后来者恐怕要白费好几天功夫。这一节专门把这几个问题的完整排查过程和根因讲清楚。5.1 坑一Netty业务线程池被慢SQL拖垮导致全连接超时现象上线第三天运营反馈说所有打手端集体断连重连也连不上服务端日志里全是ChannelHandler execution rejected异常。排查过程我先看Netty的EventLoop线程数发现IO线程占用率不高基本排除网络层问题。接着看业务线程池发现自定义的ThreadPoolTaskExecutor队列长度爆满几乎所有线程都卡在sessionLogRepository.save()这个数据库调用上。继续深挖MySQL发现慢查询日志里有一条SQL扫描行数超过200万——就是会话轨迹表的查询没有走索引。根因会话轨迹表初始没有加(order_id, create_time)联合索引导致每次写轨迹时触发的唯一性检查都全表扫。而所有打手上报的会话事件都走同一个线程池一旦数据库操作变慢整个实时链路全部阻塞。修复方案给会话轨迹表加上联合索引同时把业务线程池改成“独立线程池拒绝降级”模式——即使数据库慢也只影响持久化操作不影响Netty收发和消息转发。另外我加了逻辑会话日志写库失败时先写入本地内存队列由单独线程异步重试不让一条日志的丢失拖垮整个护航会话。5.2 坑二连接假死——打手App切后台服务端不知道对方已经掉线现象有用户投诉说打手开局后突然“消失”游戏里挂机但服务端显示打手一直是“在线”状态后续结算也无从谈起。排查过程查了Netty的IdleStateHandler配置发现设置了读空闲15秒超时理论上应该能踢掉假死连接。但看线上服务很多连接明明已经收不到心跳了却没有触发超时关闭。进一步排查发现部分打手手机在大流量内网环境下TCP半开连接偶尔能接收到对端发来的ACK包读空闲判定被重置导致连接永远不超时。根因单靠服务端被动读空闲检测不够需要“双向心跳”机制。我重新设计了一套心跳协议客户端每5秒发送一个Ping包服务端12秒内没收到Ping包则判定为假死连接主动发起一次探测Pong再没响应就直接断开。这套逻辑看起来简单但有效解决了移动端网络切换导致的假死问题。修复后的Netty心跳配置关键代码如下ch.pipeline() .addLast(new IdleStateHandler(12, 0, 0, TimeUnit.SECONDS)) .addLast(new HeartbeatHandler());然后在HeartbeatHandler的userEventTriggered中判断空闲超时后先向客户端发送一个主动探测包如果发消息失败或者超过3秒没有Pong响应就强制关闭连接。5.3 坑三分布式锁过期导致打手重复接单现象某天运营后台发现一笔订单居然有两个打手都收到了“接单成功”的推送用户端看到有两个打手同时在打招呼。排查过程查日志发现这是智能匹配流程里的问题。打手确认抢占时走的是Redis分布式锁锁设置的过期时间5秒。但那位打手的客户端在确认抢占后因为要做打手资质校验、钱包余额冻结、订阅状态检查这一串操作总耗时超过5秒锁自动过期释放了。另一个打手进来后重新获取到锁也执行了抢占确认于是两个人同时成功。根因分布式锁的过期时间设置不当业务执行时间超过锁持有时间导致锁提前失效。修复方案双重保障。第一把锁的过期时间动态延长核心逻辑是“看门狗”机制——锁获取成功后启动一个守护线程每3秒检查一次业务是否还在执行如果还在执行就自动续期到新的过期时间业务结束再显式释放锁。第二就算锁真的过期了数据库层面还有唯一索引兜底——在订单表上加uk_gamer_order(gamer_id, order_id)唯一约束或者单独建一张“订单-打手绑定关系表”加唯一索引让重复接单在数据库层面彻底死亡。这套兜底上线后我又专门模拟了极端场景锁过期网络超时重复推送来测试确认任何情况下都不可能产生两个有效接单记录。5.4 会话轨迹数据量增长太快归档策略要提前设计护航会话日志是典型的写多读少数据每局对局要上报战绩、事件、心跳一天轻松上千万条。如果不做分表归档三个月后MySQL的磁盘空间就会爆炸。我的方案是会话日志表按月份分表escort_session_log_202401、escort_session_log_202402这样按月命名。写入时通过sharding-jdbc自动路由到当前月份的表每个月月初由定时任务自动建新表同时把三个月前的旧表转存到冷存储我用的是OSS归档存储。查询时按订单时间路由到对应月份的表不会影响实时会话记录的查询性能。这里有个代码层面的小技巧分表之后MyBatis Plus自带的selectById就不好用了需要自己改造成根据订单时间算出目标表名的方式。我在DAO层写了一个SessionLogRepository统一封装内部根据createTime做月份计算后映射到正确的Mapper业务调用方完全感知不到分表逻辑。6. 护航会话中断与纠纷处理给异常场景做好“技术预案”技术系统做得好不好不是看正常流程跑得有多顺而是看异常场景兜得有多稳。护航服务最大的业务风险就是纠纷处理纠纷的依据就是数据。这一节专门聊异常场景的技术预案。6.1 掉线重连后的会话恢复打手在护航过程中掉线是常态但用户的游戏局还在继续。如果打手重连后不能快速恢复会话上下文用户就会觉得这个平台特别不靠谱。我的设计是所有护航会话的上下文信息当前局数、订单状态、打手信息、上报统计全部冗余在Redis中keysession:{orderId}TTL设置为4小时。打手掉线重连后客户端重新建立长连接发一个RECOVER_SESSION消息服务端直接把Redis里的上下文整体推送给打手端打手App立刻恢复护航界面用户端也同步收到“打手已重连”的提示。这里有体验细节的优化重连后我会把重连事件本身也写入会话轨迹表后续发生争议时掉线几次、什么时候重连的每一笔都清清楚楚。真实发生过的场景是打手掉线后用户以“打手不专业”为由要求退款。平台介入查看轨迹发现打手掉线两次且每次都在15秒内恢复最终判定打手无责订单正常结算用户端给出了一次全额体验券作安抚。这个仲裁过程没有轨迹记录是做不出来的。6.2 争议仲裁的“时光机”能力纠纷是运营侧最头疼的问题。为了给运营提供足够的技术支撑我专门做了一个“订单时光机”后台功能——输入订单号就能把订单所有状态流转、事件上报、双方在线时长、IP定位等数据按时间线展示出来。实现上依赖三类数据订单状态机的变更记录escort_order_log表。护航会话轨迹escort_session_log按月分表。打手上下线心跳记录Redis中每小时落一份快照到日志系统。这三类数据组合起来运营就能清晰地看到打手在12:00接单12:05确认开局12:10第一次上报战绩13:20掉线一次13:22重连……每一个细节都有数据支撑用户投诉时平台不再处于“你说你的、我说我的”的被动状态。6.3 极端场景下的一致性兜底最后提一下系统级的一致性兜底。Redis抢单状态和MySQL订单状态是最终一致但极端情况比如Redis宕机、MQ消息丢失下怎么保证不出现“钱花了却没单可玩”的恶性事件我的方案是“对账补偿服务”每5分钟扫描一次所有异常状态订单比对Redis和MySQL的数据。如果发现“MySQL订单是可抢状态但Redis里没有对应的抢单Key”说明Redis里的抢单数据丢失了直接把订单重新放入抢单池让打手重新抢一次。如果发现“Redis里显示已匹配但MySQL还是可抢状态”就主动触发一次数据库状态修正。这套对账逻辑代码量不大但价值非常大——它把分布式系统的“黑洞”风险降到了可以接受的范围。7. 上线前后的思考与建议整个项目从需求评审到上线前后花了六周核心代码量不算大但业务逻辑的复杂度远超预期。这里把我在这个过程中沉淀下来的一些思考和经验分享出来算是给后来人提个醒。7.1 状态机一定要前置设计不要边做边改如果你接手一个类似项目我建议把状态机设计放在第一周并且用代码 文档双份表达。状态机设计清楚后所有业务接口的边界都会自然浮现团队沟通效率也会高很多。最怕的是“状态后面再加”那会引发一连串的NPE和非法流转问题改到怀疑人生。7.2 实时通信层和业务层必须物理隔离Netty的IO线程池里绝对不能执行任何数据库操作和远程调用哪怕只是查一下缓存也建议放到独立线程池里处理。这一点怎么强调都不过分。IO线程池一旦被阻塞整个服务的连接都会被拖垮掉线率会迅速蔓延到全部在线用户。7.3 抢单逻辑不要一味追求“先到先得”从产品体验来说纯拼手速会让高价值打手流失因为他们单量太多但单价上不去。从技术角度来说加入智能权重排序后系统的复杂度会增加不少需要维护ZSET、定时驱逐、防作弊等但从业务长期健康度来看完全值得。具体做不做取决于你的平台是走量还是走质。7.4 数据留痕是平台的护城河我在这个项目中最大的体会是所有涉及双边交易、实时服务的互联网平台都必须把“数据留痕”当核心能力来建设而不是当附带功能。每一次状态变更、每一局战绩上报、每一次连接断开都要留下可审计的痕迹。这不仅是为了纠纷处理更是为了让用户和打手双方都感受到平台的公平与可靠。现在回想起来虽然只有六周时间但护航项目让我把并发控制、状态机、实时通信、数据一致性这些Java后端领域的核心知识点全部在一个真实业务里串起来了。代码一共写了不到2万行但每一行背后都有值得复盘的理由。如果你也在做这类业务希望这篇文章能帮你少走一些弯路。最后再分享一个小技巧如果你们团队后续要在“实时互动”上继续加深建议把Netty那层抽象成独立的基础组件服务不要再和具体业务绑定这样以后做直播互动、实时语音房间都能直接复用这套长连接能力。
返回列表