简介:一套基于Spring Cloud构建的网约车平台项目源码,涵盖乘客端、司机端、司机听单、派单调度、订单、计价、支付、钱包、短信验证码、地图等微服务模块,并集成Eureka注册中心、Config配置中心、Zuul网关、Hystrix熔断监控、Zipkin链路追踪等组件,适合学习微服务架构与网约车业务闭环的Java开发者。压缩包共1081个文件,以Java源码、XML配置、YML配置文件为主,另含少量静态资源、SQL脚本与文档,整体约137MB,目录区分业务层、能力层与Spring Cloud组件,便于按模块阅读和二次开发。已有2290人学习下载,对想从单体过渡到分布式架构、理解服务拆分与模块协作的读者具有参考价值。项目演示覆盖登录注册、验证码、派单接单、到达约定地点、接待旅客、开始行程、到达目的地、发起收款等核心流程,可直接对照梳理各服务间调用关系与数据流转。1. 基于 Spring Cloud 的网约车项目:这是一套能跑通全流程的分布式系统实战做后端这几年,我拆过不下二十套微服务项目,网约车类的基本都在两个极端:要么只搭了注册中心和几个空接口,要么业务堆得太满、日志错乱到根本没法排查。这套 OnlineTaxi 属于少见的完整型资源——乘客端、司机端、运营后台三端齐全,订单从发起到派单、计价、支付、结算的闭环是通的,订单和派单用的都是真实场景里那套状态机设计,不是拿 CRUD 充数。适合的人群很明确:刚啃完 Spring Cloud 理论、想把注册发现、熔断降级、分布式事务这些概念落到真实业务里的 Java 开发者,以及正在做毕设或简历项目、需要一套拿得出手代码的求职者。它最大的价值不是代码量,而是把网约车这种高并发、强一致、时效性要求极高的业务,用一套可复现的方式拆给你看。2. 微服务拆分与模块边界:订单、派单、计费怎么切才不会乱2.1 从单体出租车系统到微服务:先看服务划分逻辑拿到的这套项目里,服务拆分是照着网约车业务域来切的,不是按用户角色拍脑袋分。核心域拆成了这样:乘客端负责发单、取消、支付;司机端负责接单、行程、结算;订单中心是业务主链路,承接所有订单生命周期状态流转;派单中心独立成服务,负责调度策略和司机匹配;计费中心负责预估价格、实时计价和账单生成;支付服务管着支付渠道的对接和退款。加上基础设施类的网关服务、消息服务、定位服务,一共九个可独立部署的 Spring Boot 应用。这样切的好处是每个服务的职责足够单一,比如计费规则调整时不涉及订单状态,派单策略灰度时不影响乘客下单主流程。我拿到项目第一件事是先看每个模块的 pom 依赖和启动类,确认服务间调用边界。这套项目的调用方向是单向的——乘客端调用订单中心,订单中心通过消息通知派单中心,派单完成后再回调订单中心更新状态,计费中心被订单和司机端分别调用但不反向依赖。这种设计避免了循环调用,也是后面排查问题的基础。服务间通信全部走 OpenFeign,异步场景走 RabbitMQ,没有引入额外的 RPC 框架,学习成本低,也更容易替换成公司内部的 RPC 方案。2.2 数据库拆分与主键设计:分库分表之前的关键决策网约车场景的数据特征是订单量大、维度多、按时间查询频繁,这套项目采用的是一库多 Schema 的设计,没有直接上分库分表中间件,但表结构预留了拆分空间。订单表的主键是雪花算法生成的 Long 型 ID,订单号拆成三段:时间戳位(第 1 到 41 位)、机器标识位(第 42 到 52 位)、序列号位(第 53 到 64 位)。这套设计的好处是无需依赖数据库自增,插入时可以按时间维度直接排序,分表时路由也方便,按订单号模 16 或者基于时间范围都能路由到对应分片。订单表的核心字段里,有几个容易忽略的设计点值得抄作业:order_status用了 TINYINT 而不是 VARCHAR,状态值 0 到 8 覆盖从待支付到已完成、已取消的完整生命周期,状态流转在代码里是枚举类控制的,杜绝了外部直接改状态值。driver_id和passenger_id都建了联合索引((status, create_time)和(driver_id, status)),这是照着网约车两个核心查询场景来建的——乘客查进行中的订单、司机查正在派单的订单。我自己在设计订单类表时也沿用这套索引思路,效果比从网上复制一套通用索引规范好太多。CREATE TABLE `order_info` ( `id` BIGINT NOT NULL COMMENT '订单ID(雪花算法)', `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号', `passenger_id` BIGINT NOT NULL COMMENT '乘客ID', `driver_id` BIGINT DEFAULT NULL COMMENT '司机ID', `order_status` TINYINT NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2派单中 3待上车 4行程中 5待结算 6已完成 7已取消', `pickup_lat` DECIMAL(10, 6) NOT NULL COMMENT '上车点纬度', `pickup_lng` DECIMAL(10, 6) NOT NULL COMMENT '上车点经度', `dropoff_lat` DECIMAL(10, 6) NOT NULL, `dropoff_lng` DECIMAL(10, 6) NOT NULL, `estimate_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '预估金额', `real_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '实际金额', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_create` (`order_status`, `create_time`), KEY `idx_driver_status` (`driver_id`, `order_status`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='订单主表';这段 DDL 的关键点有两个:第一,订单时间字段统一用 DATETIME 而不是 TIMESTAMP,避免 2038 年问题,也方便后续按时间归档数据;第二,订单状态没有建外键、没有用数据库约束,所有状态变更都收敛到订单服务内部,这是微服务架构下的一致性原则——状态校验放在应用层,数据库只负责存储和查询加速。实操时如果用order_status作为查询条件且数据量已经过百万,务必保留这个联合索引,否则乘客端的订单列表接口会慢到让人怀疑人生。2.3 网关层与鉴权:统一入口怎么设计才不翻车这套项目的网关用的 Spring Cloud Gateway,路由规则按服务名转发,/api/passenger/**转到乘客端服务,/api/driver/**转到司机端服务,/api/order/**转到订单中心。网关层集中做了三件事:灰度路由(按请求头里的x-version打到不同版本的服务上去)、简单的日志埋点、以及 Token 校验。Token 校验这块用的是 JWT,乘客登录后拿一个 Token,网关层面解码校验 Token 的签名和时间戳,但角色信息在子服务里再校验。spring: cloud: gateway: routes: - id: passenger-service uri: lb://passenger-service predicates: - Path=/api/passenger/** - id: order-service uri: lb://order-service predicates: - Path=/api/order/** default-filters: - name: TokenValidate args: enable: true这段配置的注意点是:lb://前缀必须配合 Nacos 服务发现使用,如果服务没注册成功,路由直接 503。Token 校验做成 GlobalFilter,不仅拦截了需要登录的接口,还要设置白名单——登录接口、支付回调接口这些不能校验 Token,否则整个链路直接断掉。这块我在第一次跑的时候踩过坑,后面避坑章节细说。3. 核心链路落地:从乘客发单到司机接单再到行程计费3.1 乘客下单流程:订单状态机的流转细节乘客端下单的核心动作是先计算预估金额,然后创建订单,接着异步通知派单中心。接口设计是 POSTorder/create,请求体里带起点经纬度、终点经纬度、乘客 ID、车型类型。订单服务先调计费服务的estimate接口拿预估价格,再写订单表,状态置为“待支付”。这一步的设计是乘客先确认支付方式,支付动作完成后订单才真正进入派单池,避免支付失败却把司机调度过去,导致运力浪费。订单创建后的状态机流转是这套资源里最值得读源码的部分。状态机的定义在OrderStateMachine类里,定义了每个状态允许的触发事件和目标状态,比如“待支付”状态下只有pay_success事件能转发到“已支付”,cancel事件能转到“已取消”。如果代码里尝试从“行程中”直接跳到“已完成”,状态机会直接抛异常。这个设计在真实项目里非常有用——把状态流转规则收敛到一个地方,避免团队成员各自改状态导致数据错乱。我一般会在状态机转换的日志里加当前状态、目标状态、操作者 ID 三个字段,排查问题时效率能提升不少。@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private BillingServiceClient billingServiceClient; @Autowired private OrderStateMachine stateMachine; public OrderCreateResponse createOrder(OrderCreateRequest request) { // 1. 调用计费服务,计算预估价格(远程调用) EstimateResponse estimate = billingServiceClient.estimate( request.getPickupLat(), request.getPickupLng(), request.getDropoffLat(), request.getDropoffLng(), request.getCarType() ); // 2. 构建订单实体,状态默认待支付 OrderInfo order = new OrderInfo(); order.setOrderNo(SnowflakeUtil.nextId()); order.setPassengerId(request.getPassengerId()); order.setPickupLat(request.getPickupLat()); order.setPickupLng(request.getPickupLng()); order.setDropoffLat(request.getDropoffLat()); order.setDropoffLng(request.getDropoffLng()); order.setEstimateAmount(estimate.getAmount()); order.setOrderStatus(OrderStatusEnum.WAITING_PAYMENT.getCode()); // 3. 插入订单主表 orderMapper.insert(order); // 4. 发送 MQ 消息,异步唤醒派单调度 mqTemplate.convertAndSend("order.exchange", "order.create", order.getOrderNo()); return new OrderCreateResponse(order); } public void handlePayCallback(String orderNo, Integer payStatus) { // 支付回调:校验订单状态是否为待支付,然后变更 OrderInfo order = orderMapper.selectByOrderNo(orderNo); if (order != null && stateMachine.canTransition( order.getOrderStatus(), OrderStateEnum.PAY_SUCCESS)) { orderMapper.updateStatus(orderNo, OrderStateEnum.PAID.getCode()); mqTemplate.convertAndSend("order.exchange", "order.paid", orderNo); } } }这段代码反映了两个关键设计。第一,支付回调里的状态校验不是简单的对当前状态为空就更新,而是通过状态机判断可否转换,这样即使回调重复调用也不会重复改状态——幂等逻辑靠的是数据库更新时带WHERE order_status = 原状态的乐观锁实现。第二,订单创建成功和支付成功都发了 MQ 消息,但消息落库和订单状态变更不是同一个本地事务,这是后面要解决的分布式一致性问题,也是分布式事务章节的引子。3.2 派单策略:接单池与司机距离计算派单中心是这套项目里逻辑最重的地方,也是面试常问的重点。核心策略是“范围筛选 + 距离排序”:先把订单消息从 MQ 里拉出来,获取起点经纬度,然后从 Redis 的 GEO 结构里找出半径 5 公里内的所有在线司机,再按距离排序取前 10 位,依次推送订单到司机端。司机端响应的超时时间是 30 秒,如果 30 秒内无响应就顺延到下一批司机。Redis 的 GEO 数据结构大量使用,因为网约车的司机位置变更很频繁,用数据库算距离太慢。派单中心还有个细节是司机在线状态管理。司机 App 启动时通过 WebSocket 长连到司机端服务,司机端服务每隔 5 秒上报一次 GPS 坐标,坐标写入 Redis GEO 并设置 10 秒过期。这样如果司机网络断了,10 秒后 GEO 数据自然过期,司机从派单池里剔除,不需要显式调下线接口。这个设计很巧妙,属于“过期即下线”的思路,比维护一个在线状态表要简单得多。@Service public class DispatchService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private DriverClient driverClient; private static final double DISPATCH_RADIUS = 5000; // 5公里 public void dispatch(OrderDispatchMessage message) { // 1. 用订单起点经纬度查询附近司机(GEO搜索) Set<String> nearbyDrivers = redisTemplate.opsForGeo().radius( "driver:location", new Point(message.getPickupLng(), message.getPickupLat()), new Distance(DISPATCH_RADIUS, Metrics.KILOMETERS) ); if (nearbyDrivers == null || nearbyDrivers.isEmpty()) { // 附近无司机,订单标记为派单失败,进入等待队列 mqTemplate.convertAndSend("order.exchange", "order.dispatch_failed", message.getOrderNo()); return; } // 2. 距离排序,取最近10个司机逐个推送 List<String> driverIds = nearbyDrivers.stream().limit(10).collect(Collectors.toList()); for (String driverId : driverIds) { SendResult result = driverClient.pushOrder(driverId, message); if (result.isAccepted()) { // 3. 司机接受,更新订单表中司机ID,状态改为待上车 orderMapper.assignDriver(message.getOrderNo(), driverId); break; } } } }这里的参数调整空间主要在 DISPATCH_RADIUS 和推送数量上限。城市密度不同,派单半径应该跟着调:高峰期 3 公里,平峰期 5 公里,偏远区域可以放宽到 8 公里。推送数量上限太大,司机端会频繁弹单,体验差;太小,派单成功率低。建议把这俩参数配置到 Nacos 配置中心里,运营人员不用改代码就能调。我一般还会加一个“司机接单冷却时间”——司机刚拒单后 30 秒内不推送同类型订单,防止恶意拒单干扰派单效率。3.3 计费服务:里程费、时长费与远途费计费服务涉及的算法逻辑不算复杂,但边界条件多。基础计费公式是:总价 = 起步价 + 里程费 + 时长费 + 远途费(超出 15 公里后每公里加收)。这套代码里把计价规则做成了一张配置表billing_rule,每条规则包含车型、时段(早高峰、晚高峰、平峰)、起步价、每公里单价、每分钟单价、远途阈值等字段。接单时按当前时段查一条规则算出预估价格,行程结束后再按实际里程和时长结算。行程结束后,司机端会调billing/calculate接口,请求里带订单号、实际里程、实际时长,计费服务查订单的车型和用车时间,然后匹配计费规则表里的时段配置,返回最终金额并生成账单。这个接口的幂等性处理值得学习:订单号作为唯一业务键,重复调用时不会重复生成账单,而是在账单表里做唯一索引约束,第二次调用直接返回已存在的账单数据。CREATE TABLE `billing_rule` ( `id` INT NOT NULL AUTO_INCREMENT, `car_type` TINYINT NOT NULL COMMENT '车型 1快车 2专车 3豪华车', `time_type` TINYINT NOT NULL COMMENT '时段类型 1平峰 2早高峰 3晚高峰 4夜间', `base_price` DECIMAL(6, 2) NOT NULL COMMENT '起步价', `per_km_price` DECIMAL(6, 2) NOT NULL COMMENT '每公里单价', `per_min_price` DECIMAL(6, 2) NOT NULL COMMENT '每分钟单价', `long_distance_threshold` DECIMAL(6, 2) DEFAULT NULL COMMENT '远途费起算公里数', `long_distance_price` DECIMAL(6, 2) DEFAULT NULL COMMENT '远途费每公里附加价', PRIMARY KEY (`id`), KEY `idx_car_type_time` (`car_type`, `time_type`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='计费规则表';这段 DDL 的核心是在(car_type, time_type)上建联合索引,因为计价查询永远是这两个条件联合查询。时段字段在一个计费周期内一般不会改,用 TINYINT 够用。设计表的时候我注意到一个坑:时段类型和时间段值的映射逻辑写在代码枚举里,而不是数据库里直接存时间段范围,这样改时段价格不需要改代码,但调整时间段的起止时间(比如早高峰从 7 点改成 7 点半)就需要改枚举再发布,稍微麻烦一些。常见做法是把time_type映射成配置项存进 Nacos,让运营在控制台改。4. 分布式事务与数据一致性:订单支付、派单与计费如何不丢数据4.1 本地消息表与消息最终一致性:这笔交易先写库再发消息网约车业务里最典型的一致性问题在下单支付这个动作上:订单服务先更新订单状态为“已支付”,再通知派单中心开始派单。如果更新库成功而消息发送失败,订单就卡在已支付但无人派单的状态,乘客端显示“等待司机接单”但司机根本没有收到订单推送。这套项目采用的方案是本地消息表 + MQ 确认机制,不是正常工作中极少用到的强一致方案。@Transactional public void handlePayCallback(String orderNo, Integer payStatus) { // 1. 更新订单状态(本地事务) int rows = orderMapper.updateStatusWithGuard(orderNo, OrderStatusEnum.WAITING_PAYMENT.getCode(), OrderStatusEnum.PAID.getCode()); if (rows == 0) { throw new BizException("状态更新失败,可能已被处理"); } // 2. 同一事务内写入消息表 LocalMessage msg = new LocalMessage(); msg.setMsgId(UUID.randomUUID().toString()); msg.setMsgTopic("order.paid"); msg.setMsgPayload(orderNo); msg.setStatus(MessageStatusEnum.WAITING_SEND.getCode()); localMessageMapper.insert(msg); // 3. 事务提交后发送MQ消息(事务内不真正发送) TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { mqTemplate.convertAndSend("order.exchange", "order.paid", orderNo); localMessageMapper.updateStatus(msg.getMsgId(), MessageStatusEnum.SEND_SUCCESS.getCode()); } }); }这个设计的核心是“消息落库和业务操作在同一个本地事务里”,事务提交成功后才发 MQ,避免业务成功但消息丢失。afterCommit里再推送 MQ,如果 MQ 发送失败,消息表状态仍是“待发送”,会有定时任务扫描消息表中超过 10 秒仍为待发送的记录执行补偿重发。这套本地消息表方案的定位是“准最终一致性”,适合订单这种允许秒级延迟但不能丢失的场景。下一节要说的 TCC 模式则更适合资金类操作,比如支付退款。4.2 TCC 与补偿策略:这笔钱从乘客扣给司机,怎么保证各服务账目一致这套项目里涉及资金的操作分两块:乘客的支付动作对接了第三方支付渠道,属于外部调用无法本地事务控制;司机的收益结算则是内部服务间的资金操作。司机完成订单后,订单中心触发结算流程:先把乘客的支付账户标记“已消费”,同时把司机收益账户累加本次订单金额,再更新账单状态。这两个操作分布在支付服务和司机钱包服务里,如果不处理,订单完成但司机钱包没进账,就会出现资损。项目里对司机结算这块用了简单的 TCC 方案,虽然实现得比较轻量,但思路是标准的:Try 阶段先把司机账户的可结算余额冻结,Confirm 阶段把冻结余额转为可用余额,Cancel 阶段解冻并记录失败原因。这个方案在公司内部项目里很常见,完全用数据库行锁实现,不依赖第三方分布式事务中间件。@Transactional public void confirmSettlement(WalletSettlementRequest request) { // Try阶段:冻结司机账户中的本次结算金额 int freezeRows = walletMapper.freezeAmount(request.getDriverId(), request.getAmount()); if (freezeRows == 0) { // 余额不足或账户不存在,直接进入Cancel transactionManager.cancel(request); return; } // Confirm阶段:把冻结金额转为可用余额(这里模拟原项目中的confirm操作) int confirmRows = walletMapper.confirmFreeze(request.getDriverId(), request.getAmount()); if (confirmRows == 0) { // Confirm失败,进入Cancel回滚冻结 transactionManager.cancel(request); throw new BizException("结算确认失败"); } }这套轻量 TCC 的实现有个边界要知道:它只覆盖了两个内部服务之间的资金操作,没法覆盖第三方支付渠道的扣款,因为第三方渠道没有提供“冻结+确认”的一体化接口。所以实际项目中资金相关的操作只有一部分能用 TCC,其余更多是靠对账补偿来兜底。项目中定时任务每天凌晨拉取支付渠道的对账单,和本地支付记录做比对,对不上账的进人工处理队列。能看懂这套边界在哪里、哪些操作必须靠对账,比会用某个分布式事务框架重要得多。4.3 接口幂等设计:回调接口重复调用,订单状态还能保持正确我在排查这套项目时发现有个细节做得特别稳:几乎所有写操作的接口都做了幂等处理。支付回调是最典型的——如果乘客支付成功后网络超时,第三方渠道会重试回调,如果不处理会重复更新状态、重复发消息。项目里的做法是更新操作带原状态条件:int rows = orderMapper.updateStatusWithGuard(orderNo, OrderStatusEnum.WAITING_PAYMENT.getCode(), OrderStatusEnum.PAID.getCode());UPDATE order_info SET order_status = #{targetStatus} WHERE order_no = #{orderNo} AND order_status = #{sourceStatus},这样即使回调重复执行多次,也只有一个请求能改状态成功,其他请求返回的影响行数为 0,直接进入幂等处理逻辑。这个模式简单可靠,是微服务接口幂等的首要选择。另一个常用手段是状态机前置校验,二者搭配使用效果最好:状态机做逻辑校验,乐观锁做数据库兜底。我建议所有涉及状态流转的 update 语句都加上原状态条件,哪怕目前只有一个接口在调用,也要为之后的扩展和并发重复请求留好余地。5. 避坑指南:Spring Cloud 网约车项目跑起来之前必须处理的五个问题5.1 服务间调用超时:OpenFeign 默认超时导致下单接口假死现象:下单接口偶尔卡住,大概十几秒后才返回,高峰期频繁出现;翻开日志看到 OpenFeign 报Read timed out异常,但不是每次都会挂。原因:OpenFeign 默认连接超时是 10 秒,读取超时是 60 秒,在派单服务的某些接口里,如果司机端推送逻辑执行超过 60 秒,调用方大概率直接超时报错。这还不是最坑的——如果服务里配置了 Hystrix 或 Sentinel 熔断,默认超时时间是 1000 毫秒,请求稍微慢一点直接触发熔断,大批请求被快速失败。解决:按业务接口调整 OpenFeign 超时时间,派单推送这类实时性要求较高的接口设短超时快速失败,下单时调计费接口的预估价格因为需要查数据库和配置表,设置 3 秒较为合适。我习惯在每个 FeignClient 接口上用@FeignClient的configuration属性单独指定超时类,而不是全局统一配置,避免误伤其他调用链。feign: client: config: billing-service: connectTimeout: 2000 readTimeout: 30005.2 注册中心连接失败:Nacos 服务注册了却路由不到现象:服务启动日志里显示注册成功,但通过网关访问时一直报 503 Service Unavailable,或者服务间 Feign 调用报 “找不到服务实例”。原因:spring.cloud.nacos.discovery.register-enabled默认是 true,但服务注册成功并不代表服务发现列表是准的。最常见的情况是bootstrap.yml和application.yml里 Nacos 地址配置不一致:bootstrap.yml配了nacos.config.server-addr,而application.yml里 Nacos 注册地址没配,服务启动时连接了配置中心的 Nacos 却注册到另一个 Nacos 实例上。另外一个坑是网关卡在 Docker 里跑,服务暴露在宿主机上,Nacos 客户端上报的是容器内 IP,网关访问不到这个 IP,怎么转发都是失败。解决:先确认所有服务的注册地址一致,然后在 Nacos 控制台查看服务实例列表,看实例 IP 是否可以被网关所在网络访问。如果实例 IP 是 192.168.开头的容器 IP,配置spring.cloud.nacos.discovery.ip指定宿主机 IP 或者改成 Nacos 所在机器能访问的地址。5.3 RabbitMQ 消息消费幂等:重复消费导致重复派单现象:派单中心偶尔收到同一订单的两次派单消息,第一单司机接单后,第二单又被推送给另一个司机,造成“一单一接”甚至“双司机”的冲突。原因:消息队列的 At Least Once 语义下,消费者处理完消息但还没来得及返回 ack,消费者宕机或者网络抖动,MQ 会重新投递消息。项目里虽然有幂等设计但没覆盖派单消息:派单消息消费时没有先查消息消费记录表,导致同一消息被处理两次。解决:在派单中心增加一个消息消费记录表,消费前先 insert 一条记录(msg_id 做唯一索引),插入成功才执行派单逻辑,插入冲突则直接丢弃。这是本地消息表方案的镜像,也是 MQ 场景下最常用的幂等方案。注意 msg_id 必须是消息自身带有的唯一业务键,不能靠时间戳拼。CREATE TABLE `mq_consume_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `msg_id` VARCHAR(64) NOT NULL COMMENT 'MQ消息唯一ID', `order_no` VARCHAR(32) NOT NULL COMMENT '关联订单号', `consume_status` TINYINT NOT NULL DEFAULT '0' COMMENT '0消费中 1消费成功', `consume_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_msg_id` (`msg_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='MQ消费日志表';5.4 计费接口并发调用:预估价格每次不同,乘客端展示跳变现象:乘客发单前看一眼价格,跳到 45 元;重新进入页面再看,变成 43 元;再点一次,又变成 46 元。乘客端以为价格黑匣子,体验极差。原因:计费接口里按当前时间和车型每条规则都实时计算,但未做价格版本缓存。每次前端调estimate接口都要查一次规则表,如果运营后台正在调整高峰期间的计费规则,新旧规则会被不同请求查到,一个页面多次调用查到的不是同一版本规则。解决:给计费规则加上版本号机制,定时任务每 5 分钟把最新启用的规则加载到本地缓存(或 Redis),所有计算只从缓存读取,运营改动不会立刻生效但会在 5 分钟窗口内统一生效。同时在接口里增加一个estimate_version入参,前端页面在用户浏览期间固定传同一版本号,保证同一会话内的价格一致。这是很典型的“高频接口 + 低频配置”的性能与一致性权衡方案,在真实的计费服务里几乎必然存在。5.5 订单状态更新丢行:并发操作导致 update 影响行数为零现象:乘客取消订单的同时,司机刚好接单,两个操作同时更新订单状态,结果司机端显示“接单成功”但乘客端已经“已取消”,订单变成脏数据。原因:更新语句带原状态条件时,两个并发请求可能一个成功一个失败。如果失败的分支只是打日志而不做后续处理,那司机端看到的表面结果就是“接单成功”。这是乐观锁并发的典型场景,失败的一方需要感知到冲突并回滚自己的业务动作,不是直接吞掉。解决:接单接口里update driver_id = ? WHERE order_no = ? AND order_status = 待上车,如果影响行数为 0,说明订单状态已被其他请求改掉,立即返回“接单失败/订单已取消”给司机端,并触发司机端的 UI 刷新。类似的取消订单接口也做同样的处理。这个问题的本质是分布式环境下的 CAS 操作,要多想想“更新失败之后该干什么”,而不只是“如何防止更新失败”。6. 压测与验证:让订单服务扛住每秒 200 次下单的 JMeter 实操调完这套项目后我做的第一件事不是直接上线,而是先用 JMeter 压一遍核心链路,把服务的真实水位摸清楚。压测最值得做的是下单接口和派单接口这两条链,因为它们涉及远程调用、MQ 消息、Redis 读写和数据库更新,最能暴露服务的瓶颈。我的做法分三步:先压单接口找吞吐量上限,再压混合链路测资源竞争,最后调参数做一轮优化验证。下单接口的压测计划设计是这样的:线程组设 50 个并发线程,Ramp-Up 时间 5 秒,循环次数 200,每线程总共跑 200 次请求。这份资源自带的 SQL 里有构造好的乘客和订单测试数据,可以直接复用。启动后我先盯两件事:聚合报告里的 TPS 和错误率,以及订单服务的 GC 日志。# 压测时常用命令:实时查看JVM堆内存与GC情况 jstat -gcutil $(pgrep -f order-service) 1000 20输出结果依次是 S0、S1、E、O、M、YGC、YGCT、FGC、FGCT、GCT。我重点关注E(Eden 区使用率)和YGC(Young GC 次数)。如果 YGC 一秒超过 10 次,说明短生命周期对象创建太频繁,下单接口里的OrderInfo和EstimateResponse对象重复构建过多,先优化这部分再去考虑数据库索引和 SQL。一般下单接口在 50 并发下 YGC 每秒 3 到 5 次算健康,超过 8 次就需要关注了。数据库侧的瓶颈也不能忽视,压测过程中我并行执行show processlist看有没有慢 SQL 堆积。订单表的下单 insert 和状态 update 是核心压力点,如果出现锁等待,优先排查事务边界是否过长——下单逻辑里有没有把“发 MQ”或者“远程调用预估价格”圈到事务里。这套项目里事务边界控制得不错,但拿到手跑的时候我习惯自己检查一遍,凡是远程调用一律挪出事务,MQ 发送一律放到afterCommit。压测结束后的参数优化,我一般动两个配置:第一是线程池大小,Tomcat 默认最大线程数是 200,如果压测响应时间一直在 3 秒以上但 CPU 利用率不超过 60%,大概率是线程池打满了。把server.tomcat.threads.max调大到 400,同时把server.tomcat.accept-count调大到 1000,可以缓解短时洪峰的丢请求问题。第二是数据库连接池,默认 HikariCP 最大连接数是 10,压测到 100 并发时连接池撑不住,报Connection is not available, request timed out。调到 50 起步,但不要无脑调大——连接数超过数据库 CPU 核心数的 2 倍后,性能反而下降。server: tomcat: threads: max: 400 min-spare: 50 accept-count: 1000 spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000这里connection-timeout: 3000是我特意留的,如果连接池真的撑不住,让请求快速失败比一直排队强,前端可以做重试提示,而不是用户盯着页面转圈。压测时还要注意网关这层——如果网关压在单机上,NIO 线程数默认是 CPU 核数乘以 2,压测机并发上来后网关会先出瓶颈,优先调网关的线程数。验证优化是否有效的办法,我看三个指标:第一是 TPS 均值,第二是响应时间的 TP99,第三是错误率。TP99 是比平均响应时间更有说服力的指标——平均响应时间可能被少数慢请求拉高,TP99 才能真实反映绝大多数用户的体验。这套项目压完调到 200 并发,下单接口 TP99 能稳定在 800 毫秒以内,TPS 能到 200 左右,对于一台 4C8G 的机器来说已经很够看了。从那以后我每次拿到一套微服务项目,都会先花一晚上把它完整跑起来,再压一轮接口看看真实水位——因为只有跑通了、压过了,你才敢说这套代码你懂了。希望帮到你。本文还有配套的精品资源,点击获取
简介:一套基于Spring Cloud构建的网约车平台项目源码,涵盖乘客端、司机端、司机听单、派单调度、订单、计价、支付、钱包、短信验证码、地图等微服务模块,并集成Eureka注册中心、Config配置中心、Zuul网关、Hystrix熔断监控、Zipkin链路追踪等组件,适合学习微服务架构与网约车业务闭环的Java开发者。压缩包共1081个文件,以Java源码、XML配置、YML配置文件为主,另含少量静态资源、SQL脚本与文档,整体约137MB,目录区分业务层、能力层与Spring Cloud组件,便于按模块阅读和二次开发。已有2290人学习下载,对想从单体过渡到分布式架构、理解服务拆分与模块协作的读者具有参考价值。项目演示覆盖登录注册、验证码、派单接单、到达约定地点、接待旅客、开始行程、到达目的地、发起收款等核心流程,可直接对照梳理各服务间调用关系与数据流转。1. 基于 Spring Cloud 的网约车项目:这是一套能跑通全流程的分布式系统实战做后端这几年,我拆过不下二十套微服务项目,网约车类的基本都在两个极端:要么只搭了注册中心和几个空接口,要么业务堆得太满、日志错乱到根本没法排查。这套 OnlineTaxi 属于少见的完整型资源——乘客端、司机端、运营后台三端齐全,订单从发起到派单、计价、支付、结算的闭环是通的,订单和派单用的都是真实场景里那套状态机设计,不是拿 CRUD 充数。适合的人群很明确:刚啃完 Spring Cloud 理论、想把注册发现、熔断降级、分布式事务这些概念落到真实业务里的 Java 开发者,以及正在做毕设或简历项目、需要一套拿得出手代码的求职者。它最大的价值不是代码量,而是把网约车这种高并发、强一致、时效性要求极高的业务,用一套可复现的方式拆给你看。2. 微服务拆分与模块边界:订单、派单、计费怎么切才不会乱2.1 从单体出租车系统到微服务:先看服务划分逻辑拿到的这套项目里,服务拆分是照着网约车业务域来切的,不是按用户角色拍脑袋分。核心域拆成了这样:乘客端负责发单、取消、支付;司机端负责接单、行程、结算;订单中心是业务主链路,承接所有订单生命周期状态流转;派单中心独立成服务,负责调度策略和司机匹配;计费中心负责预估价格、实时计价和账单生成;支付服务管着支付渠道的对接和退款。加上基础设施类的网关服务、消息服务、定位服务,一共九个可独立部署的 Spring Boot 应用。这样切的好处是每个服务的职责足够单一,比如计费规则调整时不涉及订单状态,派单策略灰度时不影响乘客下单主流程。我拿到项目第一件事是先看每个模块的 pom 依赖和启动类,确认服务间调用边界。这套项目的调用方向是单向的——乘客端调用订单中心,订单中心通过消息通知派单中心,派单完成后再回调订单中心更新状态,计费中心被订单和司机端分别调用但不反向依赖。这种设计避免了循环调用,也是后面排查问题的基础。服务间通信全部走 OpenFeign,异步场景走 RabbitMQ,没有引入额外的 RPC 框架,学习成本低,也更容易替换成公司内部的 RPC 方案。2.2 数据库拆分与主键设计:分库分表之前的关键决策网约车场景的数据特征是订单量大、维度多、按时间查询频繁,这套项目采用的是一库多 Schema 的设计,没有直接上分库分表中间件,但表结构预留了拆分空间。订单表的主键是雪花算法生成的 Long 型 ID,订单号拆成三段:时间戳位(第 1 到 41 位)、机器标识位(第 42 到 52 位)、序列号位(第 53 到 64 位)。这套设计的好处是无需依赖数据库自增,插入时可以按时间维度直接排序,分表时路由也方便,按订单号模 16 或者基于时间范围都能路由到对应分片。订单表的核心字段里,有几个容易忽略的设计点值得抄作业:order_status用了 TINYINT 而不是 VARCHAR,状态值 0 到 8 覆盖从待支付到已完成、已取消的完整生命周期,状态流转在代码里是枚举类控制的,杜绝了外部直接改状态值。driver_id和passenger_id都建了联合索引((status, create_time)和(driver_id, status)),这是照着网约车两个核心查询场景来建的——乘客查进行中的订单、司机查正在派单的订单。我自己在设计订单类表时也沿用这套索引思路,效果比从网上复制一套通用索引规范好太多。CREATE TABLE `order_info` ( `id` BIGINT NOT NULL COMMENT '订单ID(雪花算法)', `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号', `passenger_id` BIGINT NOT NULL COMMENT '乘客ID', `driver_id` BIGINT DEFAULT NULL COMMENT '司机ID', `order_status` TINYINT NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2派单中 3待上车 4行程中 5待结算 6已完成 7已取消', `pickup_lat` DECIMAL(10, 6) NOT NULL COMMENT '上车点纬度', `pickup_lng` DECIMAL(10, 6) NOT NULL COMMENT '上车点经度', `dropoff_lat` DECIMAL(10, 6) NOT NULL, `dropoff_lng` DECIMAL(10, 6) NOT NULL, `estimate_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '预估金额', `real_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '实际金额', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_create` (`order_status`, `create_time`), KEY `idx_driver_status` (`driver_id`, `order_status`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='订单主表';这段 DDL 的关键点有两个:第一,订单时间字段统一用 DATETIME 而不是 TIMESTAMP,避免 2038 年问题,也方便后续按时间归档数据;第二,订单状态没有建外键、没有用数据库约束,所有状态变更都收敛到订单服务内部,这是微服务架构下的一致性原则——状态校验放在应用层,数据库只负责存储和查询加速。实操时如果用order_status作为查询条件且数据量已经过百万,务必保留这个联合索引,否则乘客端的订单列表接口会慢到让人怀疑人生。2.3 网关层与鉴权:统一入口怎么设计才不翻车这套项目的网关用的 Spring Cloud Gateway,路由规则按服务名转发,/api/passenger/**转到乘客端服务,/api/driver/**转到司机端服务,/api/order/**转到订单中心。网关层集中做了三件事:灰度路由(按请求头里的x-version打到不同版本的服务上去)、简单的日志埋点、以及 Token 校验。Token 校验这块用的是 JWT,乘客登录后拿一个 Token,网关层面解码校验 Token 的签名和时间戳,但角色信息在子服务里再校验。spring: cloud: gateway: routes: - id: passenger-service uri: lb://passenger-service predicates: - Path=/api/passenger/** - id: order-service uri: lb://order-service predicates: - Path=/api/order/** default-filters: - name: TokenValidate args: enable: true这段配置的注意点是:lb://前缀必须配合 Nacos 服务发现使用,如果服务没注册成功,路由直接 503。Token 校验做成 GlobalFilter,不仅拦截了需要登录的接口,还要设置白名单——登录接口、支付回调接口这些不能校验 Token,否则整个链路直接断掉。这块我在第一次跑的时候踩过坑,后面避坑章节细说。3. 核心链路落地:从乘客发单到司机接单再到行程计费3.1 乘客下单流程:订单状态机的流转细节乘客端下单的核心动作是先计算预估金额,然后创建订单,接着异步通知派单中心。接口设计是 POSTorder/create,请求体里带起点经纬度、终点经纬度、乘客 ID、车型类型。订单服务先调计费服务的estimate接口拿预估价格,再写订单表,状态置为“待支付”。这一步的设计是乘客先确认支付方式,支付动作完成后订单才真正进入派单池,避免支付失败却把司机调度过去,导致运力浪费。订单创建后的状态机流转是这套资源里最值得读源码的部分。状态机的定义在OrderStateMachine类里,定义了每个状态允许的触发事件和目标状态,比如“待支付”状态下只有pay_success事件能转发到“已支付”,cancel事件能转到“已取消”。如果代码里尝试从“行程中”直接跳到“已完成”,状态机会直接抛异常。这个设计在真实项目里非常有用——把状态流转规则收敛到一个地方,避免团队成员各自改状态导致数据错乱。我一般会在状态机转换的日志里加当前状态、目标状态、操作者 ID 三个字段,排查问题时效率能提升不少。@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private BillingServiceClient billingServiceClient; @Autowired private OrderStateMachine stateMachine; public OrderCreateResponse createOrder(OrderCreateRequest request) { // 1. 调用计费服务,计算预估价格(远程调用) EstimateResponse estimate = billingServiceClient.estimate( request.getPickupLat(), request.getPickupLng(), request.getDropoffLat(), request.getDropoffLng(), request.getCarType() ); // 2. 构建订单实体,状态默认待支付 OrderInfo order = new OrderInfo(); order.setOrderNo(SnowflakeUtil.nextId()); order.setPassengerId(request.getPassengerId()); order.setPickupLat(request.getPickupLat()); order.setPickupLng(request.getPickupLng()); order.setDropoffLat(request.getDropoffLat()); order.setDropoffLng(request.getDropoffLng()); order.setEstimateAmount(estimate.getAmount()); order.setOrderStatus(OrderStatusEnum.WAITING_PAYMENT.getCode()); // 3. 插入订单主表 orderMapper.insert(order); // 4. 发送 MQ 消息,异步唤醒派单调度 mqTemplate.convertAndSend("order.exchange", "order.create", order.getOrderNo()); return new OrderCreateResponse(order); } public void handlePayCallback(String orderNo, Integer payStatus) { // 支付回调:校验订单状态是否为待支付,然后变更 OrderInfo order = orderMapper.selectByOrderNo(orderNo); if (order != null && stateMachine.canTransition( order.getOrderStatus(), OrderStateEnum.PAY_SUCCESS)) { orderMapper.updateStatus(orderNo, OrderStateEnum.PAID.getCode()); mqTemplate.convertAndSend("order.exchange", "order.paid", orderNo); } } }这段代码反映了两个关键设计。第一,支付回调里的状态校验不是简单的对当前状态为空就更新,而是通过状态机判断可否转换,这样即使回调重复调用也不会重复改状态——幂等逻辑靠的是数据库更新时带WHERE order_status = 原状态的乐观锁实现。第二,订单创建成功和支付成功都发了 MQ 消息,但消息落库和订单状态变更不是同一个本地事务,这是后面要解决的分布式一致性问题,也是分布式事务章节的引子。3.2 派单策略:接单池与司机距离计算派单中心是这套项目里逻辑最重的地方,也是面试常问的重点。核心策略是“范围筛选 + 距离排序”:先把订单消息从 MQ 里拉出来,获取起点经纬度,然后从 Redis 的 GEO 结构里找出半径 5 公里内的所有在线司机,再按距离排序取前 10 位,依次推送订单到司机端。司机端响应的超时时间是 30 秒,如果 30 秒内无响应就顺延到下一批司机。Redis 的 GEO 数据结构大量使用,因为网约车的司机位置变更很频繁,用数据库算距离太慢。派单中心还有个细节是司机在线状态管理。司机 App 启动时通过 WebSocket 长连到司机端服务,司机端服务每隔 5 秒上报一次 GPS 坐标,坐标写入 Redis GEO 并设置 10 秒过期。这样如果司机网络断了,10 秒后 GEO 数据自然过期,司机从派单池里剔除,不需要显式调下线接口。这个设计很巧妙,属于“过期即下线”的思路,比维护一个在线状态表要简单得多。@Service public class DispatchService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private DriverClient driverClient; private static final double DISPATCH_RADIUS = 5000; // 5公里 public void dispatch(OrderDispatchMessage message) { // 1. 用订单起点经纬度查询附近司机(GEO搜索) Set<String> nearbyDrivers = redisTemplate.opsForGeo().radius( "driver:location", new Point(message.getPickupLng(), message.getPickupLat()), new Distance(DISPATCH_RADIUS, Metrics.KILOMETERS) ); if (nearbyDrivers == null || nearbyDrivers.isEmpty()) { // 附近无司机,订单标记为派单失败,进入等待队列 mqTemplate.convertAndSend("order.exchange", "order.dispatch_failed", message.getOrderNo()); return; } // 2. 距离排序,取最近10个司机逐个推送 List<String> driverIds = nearbyDrivers.stream().limit(10).collect(Collectors.toList()); for (String driverId : driverIds) { SendResult result = driverClient.pushOrder(driverId, message); if (result.isAccepted()) { // 3. 司机接受,更新订单表中司机ID,状态改为待上车 orderMapper.assignDriver(message.getOrderNo(), driverId); break; } } } }这里的参数调整空间主要在 DISPATCH_RADIUS 和推送数量上限。城市密度不同,派单半径应该跟着调:高峰期 3 公里,平峰期 5 公里,偏远区域可以放宽到 8 公里。推送数量上限太大,司机端会频繁弹单,体验差;太小,派单成功率低。建议把这俩参数配置到 Nacos 配置中心里,运营人员不用改代码就能调。我一般还会加一个“司机接单冷却时间”——司机刚拒单后 30 秒内不推送同类型订单,防止恶意拒单干扰派单效率。3.3 计费服务:里程费、时长费与远途费计费服务涉及的算法逻辑不算复杂,但边界条件多。基础计费公式是:总价 = 起步价 + 里程费 + 时长费 + 远途费(超出 15 公里后每公里加收)。这套代码里把计价规则做成了一张配置表billing_rule,每条规则包含车型、时段(早高峰、晚高峰、平峰)、起步价、每公里单价、每分钟单价、远途阈值等字段。接单时按当前时段查一条规则算出预估价格,行程结束后再按实际里程和时长结算。行程结束后,司机端会调billing/calculate接口,请求里带订单号、实际里程、实际时长,计费服务查订单的车型和用车时间,然后匹配计费规则表里的时段配置,返回最终金额并生成账单。这个接口的幂等性处理值得学习:订单号作为唯一业务键,重复调用时不会重复生成账单,而是在账单表里做唯一索引约束,第二次调用直接返回已存在的账单数据。CREATE TABLE `billing_rule` ( `id` INT NOT NULL AUTO_INCREMENT, `car_type` TINYINT NOT NULL COMMENT '车型 1快车 2专车 3豪华车', `time_type` TINYINT NOT NULL COMMENT '时段类型 1平峰 2早高峰 3晚高峰 4夜间', `base_price` DECIMAL(6, 2) NOT NULL COMMENT '起步价', `per_km_price` DECIMAL(6, 2) NOT NULL COMMENT '每公里单价', `per_min_price` DECIMAL(6, 2) NOT NULL COMMENT '每分钟单价', `long_distance_threshold` DECIMAL(6, 2) DEFAULT NULL COMMENT '远途费起算公里数', `long_distance_price` DECIMAL(6, 2) DEFAULT NULL COMMENT '远途费每公里附加价', PRIMARY KEY (`id`), KEY `idx_car_type_time` (`car_type`, `time_type`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='计费规则表';这段 DDL 的核心是在(car_type, time_type)上建联合索引,因为计价查询永远是这两个条件联合查询。时段字段在一个计费周期内一般不会改,用 TINYINT 够用。设计表的时候我注意到一个坑:时段类型和时间段值的映射逻辑写在代码枚举里,而不是数据库里直接存时间段范围,这样改时段价格不需要改代码,但调整时间段的起止时间(比如早高峰从 7 点改成 7 点半)就需要改枚举再发布,稍微麻烦一些。常见做法是把time_type映射成配置项存进 Nacos,让运营在控制台改。4. 分布式事务与数据一致性:订单支付、派单与计费如何不丢数据4.1 本地消息表与消息最终一致性:这笔交易先写库再发消息网约车业务里最典型的一致性问题在下单支付这个动作上:订单服务先更新订单状态为“已支付”,再通知派单中心开始派单。如果更新库成功而消息发送失败,订单就卡在已支付但无人派单的状态,乘客端显示“等待司机接单”但司机根本没有收到订单推送。这套项目采用的方案是本地消息表 + MQ 确认机制,不是正常工作中极少用到的强一致方案。@Transactional public void handlePayCallback(String orderNo, Integer payStatus) { // 1. 更新订单状态(本地事务) int rows = orderMapper.updateStatusWithGuard(orderNo, OrderStatusEnum.WAITING_PAYMENT.getCode(), OrderStatusEnum.PAID.getCode()); if (rows == 0) { throw new BizException("状态更新失败,可能已被处理"); } // 2. 同一事务内写入消息表 LocalMessage msg = new LocalMessage(); msg.setMsgId(UUID.randomUUID().toString()); msg.setMsgTopic("order.paid"); msg.setMsgPayload(orderNo); msg.setStatus(MessageStatusEnum.WAITING_SEND.getCode()); localMessageMapper.insert(msg); // 3. 事务提交后发送MQ消息(事务内不真正发送) TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { mqTemplate.convertAndSend("order.exchange", "order.paid", orderNo); localMessageMapper.updateStatus(msg.getMsgId(), MessageStatusEnum.SEND_SUCCESS.getCode()); } }); }这个设计的核心是“消息落库和业务操作在同一个本地事务里”,事务提交成功后才发 MQ,避免业务成功但消息丢失。afterCommit里再推送 MQ,如果 MQ 发送失败,消息表状态仍是“待发送”,会有定时任务扫描消息表中超过 10 秒仍为待发送的记录执行补偿重发。这套本地消息表方案的定位是“准最终一致性”,适合订单这种允许秒级延迟但不能丢失的场景。下一节要说的 TCC 模式则更适合资金类操作,比如支付退款。4.2 TCC 与补偿策略:这笔钱从乘客扣给司机,怎么保证各服务账目一致这套项目里涉及资金的操作分两块:乘客的支付动作对接了第三方支付渠道,属于外部调用无法本地事务控制;司机的收益结算则是内部服务间的资金操作。司机完成订单后,订单中心触发结算流程:先把乘客的支付账户标记“已消费”,同时把司机收益账户累加本次订单金额,再更新账单状态。这两个操作分布在支付服务和司机钱包服务里,如果不处理,订单完成但司机钱包没进账,就会出现资损。项目里对司机结算这块用了简单的 TCC 方案,虽然实现得比较轻量,但思路是标准的:Try 阶段先把司机账户的可结算余额冻结,Confirm 阶段把冻结余额转为可用余额,Cancel 阶段解冻并记录失败原因。这个方案在公司内部项目里很常见,完全用数据库行锁实现,不依赖第三方分布式事务中间件。@Transactional public void confirmSettlement(WalletSettlementRequest request) { // Try阶段:冻结司机账户中的本次结算金额 int freezeRows = walletMapper.freezeAmount(request.getDriverId(), request.getAmount()); if (freezeRows == 0) { // 余额不足或账户不存在,直接进入Cancel transactionManager.cancel(request); return; } // Confirm阶段:把冻结金额转为可用余额(这里模拟原项目中的confirm操作) int confirmRows = walletMapper.confirmFreeze(request.getDriverId(), request.getAmount()); if (confirmRows == 0) { // Confirm失败,进入Cancel回滚冻结 transactionManager.cancel(request); throw new BizException("结算确认失败"); } }这套轻量 TCC 的实现有个边界要知道:它只覆盖了两个内部服务之间的资金操作,没法覆盖第三方支付渠道的扣款,因为第三方渠道没有提供“冻结+确认”的一体化接口。所以实际项目中资金相关的操作只有一部分能用 TCC,其余更多是靠对账补偿来兜底。项目中定时任务每天凌晨拉取支付渠道的对账单,和本地支付记录做比对,对不上账的进人工处理队列。能看懂这套边界在哪里、哪些操作必须靠对账,比会用某个分布式事务框架重要得多。4.3 接口幂等设计:回调接口重复调用,订单状态还能保持正确我在排查这套项目时发现有个细节做得特别稳:几乎所有写操作的接口都做了幂等处理。支付回调是最典型的——如果乘客支付成功后网络超时,第三方渠道会重试回调,如果不处理会重复更新状态、重复发消息。项目里的做法是更新操作带原状态条件:int rows = orderMapper.updateStatusWithGuard(orderNo, OrderStatusEnum.WAITING_PAYMENT.getCode(), OrderStatusEnum.PAID.getCode());UPDATE order_info SET order_status = #{targetStatus} WHERE order_no = #{orderNo} AND order_status = #{sourceStatus},这样即使回调重复执行多次,也只有一个请求能改状态成功,其他请求返回的影响行数为 0,直接进入幂等处理逻辑。这个模式简单可靠,是微服务接口幂等的首要选择。另一个常用手段是状态机前置校验,二者搭配使用效果最好:状态机做逻辑校验,乐观锁做数据库兜底。我建议所有涉及状态流转的 update 语句都加上原状态条件,哪怕目前只有一个接口在调用,也要为之后的扩展和并发重复请求留好余地。5. 避坑指南:Spring Cloud 网约车项目跑起来之前必须处理的五个问题5.1 服务间调用超时:OpenFeign 默认超时导致下单接口假死现象:下单接口偶尔卡住,大概十几秒后才返回,高峰期频繁出现;翻开日志看到 OpenFeign 报Read timed out异常,但不是每次都会挂。原因:OpenFeign 默认连接超时是 10 秒,读取超时是 60 秒,在派单服务的某些接口里,如果司机端推送逻辑执行超过 60 秒,调用方大概率直接超时报错。这还不是最坑的——如果服务里配置了 Hystrix 或 Sentinel 熔断,默认超时时间是 1000 毫秒,请求稍微慢一点直接触发熔断,大批请求被快速失败。解决:按业务接口调整 OpenFeign 超时时间,派单推送这类实时性要求较高的接口设短超时快速失败,下单时调计费接口的预估价格因为需要查数据库和配置表,设置 3 秒较为合适。我习惯在每个 FeignClient 接口上用@FeignClient的configuration属性单独指定超时类,而不是全局统一配置,避免误伤其他调用链。feign: client: config: billing-service: connectTimeout: 2000 readTimeout: 30005.2 注册中心连接失败:Nacos 服务注册了却路由不到现象:服务启动日志里显示注册成功,但通过网关访问时一直报 503 Service Unavailable,或者服务间 Feign 调用报 “找不到服务实例”。原因:spring.cloud.nacos.discovery.register-enabled默认是 true,但服务注册成功并不代表服务发现列表是准的。最常见的情况是bootstrap.yml和application.yml里 Nacos 地址配置不一致:bootstrap.yml配了nacos.config.server-addr,而application.yml里 Nacos 注册地址没配,服务启动时连接了配置中心的 Nacos 却注册到另一个 Nacos 实例上。另外一个坑是网关卡在 Docker 里跑,服务暴露在宿主机上,Nacos 客户端上报的是容器内 IP,网关访问不到这个 IP,怎么转发都是失败。解决:先确认所有服务的注册地址一致,然后在 Nacos 控制台查看服务实例列表,看实例 IP 是否可以被网关所在网络访问。如果实例 IP 是 192.168.开头的容器 IP,配置spring.cloud.nacos.discovery.ip指定宿主机 IP 或者改成 Nacos 所在机器能访问的地址。5.3 RabbitMQ 消息消费幂等:重复消费导致重复派单现象:派单中心偶尔收到同一订单的两次派单消息,第一单司机接单后,第二单又被推送给另一个司机,造成“一单一接”甚至“双司机”的冲突。原因:消息队列的 At Least Once 语义下,消费者处理完消息但还没来得及返回 ack,消费者宕机或者网络抖动,MQ 会重新投递消息。项目里虽然有幂等设计但没覆盖派单消息:派单消息消费时没有先查消息消费记录表,导致同一消息被处理两次。解决:在派单中心增加一个消息消费记录表,消费前先 insert 一条记录(msg_id 做唯一索引),插入成功才执行派单逻辑,插入冲突则直接丢弃。这是本地消息表方案的镜像,也是 MQ 场景下最常用的幂等方案。注意 msg_id 必须是消息自身带有的唯一业务键,不能靠时间戳拼。CREATE TABLE `mq_consume_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `msg_id` VARCHAR(64) NOT NULL COMMENT 'MQ消息唯一ID', `order_no` VARCHAR(32) NOT NULL COMMENT '关联订单号', `consume_status` TINYINT NOT NULL DEFAULT '0' COMMENT '0消费中 1消费成功', `consume_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_msg_id` (`msg_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='MQ消费日志表';5.4 计费接口并发调用:预估价格每次不同,乘客端展示跳变现象:乘客发单前看一眼价格,跳到 45 元;重新进入页面再看,变成 43 元;再点一次,又变成 46 元。乘客端以为价格黑匣子,体验极差。原因:计费接口里按当前时间和车型每条规则都实时计算,但未做价格版本缓存。每次前端调estimate接口都要查一次规则表,如果运营后台正在调整高峰期间的计费规则,新旧规则会被不同请求查到,一个页面多次调用查到的不是同一版本规则。解决:给计费规则加上版本号机制,定时任务每 5 分钟把最新启用的规则加载到本地缓存(或 Redis),所有计算只从缓存读取,运营改动不会立刻生效但会在 5 分钟窗口内统一生效。同时在接口里增加一个estimate_version入参,前端页面在用户浏览期间固定传同一版本号,保证同一会话内的价格一致。这是很典型的“高频接口 + 低频配置”的性能与一致性权衡方案,在真实的计费服务里几乎必然存在。5.5 订单状态更新丢行:并发操作导致 update 影响行数为零现象:乘客取消订单的同时,司机刚好接单,两个操作同时更新订单状态,结果司机端显示“接单成功”但乘客端已经“已取消”,订单变成脏数据。原因:更新语句带原状态条件时,两个并发请求可能一个成功一个失败。如果失败的分支只是打日志而不做后续处理,那司机端看到的表面结果就是“接单成功”。这是乐观锁并发的典型场景,失败的一方需要感知到冲突并回滚自己的业务动作,不是直接吞掉。解决:接单接口里update driver_id = ? WHERE order_no = ? AND order_status = 待上车,如果影响行数为 0,说明订单状态已被其他请求改掉,立即返回“接单失败/订单已取消”给司机端,并触发司机端的 UI 刷新。类似的取消订单接口也做同样的处理。这个问题的本质是分布式环境下的 CAS 操作,要多想想“更新失败之后该干什么”,而不只是“如何防止更新失败”。6. 压测与验证:让订单服务扛住每秒 200 次下单的 JMeter 实操调完这套项目后我做的第一件事不是直接上线,而是先用 JMeter 压一遍核心链路,把服务的真实水位摸清楚。压测最值得做的是下单接口和派单接口这两条链,因为它们涉及远程调用、MQ 消息、Redis 读写和数据库更新,最能暴露服务的瓶颈。我的做法分三步:先压单接口找吞吐量上限,再压混合链路测资源竞争,最后调参数做一轮优化验证。下单接口的压测计划设计是这样的:线程组设 50 个并发线程,Ramp-Up 时间 5 秒,循环次数 200,每线程总共跑 200 次请求。这份资源自带的 SQL 里有构造好的乘客和订单测试数据,可以直接复用。启动后我先盯两件事:聚合报告里的 TPS 和错误率,以及订单服务的 GC 日志。# 压测时常用命令:实时查看JVM堆内存与GC情况 jstat -gcutil $(pgrep -f order-service) 1000 20输出结果依次是 S0、S1、E、O、M、YGC、YGCT、FGC、FGCT、GCT。我重点关注E(Eden 区使用率)和YGC(Young GC 次数)。如果 YGC 一秒超过 10 次,说明短生命周期对象创建太频繁,下单接口里的OrderInfo和EstimateResponse对象重复构建过多,先优化这部分再去考虑数据库索引和 SQL。一般下单接口在 50 并发下 YGC 每秒 3 到 5 次算健康,超过 8 次就需要关注了。数据库侧的瓶颈也不能忽视,压测过程中我并行执行show processlist看有没有慢 SQL 堆积。订单表的下单 insert 和状态 update 是核心压力点,如果出现锁等待,优先排查事务边界是否过长——下单逻辑里有没有把“发 MQ”或者“远程调用预估价格”圈到事务里。这套项目里事务边界控制得不错,但拿到手跑的时候我习惯自己检查一遍,凡是远程调用一律挪出事务,MQ 发送一律放到afterCommit。压测结束后的参数优化,我一般动两个配置:第一是线程池大小,Tomcat 默认最大线程数是 200,如果压测响应时间一直在 3 秒以上但 CPU 利用率不超过 60%,大概率是线程池打满了。把server.tomcat.threads.max调大到 400,同时把server.tomcat.accept-count调大到 1000,可以缓解短时洪峰的丢请求问题。第二是数据库连接池,默认 HikariCP 最大连接数是 10,压测到 100 并发时连接池撑不住,报Connection is not available, request timed out。调到 50 起步,但不要无脑调大——连接数超过数据库 CPU 核心数的 2 倍后,性能反而下降。server: tomcat: threads: max: 400 min-spare: 50 accept-count: 1000 spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000这里connection-timeout: 3000是我特意留的,如果连接池真的撑不住,让请求快速失败比一直排队强,前端可以做重试提示,而不是用户盯着页面转圈。压测时还要注意网关这层——如果网关压在单机上,NIO 线程数默认是 CPU 核数乘以 2,压测机并发上来后网关会先出瓶颈,优先调网关的线程数。验证优化是否有效的办法,我看三个指标:第一是 TPS 均值,第二是响应时间的 TP99,第三是错误率。TP99 是比平均响应时间更有说服力的指标——平均响应时间可能被少数慢请求拉高,TP99 才能真实反映绝大多数用户的体验。这套项目压完调到 200 并发,下单接口 TP99 能稳定在 800 毫秒以内,TPS 能到 200 左右,对于一台 4C8G 的机器来说已经很够看了。从那以后我每次拿到一套微服务项目,都会先花一晚上把它完整跑起来,再压一轮接口看看真实水位——因为只有跑通了、压过了,你才敢说这套代码你懂了。希望帮到你。本文还有配套的精品资源,点击获取