☰
代驾系统长连接实战(二):心跳、断线重连与消息补偿的完整实现
2026/10/11 23:19:11 网站建设 项目流程

上一篇聊了代驾系统的整体架构和三级派单,这篇往下钻一层:司机端和乘客端挂在 Netty 长连接上,手机进电梯、切后台、弱网闪断是家常便饭,连接断了之后怎么发现、怎么重连、断线期间的消息怎么不丢,这套机制才是派单链路真正能用起来的地基。

全部代码来自我们开源的代驾系统,仓库地址放在文末。

一、协议设计:两个枚举定生死

长连接第一件事不是写代码,是定协议。我们把"客户端要干什么"和"服务端回了什么"拆成两个枚举。

客户端操作类型(NettyHandleEnums):

  • CONNECT(1):第一次连接或重连时的初始化
  • KEEPALIVE(2):心跳保活
  • COORDINATE_DRIVER(3):司机坐标上报

服务端返回码(NettyCodeEnums)截取几个关键的:

KEEPALIVE(1,"心跳正常"),KEEPALIVE_ERROR(-1,"心跳异常"),CONNECT(2,"第一次连接正常"),CONNECT_ERROR(-2,"第一次连接异常"),DRIVER_UNFINISHED_ORDER` 和 `ORDER_MOVE` 这两个码——它们存在的意义就是**重连恢复**,后面会讲到。 ## 二、服务端 pipeline:10秒空闲就踢Netty的 pipeline 配置在 `DataAcceptInitializer` 里: ```javaChannelPipelinepipeline=socketChannel.pipeline();pipeline.addLast(newHttpServerCodec());pipeline.addLast(newChunkedWriteHandler());pipeline.addLast(newHttpObjectAggregator(1024*64));//===========================增加心跳支持==============================/** * 针对客户端,如果在1分钟时间内没有向服务端发送读写心跳(ALL),则主动断开连接 */pipeline.addLast(newIdleStateHandler(0,0,10,TimeUnit.SECONDS));pipeline.addLast(dataAcceptHandler);pipeline.addLast(newWebSocketServerProtocolHandler("/ws"));

IdleStateHandler(0, 0, 10)意思是读空闲不查、写空闲不查,但读+写加起来 10 秒没有任何动静就触发 ALL_IDLE 事件。客户端心跳间隔必须小于这个值,实际 App 端是几秒一次。

触发超时后的处理在userEventTriggered:

public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; if (event.state() == IdleState.ALL_IDLE) { log.info("===客户端===(ALL_IDLE 总超时 重新连接)"); } } ctx.channel().writeAndFlush(new MsgVo(NettyCodeEnums.SOCKET_TIME_OUT).toJsonStringbuf()); channelInactive(ctx);//清除netty通道 }

先给客户端推一个SOCKET_TIME_OUT(101)(万一它还活着,知道自己被踢了、立刻重连),然后进channelInactive清理通道。

三、CONNECT:重连不只是重连,是状态恢复

连接管理用的是个单机 HashMap,key 带角色前缀:

public class UserChanelRel { private static HashMap<String, Channel> manage = new HashMap<>(); // key 前缀区分角色:driver:{id} / user:{id} public static void put(String senderId, Channel channel){ manage.put(senderId, channel); } }

CONNECT 懆支的核心逻辑(DataAcceptHandler):

case CONNECT: //客户端连接--第一次或者重连处理 if (chanelContext.equals("CONNECT")) { //每次连接或者重连时,把连接存入连接池中 UserChanelRel.put(didStr, ctx.channel()); channel.writeAndFlush(new MsgVo(NettyCodeEnums.CONNECT).toJsonStringbuf()); if (cal.isEquals(channelIdentity, StaticUtils.STATUS_YES)){ //校验司机是否处于服务状态 OrderMain orderMain = orderMainService.selectOrderByDriver(did); if (orderMain != null) { //反馈司机有未完成订单 MsgVo msgVo = new MsgVo(NettyCodeEnums.DRIVER_UNFINISHED_ORDER); msgVo.setOrderCode(orderMain.getOrderCode()); channel.writeAndFlush(msgVo.toJsonStringbuf()); } }

这里的重点是:重连成功不等于完事。司机端重连后,服务端要查他名下有没有未完成订单,有就推DRIVER_UNFINISHED_ORDER,App 收到后把订单界面恢复出来。用户端更细——如果重连时带着 orderCode,且订单正在行驶中,服务端会把里程、等待时长、费用等完整状态通过ORDER_MOVE推回去,用户端直接接着断线前的界面渲染。

这是踩过坑才明白的:只恢复连接不恢复状态,用户看到的就是一个"回到首页"的 App,订单凭空消失,投诉就是这么来的。

四、KEEPALIVE:心跳顺手干三件正事

心跳包不只是保活,我们的心跳分支顺手做了三件事:

caseKEEPALIVE://1. 心跳包超过350字节判定为非法,直接断开if(length>350){ctx.channel().writeAndFlush(newMsgVo(NettyCodeEnums.SOCKET_ERROR).toJsonStringbuf());channelInactive(ctx);UserChanelRel.delChannel(didStr);}//2. 没走过CONNECT的心跳请求,移除Channelchannel1=UserChanelRel.get(didStr);if(channel1==null){ctx.writeAndFlush(newMsgVo(NettyCodeEnums.SOCKET_ERROR).toJsonStringbuf());channelInactive(ctx);}

先做两道安全校验:包太大(>350 字节)的是恶意流量,踢;没经过 CONNECT 注册就发心跳的,也是非法连接,踢。

然后分角色处理。司机端心跳里带着经纬度,所以心跳 = 位置上报,一举两得:

if(cal.isEquals(channelIdentity,StaticUtils.STATUS_YES)){//更新司机位置DDriverdriver=dDriverService.getById(did);driver.setLonVal(requestVo.getLongitude());driver.setLatVal(requestVo.getLatitude());driver.setAddress(requestVo.getAddress());//异常状态的司机,名下没单则恢复为可接单Integerstate=driver.getState();if(cal.isEquals(state,StaticUtils.STATUS_SUCCESS)||cal.isEquals(state,StaticUtils.STATUS_FAIL)){OrderMainorderMain=orderMainService.selectOrderByDriver(did);if(orderMain==null){driver.setState(StaticUtils.STATUS_YES);}}dDriverService.updateById(driver);//回推最新司机状态,让App刷新接单开关MsgVomsgVo=newMsgVo(NettyCodeEnums.DRIVER_STATUS_RESET);msgVo.setDriverStatus(driver.getState());ctx.channel().writeAndFlush(msgVo.toJsonStringbuf());}

用户端心跳则承担消息补偿——断线期间推不出去的消息暂存在 Redis,心跳一上来就补发:

}else{Stringkey=redisUtilsService.getKey(didStr);if(StringUtils.isNotEmpty(key)){MsgVomsgVo=JSONObject.parseObjedt(key,MsgVo.class);ctx.channel().writeAndFlush(msgVo.toJsonStringbuf());redisUtilsService.delete(didStr);}}

五、坦白几个不足

这套东西能跑,但放在更大规模下有几个明确的坑:

