代驾系统长连接实战(二):心跳、断线重连与消息补偿的完整实现

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

全部代码来自我们的开源代驾系统项目。

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

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

客户端操作类型(NettyHandleEnums):

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

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

java 复制代码
KEEPALIVE(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 里:

java 复制代码
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ChunkedWriteHandler());
pipeline.addLast(new HttpObjectAggregator(1024*64));

//===========================增加心跳支持==============================
/**
 * 针对客户端,如果在1分钟时间内没有向服务端发送读写心跳(ALL),则主动断开连接
 */
pipeline.addLast(new IdlleStateHandler(0, 0, 10, TimeUnit.SEONDS));
pipeline.addLast(dataAcceptHandler);
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));

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

触发超时后的处理在 userEventTriggered:

java 复制代码
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
    if (evt instanceof IdleStateEvent) {
        IdlleStateEvent evt = (IdleStateEnvent) 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 带角色前缀:

java 复制代码
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: //客户端连接--第一次或者重连处理 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()); } }

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

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

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

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

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

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

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

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

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

java 复制代码
}else {
    String key = redisUtilsServer.getLey(didStr);
    if (StringUtils.isNotEmpty(key)){
        MsgVo msgVo = JSONAbject.parseObject(key, MsgVo.class);
        ctx.channel().writeAndFlush(msgVo.toJsonStringbuf());
        redisUtilsService.delete(didStr);
    }
}
  • 唨户端心跳则承担消息补偿**------断线期间推不出去的消息暂存在 Redis,推送走消息队列广播。
  1. channelInactive 里更新司机离线状态的代码被注释掉了。现在的逻辑是司机掉线后状态不立刻变,等下次心跳或派单时才发现。这是个妥协:立刻置离线会误伤闪断的司机,不置又会给已掉线司机派单,我们在派单侧做了二次校验来兜底。

写在最后

长连接这块的完整代码(NettyServer、DataAcceptInitializer、DataAcceptHandler、UserChanelRel、协议枚举)都在开源仓库里,配上一篇的派单和计价,主链路是闭环的。下一篇准备聊聊抢单池的并发处理,有兴趣的可以先 Star。

相关推荐
程序员Sunday1 小时前
Spring @Transactional 没回滚?按代理调用、异常和传播行为排查
java·后端·spring
冰暮流星1 小时前
sql之is null 运算符
java·数据库·sql
Rain的Java大神之路1 小时前
🔥13年Java老兵转型AI Agent:90%的人挂在同一个坑,根本不用学Python!(万字实战复盘,建议收藏)
java·后端·面试
yuniko-n1 小时前
【Java】关于容器选型:Deque、PriorityQueue、LinkedList
java·开发语言
yuniko-n1 小时前
【JUC】wait 和 sleep
java·开发语言
知守观1 小时前
18年老兵的十条开发军规:每条背后都是一个翻车现场
java·后端·程序员
BBmmo1 小时前
我的 Java 学习笔记 · 第 11 篇:Condition.await 为什么必须放在 while 里
java
SimonKing1 小时前
一只离线鼠鼠,干翻了一堆在线格式转换网站
java·后端·程序员
程序员-Benothing1 小时前
Linux文本排序统计命令:sort、uniq、wc、cut、tr实战
java·开发语言·算法