从源码拆解网约车系统:订单状态机、派单算法与高并发设计

📅 发布时间:2026/9/7 9:35:29
从源码拆解网约车系统:订单状态机、派单算法与高并发设计 简介滴滴打车客户端Android源码包面向移动应用开发者及网约车业务学习者用于剖析实际出行类App的完整实现逻辑。资源共283个文件、约5MB其中包含36个java、121个class、39个xml、66个png、8个jar及2个apk等java/class为源码与编译产物xml与png对应界面布局和图标资源jar为依赖库apk则便于直接安装体验。整体目录覆盖乘客端、司机端、订单、支付等模块并整理了模块化架构、多线程调度、百度地图API集成、登录注册、推送通知、支付对接及安全加密等十余类关键知识点。已有近5000人学习/下载。相比零散教程该包能帮助读者系统理解网约车业务中定位、派单、计费到支付的完整技术链路并通过具体代码掌握网络请求、性能优化与权限安全等工程实践适合需要积累项目经验或从事出行类应用开发的开发者研读。 先说我看到“滴滴打车源码”这个关键词的第一反应市面上的确流传着不少号称“滴滴源码”的打包项目但凡真用过的都知道下载下来能跑通的水货居多能带你理解网约车系统核心逻辑的少之又少。而且真正深入进去会发现打车平台这种系统难点从来不在“能不能叫到车”而在订单状态的一致性、派单算法的取舍、高并发下不超卖不丢单、以及司乘两端消息怎么可靠触达。这篇文章我不想再去评价某个泄露源码的真假而是从一个后端开发者的视角把一套打车系统从订单创建到支付完成的源码级实现思路拆开讲清楚。如果你正准备做同城出行、跑腿配送、代驾或者货运调度这类项目这文值得收藏。1. 打车源码不是单个项目而是分布式系统的拆分思路1.1 从“用户叫车”这条链路反推服务边界很多第一次拿到类似打车源码的同学第一反应是去MySQL里找订单表然后顺着接口往下读。这个思路不能说错但很容易把自己绕晕。因为一套真实可用的打车系统源码从来不是“一个后端工程”而是拆成了至少五六个相互独立又能协作的服务。我自己在梳理这类项目时习惯从一个完整的用户行为链路反推服务边界乘客打开App、发起订单、系统派单、司机接单、司机到起点、乘客上车、行程中、到达目的地、支付账单、评价。这十来个环节里每一步都有独立的领域模型和状态流转。你要是硬塞进一个单体应用里前期写起来爽等接入地图服务、实时定位、支付回调之后代码会迅速腐化。按我的经验一份能称为“源码级”的打车项目后端至少要拆成这样几个模块用户服务乘客端和司机端的账号、登录态、乘客资料、司机资料审核。订单服务订单创建、状态流转、订单取消、订单详情查询。这是整个系统的核心。派单服务接收订单创建事件计算候选司机执行派单策略推送抢单或指派。司机服务出车收车、司机实时位置维护、服务状态管理。支付服务计价、下单支付、优惠券分摊、退款、对账。消息服务面向乘客和司机的长连接推送以及订单状态变更通知。这里最关键的是订单服务和派单服务。很多人会觉得派单就是“找距离最近的司机”其实真实项目里派单服务要处理的并发量和策略复杂度远比订单服务高。后面我会单独讲。1.2 一次真实叫车请求会经过哪些核心服务为了让你对链路有体感我直接画一条带时序感的请求路径。假设乘客在App首页点击“呼叫出租车”乘客端调用order/create接口入参是出发点经纬度、终点经纬度、车型、预计出发时间。订单服务先校验用户状态、风控规则然后创建一条状态为WAITING的订单记录写库成功。订单创建完成后订单服务发一条领域事件ORDER_CREATED到消息队列Kafka 或 RocketMQ。派单服务监听这个事件带上订单的起终点坐标去查候选司机执行派单算法。派单服务把候选司机列表按评分和距离排序逐个推送“新订单通知”。司机端收到推送后点击“接单”调order/accept接口。订单服务锁住订单校验仍处于WAITING状态然后更新为ACCEPTED再通知乘客端“司机已接单”。这条链路里订单服务只负责状态和持久化派单服务负责实时计算消息服务负责触达。它们之间通过消息队列解耦。这样设计的最大好处是乘客下单那一刻订单服务不需要关心“哪个司机能接”派单服务也不用担心写库失败会影响下单主流程。我见过不少自己从零写打车项目的开发者最喜欢在创建订单的接口里同步去查司机、去推送、去更新司机状态结果单量一上来下单接口动不动就超时。2. 订单状态机与状态一致性网约车源码的真正骨架2.1 一张订单的一生状态定义与流转规则如果你把一个打车系统源码从头翻到尾会发现最频繁出现的不是业务代码而是各种if (status xxx)的判断。订单状态机是整个系统里最不该出bug的地方但也是很多人写得最随意的地方。一套常规的网约车订单状态定义大概是这样的状态码状态名含义可流转到0CREATED已创建等待派单WAITING、CANCELLED1WAITING待接单正在派单中ACCEPTED、CANCELLED2ACCEPTED司机已接单TO_PICKUP、CANCELLED3TO_PICKUP司机前往乘客起点PICKED_UP、CANCELLED4PICKED_UP乘客上车行程开始ARRIVED5ARRIVED到达目的地行程结束PAYING6PAYING待支付PAID、CANCELLED7PAID已完成—8CANCELLED已取消—看到这张表你可能会觉得这不就是枚举嘛有什么好写的。真正的坑在于状态只能按箭头方向走不能跳变更不能回退。比如一个订单在PICKED_UP状态下乘客说司机没来客服要取消订单这时候代码里如果只是执行update order set status CANCELLED where order_id xxx那就完蛋了——行程已经开始了计费模块已经在跑强制改状态会导致計费数据变孤儿。2.2 状态流转的代码实现状态机模式写状态流转我推荐用状态机模式而不是到处散落if判断。伪代码可以这样写// 订单状态流转配置 MapOrderStatus, ListOrderStatus TRANSITIONS new HashMap() {{ put(CREATED, Arrays.asList(WAITING, CANCELLED)); put(WAITING, Arrays.asList(ACCEPTED, CANCELLED)); put(ACCEPTED, Arrays.asList(TO_PICKUP, CANCELLED)); put(TO_PICKUP, Arrays.asList(PICKED_UP, CANCELLED)); put(PICKED_UP, Arrays.asList(ARRIVED)); put(ARRIVED, Arrays.asList(PAYING)); put(PAYING, Arrays.asList(PAID, CANCELLED)); }}; public boolean transition(Order order, OrderStatus target) { ListOrderStatus allowed TRANSITIONS.get(order.getStatus()); if (allowed ! null allowed.contains(target)) { // 使用乐观锁更新防止并发修改 int rows orderMapper.compareAndSetStatus(order.getId(), order.getStatus(), target); return rows 0; } return false; }注意compareAndSetStatus这个方法SQL 里对应的是UPDATE orders SET status #{target} WHERE id #{id} AND status #{expect}。这是状态一致性的第一道防线。因为乘客端和司机端可以同时操作同一个订单如果没有这个条件就可能出现乘客点了取消司机同时点了接单最终状态以最后一次写入为准完全不可控。2.3 出车与收车司机端状态管理订单状态机的“上游”还有一个很容易被新手忽略的状态司机端的出车状态。这东西不复杂但是很影响派单逻辑。司机出车意味着他可以被派单收车、服务中、暂停接单都应该有独立的标志位。我在实操中习惯用 Redis 保存司机实时状态和位置而不是每次都查 MySQLKeydriver:online:{driverId}ValueJSON 字符串包含latitude、longitude、statusonline/offline/busy、updatedAt。为什么用 Redis因为派单服务每秒钟可能要扫描上万个在线司机如果每次扫描都去 MySQL 里查“这个司机在不在线、现在位置在哪”数据库绝对扛不住。Redis 的 Geo 数据结构甚至可以直接做范围查询后面讲派单算法时会再提到。出车接口要做的事也很简单把司机状态置为 online写入 Redis再异步通知派单服务“这个司机可以接单了”。收车则是反操作。但要注意这里有竞态条件司机已经接单了结果他点了收车怎么办我的做法是收车之前必须先检查有没有进行中的订单有就直接拒绝前端提示“当前有行程进行中无法收车”。这个校验看起来简单但漏掉的话就会出现司机接单后失联的严重投诉。3. 派单算法从“最近距离”到“综合评分”的实现演进3.1 最简单的距离优先实现先别急着上什么智能调度绝大多数打车源码的派单核心本质上就是一个“在候选司机里挑一个最合适的”。最基础、也最好理解的实现是距离优先取订单起点坐标。在 Redis Geo 里查search(起点坐标, 半径5km, unitm)拿到候选司机ID列表。过滤掉状态不是 online 的司机。按直线距离从近到远排序。取前三名依次推送抢单直到有人接单。伪代码大致长这样public ListLong findCandidateDrivers(Position from, double radius) { SetLong driverIds geoService.search(GEO_KEY, from.getLng(), from.getLat(), radius); return driverService.batchGetOnline(driverIds); }这种实现的好处是简单、快单机 QPS 轻松上几千。坏处也很明显它完全不管司机是不是往用户方向走、司机接单意愿高不高、司机真实距离是不是被建筑道路绕远了。所以在真实系统里直线距离只用来圈定候选集合真正排序要换更实际的指标。3.2 加入等待时间、司机评分和调度约束稍微像样一点的派单排序用的是综合分。几个关键因子接驾距离司机当前位置到乘客起点的预估行驶距离权重最高。司机评分服务分高的司机优先派单避免好单全被刷单号抢走。方向匹配司机行驶方向是否和订单方向一致这个一般需要结合司机历史轨迹方向来判断新手项目可以先不做。派单热度如果一个司机连续被推送了5次订单都不接系统应降低他的优先级甚至暂停推送几分钟。综合分的伪代码public double calcDriverScore(Driver d, Order order) { double distanceScore 1 / (1 d.getDistanceToPickup(order.getStartPos())) * 100; double ratingScore d.getRating() * 10; // 4.8分 48分 double willingScore d.getWillingnessFactor() * 10; // 0~1 return distanceScore * 0.5 ratingScore * 0.3 willingScore * 0.2; }这个公式不用追求精确核心是体现出“距离近不代表最适合”的思想。你可以按业务阶段调整权重比如新开城想冲单量就把距离权重调高想保服务体验就把评分权重调高。3.3 推送、抢单与强制派单的取舍派单方式有两种流派抢单模式和指派模式。抢单是平台把订单推给多个司机谁手快谁接指派是平台直接指定一个司机司机只能接受或拒绝。从源码实现的角度抢单模式最简单因为司机点击“接单”时订单状态已经从 WAITING 往 ACCEPTED 流转了平台要做的只是保证“不能被两个司机同时抢到”。所以订单服务里compareAndSetStatus的乐观锁就是抢单的底层基础。指派模式则反过来要由派单服务调order/assign接口主动锁单再发强通知给司机。我的建议是源码学习阶段用抢单商业系统尽量用指派。因为抢单模式会让有经验的司机挑肥拣瘦偏远地区单没人抢高峰期好单被秒抢体验很难做。指派模式配合“接单率考核”更容易实现全局运力调度。4. 地图与路径规划接入层的设计取舍4.1 地理围栏、坐标转换与距离计算打车系统离不开地图能力。我把地图这块分成四件事坐标存储、距离计算、路径规划、轨迹纠偏。先说坐标存储。前端传上来的经纬度建议统一转成 GCJ-02 坐标系再落库因为国内地图厂商用的都是火星坐标GPS 原始坐标直接描点到地图上会偏移几十米到几百米不等。这个转换在服务端做一次就行写到工具类里所有入库的经纬度都过一遍。距离计算这里有个常见的性能误区有些开发者用 Hive SQL 或 MySQL 里直接算球面距离量大之后慢到怀疑人生。简单场景直接用 Redis Geo 算距离就行它是按照地球球面模型算的误差在米级足够用来筛选候选司机。更精确的驾驶距离必须调地图厂商的路径规划接口这个只能按次计费所以要控制调用量。4.2 路径规划结果的缓存与回退策略路径规划接口不能每次都用真实费用去调尤其是高频场景。我自己踩过几次教训之后总结了一套缓存策略起点终点完全一致直接缓存结果key 用route:{startLngLat}:{endLngLat}有效期10分钟。方向相反很多地图API支持返回对称路线可以直接复用但要记得在代码里标记方向。起点或终点变化在100米内可以认为路线不变用哈希网格降精度匹配缓存。还有一个必须考虑的事情地图服务商偶尔会挂。像高德、百度这种大厂也不是100%可用一旦路径规划接口超时系统不能跟着崩。我的策略是设置短超时800ms失败后降级用直线距离 x 1.4 的粗略预估来继续派单和计价等地图服务恢复后再异步回填真实路线距离。4.3 司机轨迹上报与偏航检测司机端的轨迹不会真的每秒都往后台传那样流量和存储都扛不住。行业里通用做法是司机端每3秒采集一次GPS累积5个点后批量上报一次后台收到轨迹点之后做抽稀处理只保留关键转弯点存到时序数据库或者 MongoDB。偏航检测是另一个容易被忽略的模块。司机没按规划路线走系统得判断是“司机抄近道”还是“司机想绕路宰客”。实现思路不复杂每隔一段时间拿司机最新位置和路径规划接口算出的新路线对比如果新路线距离比原路线多了15%以上就标记为偏航并弹提醒。这个逻辑写起来就几十行但非常体现项目完整度面试时讲出来也很加分。5. 高并发下的订单防重与支付对账5.1 防重提交幂等键与分布式锁抢单场景最容易出幺蛾子的是重复提交。司机网络卡顿点了一下没响应又点了一下后端如果没做防重就会创建出两个订单——一个显示被A司机接了另一个还在等待派单。解决重复提交最实在的方案是幂等键。核心逻辑是在前端生成一个全局唯一的requestId后端在真正执行业务之前先去 Redis 里查这个requestId是否已经处理过boolean first redis.setIfAbsent(idem: requestId, 1, Duration.ofMinutes(5)); if (!first) { // 重复提交直接返回上一次的处理结果 return lastResult; }这个setIfAbsent是原子操作配合过期时间能天然解决并发重复请求的问题。但要注意过期时间不能太短一旦业务处理超过5分钟锁提前过期就会出现重复下单的漏网之鱼。5.2 订单过期取消与异常释放乘客下单后一直没人接订单不能永远挂在那儿。常见策略是下单后2分钟内若无人接单系统自动取消并把司机资源释放掉。这里有人会用定时任务每分钟扫一次WAITING状态的订单超过时间就取消。单量少没问题单量大了就慢而且存在“扫描到一半用户刚好叫车”的时间差。更好的方案是延时消息订单创建时发一条延迟2分钟的MQ消息消息到达后再检查订单是否还在WAITING是则自动取消否则忽略。要注意释放司机资源这个动作其实在ACCEPTED的瞬间就应该完成。也就是说司机一旦接单派单服务要马上把他从候选池里摘掉防止被派给下一个乘客。实现上可以在 Redis 里把司机状态置为 busy或者从候选者集合中删除该司机ID。5.3 支付金额计算与优惠分摊计价和支付是打车源码里最容易跟真实业务脱节的部分。如果只是做一个静态价格 起步价 里程费 时长费那太简单了不值得写。稍微真实一点的项目至少要包含动态调价、优惠券、免密支付、对账四个逻辑。动态调价在高峰期把价格乘以1.2到1.8倍实现上要注意金额精度前端显示和小程序支付都按“分”为单位计算后端用long类型存储金额。优惠券分摊则考验对账能力订单实付金额 原始金额 - 优惠券金额 动态调价金额但每一部分都要记录在订单明细表里否则月底财务对不上账。一个实用的小建议支付回调必须做幂等。微信或支付宝的回调接口可能会重复通知多次比如网络重试如果每收到一次回调就给订单加一次“已支付”状态金额直接翻倍。正确做法是回调处理里先select ... for update锁住订单检查订单状态如果已经是PAID直接返回成功不再执行入账逻辑。6. 司乘端消息通道与实时通信选型6.1 WebSocket长连接与消息推送乘客想知道“司机到哪了”司机想知道“乘客改起点了”靠App轮询接口不是不行但很费资源而且体验差。真实项目里司乘两端各自维护一条和后台的长连接。技术选型上小规模项目用 Netty 写一个 WebSocket 服务就够了大规模再考虑引入开源IM组件。但不管用什么核心逻辑都一样客户端连接上来时上报自己的 userId服务端把连接注册到本地路由表推送的时候按 userId 找到对应的连接通道发送消息。这里有个坑一台服务器只能持有它自己接受的连接。如果用户A连接的是节点1用户B连接的是节点2它们之间要通信单个节点是互不可见的。解决思路是引入 Redis 发布订阅或者用消息队列转发节点1收到司机位置更新消息后把消息 publish 到 Redis 的driver:location:{orderId}频道所有订阅该频道的节点都能收到然后节点2再把消息推给乘客端。6.2 位置更新的批量上报与频率控制还是那句话位置上报不能太频繁否则服务器扛不住。我在实战里推荐这样一个频率控制行程进行中司机端每3秒采集每10秒上报一次聚合点位。等待接驾中司机端每5秒采集每15秒上报一次。App退到后台停止采集改为系统级推送通知触发定位。类似“司机距你1.2公里”这种展示不需要实时精确到米。后台每收到一次位置上报都会更新 Redis 里的经纬度同时向乘客端推送一次位置变更。如果乘客端正好也开着实时轨迹可以每10秒从服务端拉一次最新位置平滑绘制而不是依赖每一帧推送。这里再提一个性能优化点在推送轨迹点给乘客端时不要一上来就推一串原始坐标点。先做抽稀比如只保留间距大于50米的点这样乘客端绘制轨迹时不会出现几百个点糊成一团的情况。7. 从源码到实战开源方案与复现路线建议7.1 技术栈选型从单机到分布式看再多的源码都不如自己动手把主链路写一遍。但我不建议一上来就引入微服务全家桶而是按阶段演进单机单体版Spring Boot MySQL Redis WebSocket把订单状态机、派单、支付回调全写在一个工程里先实现waiting - accepted - started - finished这条主链路。拆服务版等单机版跑通把订单服务和派单服务抽离成两个进程引入 RocketMQ 或 Kafka 传递订单事件。这一步让你理解为什么要异步解耦。加算法版在派单服务里实现距离优先、综合评分、指派兜底三种模式并用 Jmeter 压测对比它们的性能差异。上线运维版容器化部署配置 Nginx 反向代理、Redis 主从、MySQL 主从再接入监控告警。技术栈上Redis 的 Geo 命令是必须掌握的它是派单的基石。搜索附近司机、计算距离、按半径过滤都是它搞定。MySQL 的表设计上订单表的核心字段建议包含order_id业务单号、passenger_id、driver_id、start_lng/lat、end_lng/lat、status、amount、create_time、update_time并建联合索引(status, create_time)以支持“按状态查历史订单”的查询。7.2 开发顺序建议先搭骨架再填肉如果你准备照着这个思路敲代码我建议按这个顺序来先搭好多模块工程骨架用户、订单、派单、支付、消息五个模块内部用 Feign 调用。完成用户注册登录和司机认证先不管风控和审核。完成订单表结构设计和状态机核心代码。实现 Redis 在线司机管理和 Geo 查询。实现抢单接口打通“乘客下单 - 司机接单”这条最短链路。接线上的订单状态流转司机到起点、开始行程、到达终点。接入支付模拟器先不用真实支付渠道用“余额支付”模拟回调。最后再做压力测试和细节打磨。这个顺序的最大好处是每一步都有可运行、可验证的成果不会写了一个月代码还在处理数据库连接。还有一个小技巧项目里宁可少写花哨功能也要把订单状态机、幂等防重、自动取消这三个模块写严谨。我在看别人简历上的“滴滴打车项目”时最常问的就是这三个点。能把这三点讲明白的人基本都是自己动过手的。做这类项目说到底是一种“用最小成本理解复杂系统”的捷径。网约车把真实世界里最棘手的实时调度、状态一致性、高并发问题浓缩到了一张订单里。你把它啃透了去看外卖系统、快递调度、货运匹配会发现架构思路惊人地相似。本文还有配套的精品资源点击获取