简介一套基于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搜索 SetString 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个司机逐个推送 ListString 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。我重点关注EEden 区使用率和YGCYoung 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 的机器来说已经很够看了。从那以后我每次拿到一套微服务项目都会先花一晚上把它完整跑起来再压一轮接口看看真实水位——因为只有跑通了、压过了你才敢说这套代码你懂了。希望帮到你。本文还有配套的精品资源点击获取