
最近把之前做的一套代驾服务系统整理开源了涵盖用户下单、司机抢单、实时定位、计价结算、在线支付的完整闭环。这篇不讲 PPT 架构直接挑三个真正花了时间的难点结合源码聊聊是怎么落地的、以及现在回看有哪些坑。开源地址放在文末欢迎 Star / Issue / PR。一、整体架构前后端分离后端按职责拆成两个可独立部署的 API 服务 一个公共模块biaoma-cloud-app-api用户 / 司机端接口 Netty 长连接网关biaoma-cloud-admin-api运营管理后台接口biaoma-cloud-common实体、Service、工具、支付/消息等公共能力管理后台Vue2 Element UI ECharts主要技术栈能力选型框架Spring Boot 2.7 MyBatis-PlusJDK 17实时通信NettyWebSocket / TCP 长连接缓存 / 在线状态Redis异步解耦RabbitMQ抢单等异步消息地图高德地图定位、距离、轨迹支付微信支付 支付宝数据库MySQL 5.7二、难点一基于 Netty 的长连接网关代驾场景里派单、抢单、司机坐标上报、语音播报都要求毫秒级到达HTTP 轮询扛不住所以单独起了一个 Netty 服务。服务端启动用主从 Reactor 模型端口走配置配合 Spring 生命周期做优雅停机ComponentpublicclassNettyServer{Value(${netty.port})privateIntegerport;privatefinalEventLoopGroupbossGroupnewNioEventLoopGroup(Runtime.getRuntime().availableProcessors()*2);privatefinalEventLoopGroupworkerGroupnewNioEventLoopGroup(1000);publicvoidinit(){ServerBootstrapbootstrapnewServerBootstrap();bootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).option(ChannelOption.SO_BACKLOG,1024*1024).childOption(ChannelOption.TCP_NODELAY,true).childOption(ChannelOption.SO_KEEPALIVE,true).childHandler(dataAcceptInitializer);// pipeline编解码 WebSocket 处理newThread(()-{try{ChannelFuturefbootstrap.bind(port).sync();f.channel().closeFuture().sync();}catch(InterruptedExceptione){log.info(TCP服务器启动失败{},e.getMessage());}}).start();}PreDestroypublicvoiddestroy(){bossGroup.shutdownGracefully().syncUninterruptibly();workerGroup.shutdownGracefully().syncUninterruptibly();}}pipeline 里区分文本帧和二进制帧文本帧交给业务 HandlerpublicclassWebSocketHandleextendsSimpleChannelInboundHandlerObject{protectedvoidchannelRead0(ChannelHandlerContextctx,Objectmsg){if(msginstanceofTextWebSocketFrame){dataAcceptHandler.channelRead0(ctx,(TextWebSocketFrame)msg);}elseif(msginstanceofBinaryWebSocketFrame){// 二进制帧如压缩数据单独处理}}}怎么找到“该推给谁”用一张内存表维护 用户ID 与 Channel 的映射业务侧推送时按 ID 取通道即可publicclassUserChanelRel{// key 前缀区分角色driver:{id} / user:{id}privatestaticfinalHashMapString,ChannelmanagenewHashMap();publicstaticvoidput(StringsenderId,Channelchannel){manage.put(senderId,channel);}publicstaticChannelget(StringsenderId){returnmanage.get(senderId);}}推送时一行代码ChannelchannelUserChanelRel.get(StaticUtils.DRIVERdriver.getId());channel.writeAndFlush(newMsgVo(NettyCodeEnums.DRIVER_START_THE_SERVICE).toJsonStringbuf());通信协议用一个枚举统一约定消息类型端上只认 code前后端好维护KEEPALIVE(1,心跳正常),CONNECT(2,第一次连接正常),COORDINATE_DRIVER(3,司机坐标传输),ORDER_BEST_PUSH(11,订单优推),GRAB_SINGLE_POND_IS_REFRESH(12,刷新抢单池),USER_START_THE_SERVICE(14,订单被接单),DRIVER_START_THE_SERVICE(22,抢单成功),DRIVER_VOICE(1001,语音播报)// 共 30 种业务事件现在回看的坑重点UserChanelRel 是单机 HashMapNetty 多实例部署时通道不共享没法水平扩展。生产演进方向是把“用户在哪个节点”注册到 Redis推送时先查节点再转发或直接上 MQ 广播另外 worker 线程数固定写死 1000、SO_BACKLOG 设到百万级都属于“能跑但不优雅”更合理的是 worker 用默认的 2 倍 CPU 核数、backlog 按实际峰值设置。这些我也标在后续 Roadmap 里。三、难点二优推、普通派单、抢单池的三级派单派单不是简单“丢给最近的人”。真实策略是层层降级尽量提高成单率查出半径内的在线司机如果用户有“推荐司机”邀请关系优先定向派给他其次派给开了“优推”的司机按距离从近到远依次尝试再派给普通司机按距离、服务分排序尝试全都没接订单落入抢单池并广播通知附近司机“抢单池有新单”。核心逻辑有删减publicvoidorderBestPush(BigDecimalradius,BigDecimallat,BigDecimallon,Integerprecedence,OrderMainmain,CalcuUtilscal,OrderValuationVovo){ListDDriverdriversdDriverService.selectNearbyDriver(radius,precedence,lat,lon,1);if(CollectionUtils.isNotEmpty(drivers)){// 1) 邀请关系的推荐司机优先// 2) 优推司机(precedence1) 按距离排序依次尝试// 3) 普通司机按距离、type 排序尝试// 4) 都没接住 → 入抢单池pondGrabSingle(vo,cal,lat,lon);}else{pondGrabSingle(vo,cal,lat,lon);}}privatevoidpondGrabSingle(OrderValuationVovo,CalcuUtilscal,BigDecimallat,BigDecimallon){grabSinglePondService.saveGrabSinglePond(vo);// 订单落抢单池resetPond(cal,lat,lon);// 通知附近司机刷新}入池后通知半径内默认 5km可由参数 pool_scope 配置的所有在线司机ListDDriverdriversdDriverService.selectNearbyDriver(pondKm*1000,null,lat,lon,null);for(DDriverd:drivers){ChannelchannelUserChanelRel.get(StaticUtils.DRIVERd.getId());if(channel!null){channel.writeAndFlush(newMsgVo(NettyCodeEnums.GRAB_SINGLE_POND_IS_REFRESH).toJsonStringbuf());}}司机不在线怎么办不能让消息丢了这里做了多通道兜底通道在线Netty 直推通道不在线把消息写进 Redis用户消息 60s、司机消息 20s重连后补投用户已关注服务号走微信公众号模板消息通知“已成功呼叫代驾 司机信息”司机端再叠加一次语音播报DRIVER_VOICE降低漏单率。抢单这个动作本身通过 RabbitMQ 异步化避免高峰期在 HTTP 线程里做一堆锁竞争。四、难点三用 BigDecimal 搭一个能算账的计价引擎代驾计费项很多起步价、起步含里程、超里程单价、起步前等待、行驶中等待、用户加价、动态溢价、超程返程费、优惠券、平台信息费、保险。用 double 算钱迟早出精度事故所以全程 BigDecimal并封了一个 CalcuUtils 统一做四舍五入和 scale 控制。最终订单金额的拼装有删减// 超出起步里程部分向上取整里程费 超程公里 × 单价 / 计费单位BigDecimalorderMileagecal.sub(totalMileage,startMileage,2);BigDecimalmileageFeecal.div(cal.mul(orderMileage,extraMileagePrice,2),extraMileage,2);// 订单金额 起步价 行驶等待费 用户加价 起步前等待费 里程费BigDecimalamountcal.add(2,startMoney,waitFee,raiseMoney,startingWaitFee,mileageFee);// 超过返程阈值公里数后超出段按比例加收返程费if(totalMileage.compareTo(exceedMileage)1){BigDecimalreturnFeecal.mul(cal.div(cal.mul(overPart,extraMileagePrice,2),extraMileage,2),exceedDistanceRatio,2);amountcal.add(2,amount,returnFee,premium);// 再叠加动态溢价}// 支付金额 订单金额 - 优惠券BigDecimalpayPricecouponMoney.compareTo(amount)0?cal.sub(amount,couponMoney,2):BigDecimal.ZERO;两个细节处理得比较实在实际里程低于预估里程时按预估里程计可由 pricing_manner 配置开关防止司机被恶意超短途订单白跑平台信息费支持 4 种抽成模式且都带封顶价 maxValueswitch(platformRatioType){case1:固定金额;// 线上/线下分别配置case2:按司机当月已完成单量分档抽成;case3:线上单/线下单区别抽成;case4:按下单时间段抽成夜间高峰可设不同比例;}// 司机所得 订单金额 - 平台信息费 - 保险orderMain.setDriverMoney(cal.sub(practicalMoney,2,platformScale,insurance));五、其他鉴权拦截器 Redis Token维护一份白名单登录、支付回调、上传等OPTIONS 预检直接放行支付微信、支付宝下单与异步回调都在 PayController / PayService回调地址白名单同样放行轨迹司机端定时上报坐标协议码 3服务端累计里程并落轨迹供后台轨迹回放定时任务订单超时、自动结算、优推重试等用 Spring Task 驱动。六、写在最后这套系统不是“演示 Demo”下单、派单、行程、支付、结算的主链路是跑得通的但也必须坦白单机 Channel 表、写死的线程和 backlog 参数意味着它离“开箱即上大规模生产”还有距离更适合作为学习真实业务建模、二次开发的脚手架。文章里提到的演进点欢迎一起来改。完整后端 管理后台 数据库脚本都在仓库里按 README 几步就能本地拉起Gitee 开源地址https://gitee.com/zhoujian6666/biaoma-ride-car-service协议以仓库 LICENSE 为准。如果对你有帮助点个 Star 就是最大的支持也欢迎在 Issue 里交流派单和计价的更好做法。