  1. 10 秒的 ALL_IDLE 太短。弱网环境下 TCP 重传都不止 10 秒,容易误杀。注释里能看到最早的版本是 120 秒,后来调小了,这个值应该按客户端心跳间隔的 3 倍以上来配。
  2. UserChanelRel 是单机 HashMap。多实例部署时,A 机器上的连接,B 机器查不到,派单推送会丢。正确做法是连接关系存 Redis,推送走消息队列广播。
  3. channelInactive 里更新司机离线状态的代码被注释掉了。现在的逻辑是司机掉线后状态不立刻变,等下次心跳或派单时才发现。这是个妥协:立刻置离线会误伤闪断的司机,不置又会给已掉线司机派单,我们在派单侧做了二次校验来兜底。

写在最后

长连接这块的完整代码(NettyServer、DataAcceptInitializer、DataAcceptHandler、UserChanelRel、协议枚举)都在开源仓库里,配上一篇的派单和计价,主链路是闭环的。Gitee 地址:

https://gitee.com/zhoujian6666/biaoma-ride-car-service

协议以仓库 LICENSE 为准。下一篇准备聊聊抢单池的并发处理,有兴趣的可以先 Star。
上一篇聊了代驾系统的整体架构和三级派单,这篇往下钻一层:司机端和乘客端挂在 Netty 长连接上,手机进电梯、切后台、弱网闪断是家常便饭,连接断了之后怎么发现、怎么重连、断线期间的消息怎么不丢,这套机制才是派单链路真正能用起来的地基。全部代码来自我们开源的代驾系统,仓库地址放在文末。## 一、协议设计:两个枚举定生死长连接第一件事不是写代码,是定协议。我们把"客户端要干什么"和"服务端回了什么"拆成两个枚举。客户端操作类型(NettyHandleEnums):- CONNECT(1):第一次连接或重连时的初始化- KEEPALIVE(2):心跳保活- COORDINATE_DRIVER(3):司机坐标上报服务端返回码(NettyCodeEnums)截取几个关键的:javaKEEPALIVE(1,"心跳正常"),KEEPALIVE_ERROR(-1,"心跳异常"),CONNECT(2,"第一次连接正常"),CONNECT_ERROR(-2,"第一次连接异常"),DRIVER_UNFINISHED_ORDER(29,"司机有未完成订单"),DRIVER_STATUS_RESET(30,"刷新司机状态"),ORDER_MOVE(100,"订单行驶中"),SOCKET_TIME_OUT(101,"读写超时"),LOGIN_ERROR(997,"账号登录失效")注意 DRIVER_UNFINISHED_ORDER 和 ORDER_MOVE 这两个码——它们存在的意义就是重连恢复,后面会讲到。## 二、服务端 pipeline:10 秒空闲就踢Netty 的 pipeline 配置在 DataAcceptInitializer 里:javaChannelPipeline pipeline = socketChannel.pipeline();pipeline.addLast(new HttpServerCodec());pipeline.addLast(new ChunkedWriteHandler());pipeline.addLast(new HttpObjectAggregator(1024*64));//===========================增加心跳支持==============================/** * 针对客户端,如果在1分钟时间内没有向服务端发送读写心跳(ALL),则主动断开连接 */pipeline.addLast(new IdleStateHandler(0, 0, 10, TimeUnit.SECONDS));pipeline.addLast(dataAcceptHandler);pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));IdleStateHandler(0, 0, 10) 意思是读空闲不查、写空闲不查,但读+写加起来 10 秒没有任何动静就触发 ALL_IDLE 事件。客户端心跳间隔必须小于这个值,实际 App 端是几秒一次。触发超时后的处理在 userEventTriggered:javapublic void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; if (event.state() == IdleState.ALL_IDLE) { log.info("===客户端===(ALL_IDLE 总超时 重新连接)"); } } ctx.channel().writeAndFlush(new MsgVo(NettyCodeEnums.SOCKET_TIME_OUT).toJsonStringbuf()); channelInactive(ctx);//清除netty通道}先给客户端推一个 SOCKET_TIME_OUT(101)(万一它还活着,知道自己被踢了、立刻重连),然后进 channelInactive 清理通道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询