
拼车类业务做到后面最麻烦的往往不是“有人下单”而是“怎么把零散的需求变成一趟值得跑的单子”。举个实际场景早高峰从望京到西二旗5 分钟内有十几个人发单但每个人的出发地、目的地、期望出发时间都不一样。如果直接一单一单派司机不愿意接乘客等得久平台运力也被浪费。这时候就需要把多个需求“打包”成一个行程批次再统一调度也就是本文要讲的拼车打包packing。这篇文章会从业务概念出发梳理拼车打包系统的核心模块给出数据库表设计、接口设计和一个可运行的示例工程最后补充高频踩坑点和工程建议。无论你是刚接触出行类业务的后端开发还是想自己搭一个最小拼车 Demo 学习都可以照着这份教程一步步做下来。1. 什么是“拼车打包”它能解决什么问题“打包”这个词在出行场景里可以理解成一种聚合策略。平台不把每个乘客请求看成独立订单而是先收集一段时间内、空间上相近的出行需求把它们合并成一个“行程包”Trip Bundle再交由一个司机完成。这样做的好处很直观对乘客来说拼车价格更低并且只要打包合理总等待时间不会明显变长。对司机来说一趟能接到多个顺路订单收入更高空驶率下降。对平台来说单位时间的订单完成量提升运力利用率更高调度成本更低。可以简单理解成这样一个流程乘客发单 - 需求进入等待池 - 系统定期执行打包 - 生成行程包 - 司机可见并接单 - 按打包顺序接送在拼车系统里“打包”和“匹配”是两个不同环节。匹配解决的是“哪个司机适合接这个单”打包解决的是“哪些乘客需求适合放进同一个行程”。通常先打包再做司机匹配。本文重点放在打包环节。2. 系统整体设计做任何一个系统第一步不是写代码而是梳理边界和流程。先看一个最小可行闭环乘客发布出行需求包含出发地、目的地、期望时间。需求进入“待打包池”。定时任务或手动触发执行打包策略。系统把满足条件的若干需求合并成一个行程包。行程包生成后司机可以浏览、抢单或由系统派单。司机接单后乘客能看到自己被打包到哪个行程中并进入履约流程。从模块划分来看一个拼车打包系统至少包含以下部分模块职责需求模块接收、校验、存储乘客出行需求打包引擎核心算法决定哪些需求可以合并行程包模块维护行程包信息、乘客明细、状态流转派单模块将行程包推送给司机或等待司机抢单通知模块向乘客、司机发送打包结果和状态变更通知打包引擎是整个系统的核心。这个模块里要做三件事定义可打包条件比如出发地距离不超过多少公里、目的地方向是否接近、时间差是否在允许范围内。设计打包评分规则给每种组合打分分高的优先打包。控制打包上线一个行程包最多装多少人超过上限就拆包。这种设计思路不依赖具体技术栈即使你用的是 Python Flask、Java Spring Boot 还是 Node.js都可以按这个边界去实现。3. 环境准备与项目结构示例工程采用 Java Spring Boot H2 内存数据库原因是环境开销小克隆下来就能跑不需要额外安装 MySQL。实际生产环境可以把 H2 替换成 MySQL 或 PostgreSQL。版本说明JDK建议 17 及以上。Spring Boot示例使用 3.x具体版本请结合你本机环境调整。构建工具Maven 3.8。数据库H2 仅用于本地演示。如果你本机还没有 Java 开发环境先确保能执行java -version和mvn -version。版本不同不是重点重点是项目能正常启动。项目结构如下packing-demo ├── pom.xml ├── src/main/java/com/example/packing │ ├── PackingApplication.java │ ├── controller │ │ ├── DemandController.java │ │ └── BundleController.java │ ├── entity │ │ ├── Demand.java │ │ ├── TripBundle.java │ │ └── BundleItem.java │ ├── repository │ │ ├── DemandRepository.java │ │ ├── TripBundleRepository.java │ │ └── BundleItemRepository.java │ ├── service │ │ ├── PackingService.java │ │ └── DemandService.java │ └── strategy │ └── SimplePackingStrategy.java └── src/main/resources └── application.yml这个结构不复杂每个类都只负责一件事。实体类对应数据表Repository 负责数据访问Service 处理业务逻辑Strategy 封装可替换的打包策略。Controller 只做参数接收和结果返回不写业务逻辑。4. 数据库表设计业务千变万化表结构是相对稳定的部分。这里设计四张核心表。先说需求表用来存储乘客发布的原始出行需求。字段名类型说明idBIGINT主键passenger_idBIGINT乘客IDstart_lngDOUBLE出发地经度start_latDOUBLE出发地纬度end_lngDOUBLE目的地经度end_latDOUBLE目的地纬度start_timeDATETIME期望出发时间statusINT需求状态created_atDATETIME创建时间再考虑行程包表和明细表。行程包表保存打包结果明细表保存每个行程包和乘客需求的对应关系。这是典型的一对多设计一个行程包包含多条需求明细。TripBundle 表字段字段名类型说明idBIGINT主键bundle_noVARCHAR行程包编号start_lngDOUBLE行程包起点经度start_latDOUBLE行程包起点纬度end_lngDOUBLE行程包终点经度end_latDOUBLE行程包终点纬度passenger_countINT已打包人数statusINT行程包状态created_atDATETIME创建时间BundleItem 表字段字段名类型说明idBIGINT主键bundle_idBIGINT行程包IDdemand_idBIGINT需求IDseqINT在该行程中的序号statusINT该明细状态最后是状态字段的枚举定义。需求状态常用值0待打包1已打包2已取消行程包状态常用值0待接单1已接单2进行中3已完成4已取消这种状态设计在真实项目中可以直接展开成更细的状态机。只要状态流转清晰后面的代码写起来就不会乱。5. 核心功能实现5.1 发布拼车需求先定义一个简单的实体类 Demand。package com.example.packing.entity; import java.time.LocalDateTime; public class Demand { private Long id; private Long passengerId; private Double startLng; private Double startLat; private Double endLng; private Double endLat; private LocalDateTime startTime; private Integer status; private LocalDateTime createdAt; public Demand() { } public Demand(Long passengerId, Double startLng, Double startLat, Double endLng, Double endLat, LocalDateTime startTime) { this.passengerId passengerId; this.startLng startLng; this.startLat startLat; this.endLng endLng; this.endLat endLat; this.startTime startTime; this.status 0; this.createdAt LocalDateTime.now(); } // getter 和 setter 省略实际开发中可以使用 Lombok Data 简化 }需求发布接口对应的 Controller 可以这样写package com.example.packing.controller; import com.example.packing.entity.Demand; import com.example.packing.service.DemandService; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/api/demand) public class DemandController { private final DemandService demandService; public DemandController(DemandService demandService) { this.demandService demandService; } PostMapping public Demand createDemand(RequestBody Demand demand) { return demandService.createDemand(demand); } GetMapping(/pending) public ListDemand listPendingDemand() { return demandService.listPendingDemand(); } }DemandService 负责把新需求状态设为“待打包”并保存package com.example.packing.service; import com.example.packing.entity.Demand; import com.example.packing.repository.DemandRepository; import org.springframework.stereotype.Service; import java.util.List; Service public class DemandService { private final DemandRepository demandRepository; public DemandService(DemandRepository demandRepository) { this.demandRepository demandRepository; } public Demand createDemand(Demand demand) { demand.setStatus(0); return demandRepository.save(demand); } public ListDemand listPendingDemand() { return demandRepository.findByStatus(0); } }这里只使用了最基本的 JPA 方法。findByStatus(0)查询所有待打包需求。如果你的项目里需求量大查询时要加时间范围和分页避免全表扫描。5.2 行程打包算法打包策略是整个系统中最重要的地方。为了让你看懂核心思路这里用一个“简化版算法”演示不引入复杂的地理围栏和路径规划。核心逻辑分四步计算两个需求出发点之间的距离。计算两个需求目的点之间的距离。如果起点距离和终点距离都小于阈值则视为“可打包”。考虑时间因素期望出发时间差不能超过 15 分钟。距离计算采用球面距离公式也叫 Haversine 公式。这个公式适合短距离场景精度足够。package com.example.packing.strategy; public class GeoUtil { private static final double EARTH_RADIUS 6371.0; public static double distance(double lng1, double lat1, double lng2, double lat2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double deltaLat Math.toRadians(lat2 - lat1); double deltaLng Math.toRadians(lng2 - lng1); double a Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2); double c 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return EARTH_RADIUS * c; } }然后是具体的打包策略类package com.example.packing.strategy; import com.example.packing.entity.Demand; import org.springframework.stereotype.Component; import java.time.Duration; import java.util.ArrayList; import java.util.List; Component public class SimplePackingStrategy { private static final double MAX_DISTANCE_KM 3.0; private static final long MAX_TIME_DIFF_MINUTES 15; private static final int MAX_BUNDLE_SIZE 4; public ListListDemand pack(ListDemand demandList) { ListListDemand bundles new ArrayList(); boolean[] used new boolean[demandList.size()]; for (int i 0; i demandList.size(); i) { if (used[i]) { continue; } ListDemand bundle new ArrayList(); Demand base demandList.get(i); bundle.add(base); used[i] true; for (int j i 1; j demandList.size(); j) { if (used[j] || bundle.size() MAX_BUNDLE_SIZE) { continue; } Demand candidate demandList.get(j); if (canPack(base, candidate)) { bundle.add(candidate); used[j] true; } } bundles.add(bundle); } return bundles; } private boolean canPack(Demand d1, Demand d2) { double startDistance GeoUtil.distance( d1.getStartLng(), d1.getStartLat(), d2.getStartLng(), d2.getStartLat()); double endDistance GeoUtil.distance( d1.getEndLng(), d1.getEndLat(), d2.getEndLng(), d2.getEndLat()); long timeDiff Math.abs(Duration.between(d1.getStartTime(), d2.getStartTime()).toMinutes()); return startDistance MAX_DISTANCE_KM endDistance MAX_DISTANCE_KM timeDiff MAX_TIME_DIFF_MINUTES; } }这段代码有一个明显的优化空间它以第一个需求为基准向后找能够打包的需求没有做全局最优组合。如果你只是学习这个版本够用如果要做生产级算法建议引入打分机制把“起点更近、终点更近、时间差更小”的组合优先打包。也可以用贪心、聚类或者 OR-Tools 做更精细的规划。PackingService 把打包策略和行程包落库串起来package com.example.packing.service; import com.example.packing.entity.Demand; import com.example.packing.entity.TripBundle; import com.example.packing.repository.BundleItemRepository; import com.example.packing.repository.DemandRepository; import com.example.packing.repository.TripBundleRepository; import com.example.packing.strategy.SimplePackingStrategy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; Service public class PackingService { private final DemandRepository demandRepository; private final TripBundleRepository tripBundleRepository; private final BundleItemRepository bundleItemRepository; private final SimplePackingStrategy packingStrategy; public PackingService(DemandRepository demandRepository, TripBundleRepository tripBundleRepository, BundleItemRepository bundleItemRepository, SimplePackingStrategy packingStrategy) { this.demandRepository demandRepository; this.tripBundleRepository tripBundleRepository; this.bundleItemRepository bundleItemRepository; this.packingStrategy packingStrategy; } Transactional public int executePacking() { ListDemand pendingDemands demandRepository.findByStatus(0); ListListDemand bundles packingStrategy.pack(pendingDemands); int count 0; for (ListDemand bundleDemands : bundles) { if (bundleDemands.size() 2) { continue; } TripBundle tripBundle new TripBundle(); tripBundle.setStartLng(bundleDemands.get(0).getStartLng()); tripBundle.setStartLat(bundleDemands.get(0).getStartLat()); tripBundle.setEndLng(bundleDemands.get(0).getEndLng()); tripBundle.setEndLat(bundleDemands.get(0).getEndLat()); tripBundle.setPassengerCount(bundleDemands.size()); tripBundle.setStatus(0); tripBundle tripBundleRepository.save(tripBundle); for (int i 0; i bundleDemands.size(); i) { Demand demand bundleDemands.get(i); demand.setStatus(1); demandRepository.save(demand); // 保存明细 bundleItemRepository.save( new com.example.packing.entity.BundleItem( tripBundle.getId(), demand.getId(), i 1, 0)); } count; } return count; } }注意这里加了Transactional因为打包操作会同时更新需求状态、创建行程包、创建明细记录。如果不加事务一旦中途报错就会出现“需求状态已更新但行程包没生成”的数据不一致问题。5.3 行程包状态流转行程包生成后状态流转换可以用一个状态机来表示。下面是一个简化的流程待接单打包完成等待司机接单。已接单司机接单此时乘客会收到“司机已接单”的通知。进行中司机开始行程。已完成所有乘客都已到达目的地。package com.example.packing.controller; import com.example.packing.entity.TripBundle; import com.example.packing.repository.TripBundleRepository; import org.springframework.web.bind.annotation.*; import java.util.Optional; RestController RequestMapping(/api/bundle) public class BundleController { private final TripBundleRepository tripBundleRepository; public BundleController(TripBundleRepository tripBundleRepository) { this.tripBundleRepository tripBundleRepository; } GetMapping(/{bundleId}) public OptionalTripBundle getBundle(PathVariable Long bundleId) { return tripBundleRepository.findById(bundleId); } PostMapping(/{bundleId}/driver-accept) public TripBundle driverAccept(PathVariable Long bundleId) { TripBundle bundle tripBundleRepository.findById(bundleId).orElseThrow(); bundle.setStatus(1); return tripBundleRepository.save(bundle); } }状态变更时除了更新状态字段还建议记录状态变更日志。真实项目里状态流转绝对不能直接 UPDATE要走状态机校验。比如“已完成”的状态不能被改回“进行中”否则订单数据会被破坏。6. 运行与验证在项目根目录执行mvn spring-boot:run启动成功后可以用 curl 模拟两个乘客发单。先发第一个需求curl -X POST http://localhost:8080/api/demand \ -H Content-Type: application/json \ -d { passengerId: 1001, startLng: 116.397, startLat: 39.908, endLng: 116.317, endLat: 40.051, startTime: 2025-01-01T08:30:00 }再发第二个需求curl -X POST http://localhost:8080/api/demand \ -H Content-Type: application/json \ -d { passengerId: 1002, startLng: 116.400, startLat: 39.910, endLng: 116.320, endLat: 40.050, startTime: 2025-01-01T08:35:00 }这两个需求的起点距离约 0.3 公里终点距离约 0.3 公里时间差 5 分钟满足打包条件。此时调用打包接口curl -X POST http://localhost:8080/api/packing/execute返回结果应该显示成功打包了 1 个行程包。再查行程包接口curl http://localhost:8080/api/bundle/1从返回结果中可以看到这个行程包里包含了两位乘客状态是“待接单”。如果第二个需求的时间改成 09:30和第一个需求时间差超过 15 分钟打包结果就会变成 0。这是验证时间阈值是否生效最简单的方法。7. 常见问题与排查思路做拼车打包功能时最容易踩到下面几个坑。问题现象常见原因解决思路打包结果总是 0经纬度传反或起点距离超过阈值先打印参与计算的坐标用在线工具核对距离打包数量不稳定没有对需求按时间排序打包前先按 startTime 升序排列保证先到的需求优先组合一个乘客出现在多个行程包并发打包导致重复读取未锁定数据打包任务增加分布式锁或者对需求行加乐观锁司机接单后乘客看到的信息不对行程包和明细没有关联查询接口层要联合查询 BundleItem 和 Demand返回乘客完整信息数据量过万后接口变慢查询全部待打包需求导致内存溢出增加时间窗口比如只取 30 分钟内的待打包需求其中并发问题最容易忽略。如果你用定时任务每 5 分钟执行一次打包而对打包需求没有做状态条件控制很可能在任务重入时把同一批需求打包两次。最简单的规避方式是在执行打包前先更新需求状态为“打包中”再执行后面的逻辑。即使任务重复执行此时查询出的待打包需求不会包含正在打包的数据。如果项目量级再大建议引入 Redis 分布式锁锁的 key 可以设计成packing:lock:20250101注意锁粒度要和业务周期匹配避免整个服务被锁住。8. 最佳实践与工程建议8.1 把打包策略做成可插拔不要把所有算法都写在一个 Service 里。在真实项目中建议定义独立的PackingStrategy接口不同策略互相独立通过配置中心或开关切换。比如早高峰用“时间优先”策略平峰用“里程优先”策略。接口可以这样抽象public interface PackingStrategy { ListListDemand pack(ListDemand demandList); }后续想加新算法只需要实现接口然后通过 Spring 的Qualifier或者配置项指定具体实现。8.2 时间维度单独建索引拼车打包和订单查询都极其依赖时间字段。数据库设计时start_time和status要建联合索引否则定时任务每次查询待打包需求都会全表扫描。示例 SQLCREATE INDEX idx_demand_status_time ON demand(status, start_time);8.3 状态变更必须使用状态机现实业务中乘客可能取消需求、司机可能取消接单、行程可能超时未接单。状态一多代码里到处 if-else 就会失控。建议用枚举状态机管理待打包需求只能流转到“已打包”或“已取消”。已打包需求在司机接单前可以被乘客取消。行程包一旦“已接单”只有司机可以取消且只能流转到“已取消”或“进行中”。8.4 做好幂等控制乘客可能因为网络原因重复点发布按钮后端接口一定要做幂等。常用方案是前端生成requestId后端在 Redis 里缓存这个 ID重复请求直接返回上次结果。打包任务同理同一个批次只能生成一次行程包。8.5 日志要记录打包过程的决策信息调试算法类功能最怕“黑盒”。建议在打包引擎的每一步记录日志比如需求 1001 和 1002 起点距离 0.31km终点距离 0.28km时间差 5 分钟打包成功线上排查问题时这类日志比堆栈信息有价值得多。日志级别用 DEBUG生产环境按需开启。8.6 监控指标拼车打包系统至少需要关注以下指标打包成功率成功打包的需求数 / 总需求数。平均拼车人数单行程包内平均乘客数。等待时长从发单到打包完成的时间。行程包利用率实际拼车人数 / 行程包最大容量。这些指标反映出算法是否合理。如果平均拼车人数长期偏低说明打包条件太严格需要放宽阈值如果等待时长期偏高说明打包频率太低需要缩短定时任务周期。9. 总结与下一步拼车打包这个词听起来像一个小功能实际上牵扯到聚合策略、状态流转、并发控制、数据库设计和算法选择是一个很典型的后端综合场景。把上面这套流程跑通之后你已经掌握了拼车业务中最核心的一个闭环需求发布、需求等待、行程打包、行程包生成、司机接单。下一步可以往三个方向深入把简化版的“首需求基准打包”替换成带评分机制的聚类算法。接入真实地图 API用路线规划结果替代直线距离判断。增加司机端派单与抢单流程把打包和运力调度打通。第一次做这个项目时不要急着优化算法。先把状态流转和数据结构理清楚再慢慢调整打包策略你会发现业务逻辑稳定之后算法优化才有意义。如果文中有没讲透的地方欢迎在评论区提问我根据反馈继续补充实战细节和踩坑记录。