滴滴租车源码避坑速查手册:3个Bug教你调通 滴滴租车源码避坑速查手册:3个Bug教你调通 复制来的代码跑不通,报错信息像天书?别急,这坑我踩过。 做后端或全栈开发,常遇到“拿来主义”的代码。尤其是像滴滴租车这类高并发、复杂状态机业务,直接拷贝Demo往往因为环境依赖、状态初始化缺失而崩盘。 很多人卡在 NullPointerException 或 状态流转错误 上,其实不是逻辑错,而是上下文缺失。 这篇速查手册不灌鸡汤,直接拆解核心逻辑。我们不看大而全的架构,只抓最痛的三个点:订单状态机、库存扣减、回调幂等。 入口定位:从Controller到Service的断点 调试第一步,别盯着报错行。先找入口。 以租车订单创建为例,HTTP请求进来,经过网关、鉴权,最终落到 OrderService.createOrder。 很多拷贝代码在这里就断了。为什么? 因为 Demo 里的 OrderContext 没初始化。 核心代码片段 1:订单创建入口 @Service public class OrderService { @Autowired private VehicleInventoryDao inventoryDao; @Autowired private PaymentGateway paymentGateway; /** * 创建租车订单 * @param request 包含用户ID、车辆ID、起止时间 */ public OrderResponse createOrder(OrderRequest request) { // 1. 参数校验:这里很多Demo会省略,导致后续NPE if (request.getUserId() == null || request.getVehicleId() == null) { throw new BusinessException(参数不能为空); } // 2. 检查车辆库存:这是最容易出Bug的地方 // 错误示范:直接 get(),如果查不到返回 null,下一行直接炸 VehicleInventory inv = inventoryDao.getByVehicleId(request.getVehicleId()); // 正确做法:必须判空 if (inv == null || inv.getAvailableCount() = 0) { throw new BusinessException(车辆不可用或库存不足); } // 3. 构建订单对象 Order order = new Order(); order.setUserId(request.getUserId()); order.setVehicleId(request.getVehicleId()); order.setStatus(OrderStatus.PENDING); // 初始状态:待支付 order.setCreateTime(new Date()); // 4. 持久化订单 orderDao.save(order); // 5. 返回结果 return new OrderResponse(order.getId(), 创建成功); } } 逐行解析: @Autowired 注入:Spring 容器管理 Bean。如果你拷贝的代码里没有 @Service,或者没启动 Spring 上下文,这里就是 null。 if (request.getUserId() == null ...):这是第一道防线。很多开源 Demo 假设参数一定合法,实际生产中,前端可能传空,网关可能篡改。必须显式校验。 inventoryDao.getByVehicleId:数据库查询。注意,这里查的是“当前时刻”的库存。如果两个用户同时下单,这里可能出现脏读。 if (inv == null || inv.getAvailableCount() = 0):这是关键避坑点。很多新手直接写 inv.getAvailableCount(),如果 inv 是 null,直接 NullPointerException。调试时,如果报错在这一行,90% 是查不到数据,而不是逻辑错。 order.setStatus(OrderStatus.PENDING):状态机起点。租车业务的核心是状态流转:PENDING - PAID - IN_USE - RETURNED。初始状态必须明确。 调试技巧: 如果这里报错 NullPointerException,在 IDE 里打断点,检查 inv 是否为 null。如果为 null,去查数据库,看 vehicle_inventory 表里有没有对应 vehicleId 的记录。大概率是测试数据没插进去。 核心片段:库存扣减与并发控制 租车业务最痛的不是创建订单,而是扣库存。 高峰期,100 个人抢 1 辆热门车,怎么处理? 很多博客教你用 SELECT ... FOR UPDATE,但在高并发下,数据库锁开销巨大。 更常见的方案是:Redis 预扣减 + 数据库最终一致。 核心代码片段 2:Redis 预扣减库存 @Service public class InventoryService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private VehicleInventoryDao inventoryDao; /** * 预扣减库存 * @param vehicleId 车辆ID * @return true: 扣减成功, false: 库存不足 */ public boolean preDeductStock(String vehicleId) { // 1. 构造 Redis Key // 注意:Key 设计要有业务含义,方便排查 String key = car:stock: + vehicleId; // 2. 获取当前库存 String stockStr = redisTemplate.opsForValue().get(key); // 3. 处理 Redis 数据不存在的情况 // 场景:Redis 重启,或 Key 过期,或初始化失败 if (stockStr == null) { // 回源数据库 VehicleInventory inv = inventoryDao.getByVehicleId(vehicleId); if (inv == null) { throw new BusinessException(车辆不存在); } // 重新加载到 Redis redisTemplate.opsForValue().set(key, String.valueOf(inv.getAvailableCount()), 1, TimeUnit.DAYS); stockStr = String.valueOf(inv.getAvailableCount()); } // 4. 原子操作:只有大于 0 才能扣减 // DECR 是原子操作,保证并发安全 Long result = redisTemplate.opsForValue().decrement(key); // 5. 判断扣减结果 if (result != null result = 0) { return true; // 扣减成功 } else { // 扣减失败,说明库存不足 return false; } } } 逐行解析: String key = car:stock: + vehicleId;:Key 命名规范。加上业务前缀 car:stock:,在 Redis 控制台里一眼就能找到。别用 1, 2 这种数字做 Key。 redisTemplate.opsForValue().get(key):读缓存。如果 null,说明缓存失效。 if (stockStr == null):这是高频 Bug 点。很多代码没处理缓存穿透。如果 Redis 里没有数据,直接 Integer.parseInt(null) 会报错。必须回源数据库。 redisTemplate.opsForValue().set(key, ..., 1, TimeUnit.DAYS):设置过期时间。防止死锁,也防止内存泄漏。 redisTemplate.opsForValue().decrement(key):原子操作。这是并发控制的核心。decrement 返回的是扣减后的值。 if (result != null result = 0):判断逻辑。如果扣减后 result 是 -1,说明库存不够了。注意:这里 decrement 已经执行了,即使返回 -1,Redis 里的值也变成了 -1。这是一个隐患。 进阶避坑: 上面的代码有个小问题:如果 decrement 返回 -1,库存就变成负数了。下次再查,stockStr 是 -1,再扣减变成 -2。 修正方案: 使用 Lua 脚本保证原子性,或者在扣减前加一层判断。 // 推荐:使用 Lua 脚本 String script = if redis.call('get', KEYS[1]) = 1 then return redis.call('decr', KEYS[1]) else return -1 end; Long result = redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(key)); 这样,只有库存 = 1 时才扣减,否则返回 -1,且不改变 Redis 中的值。 设计思想:状态机与幂等性 为什么租车系统这么复杂? 因为状态多,且回调不可控。 用户支付后,支付网关会异步回调你的服务器。但网络不稳定,回调可能: 不回调。 回调多次。 回调顺序错乱(先收到“退款成功”,再收到“支付成功”)。 怎么解决?状态机 + 幂等。 状态机设计: PENDING (待支付) | v PAID (已支付) --- 支付成功回调 | v IN_USE (使用中) --- 用户取车 | v RETURNED (已还车) --- 用户还车 | v COMPLETED (已完成) --- 财务结算 关键点: 单向流转:状态只能从低到高,不能回头(除非是退款,那是另一个分支)。 幂等性:同一个回调,处理多次,结果必须一致。 核心代码片段 3:支付回调幂等处理 @Service public class PaymentCallbackService { @Autowired private OrderDao orderDao; @Autowired private RedisTemplateString, String redisTemplate; /** * 处理支付成功回调 * @param callbackData 支付网关回调数据 */ public void handlePaymentSuccess(PaymentCallbackData callbackData) { String orderId = callbackData.getOrderId(); String transactionId = callbackData.getTransactionId(); // 支付网关的交易号,唯一 // 1. 幂等性检查:用 Redis 记录已处理的交易号 String idempotentKey = pay:callback: + transactionId; // setIfAbsent: 如果 Key 不存在,则设置。返回 true 表示首次处理 Boolean isFirst = redisTemplate.opsForValue().setIfAbsent(idempotentKey, 1, 24, TimeUnit.HOURS); if (!Boolean.TRUE.equals(isFirst)) { // 重复回调,直接返回,不做任何处理 log.warn(Duplicate payment callback for transaction: {}, transactionId); return; } // 2. 查询订单 Order order = orderDao.findById(orderId); if (order == null) { throw new BusinessException(订单不存在); } // 3. 状态校验:只有 PENDING 状态的订单才能流转到 PAID if (order.getStatus() != OrderStatus.PENDING) { // 如果已经是 PAID 或其他状态,说明状态已流转,无需重复处理 // 但这里要注意:如果状态是 CANCELLED,应该告警 if (order.getStatus() == OrderStatus.CANCELLED) { log.error(Order {} is cancelled but received payment callback, orderId); // 可能需要触发退款流程 } return; } // 4. 更新订单状态 order.setStatus(OrderStatus.PAID); order.setPayTime(new Date()); orderDao.update(order); // 5. 发送消息通知后续流程(如通知仓库备车) messageQueue.send(order.paid, orderId); } } 逐行解析: String transactionId = callbackData.getTransactionId();:关键。用支付网关的交易号做幂等 Key,而不是订单号。因为一个订单可能多次支付(失败重试),但交易号是唯一的。 redisTemplate.opsForValue().setIfAbsent(...):幂等核心。setIfAbsent 是原子操作。如果 Key 已存在,返回 false,说明之前处理过。 if (!Boolean.TRUE.equals(isFirst)):重复请求直接丢弃。这是处理“回调多次”的标准姿势。 if (order.getStatus() != OrderStatus.PENDING):状态机校验。防止“已支付订单”被再次处理,也防止“已取消订单”被错误激活。 messageQueue.send(order.paid, orderId):解耦。支付成功后,不直接调用库存、通知等下游,而是发消息。这样即使下游挂了,支付状态也不受影响,可以通过消息重试。 MDN Web Docs 参考: 在处理前端回调或 API 交互时,可以参考 MDN Web Docs 中关于 HTTP 状态码和幂等性方法的定义。虽然这里是后端逻辑,但理解 HTTP 语义有助于设计更好的接口。 手写简化版:一个能跑的 Demo 别被上面的代码吓到。如果你只想快速跑通一个租车 Demo,可以这样简化: 简化版 OrderController @RestController @RequestMapping(/api/orders) public class OrderController { @Autowired private OrderService orderService; @Autowired private InventoryService inventoryService; @PostMapping public ResultOrderResponse createOrder(@RequestBody OrderRequest req) { try { // 1. 预扣库存 boolean stockOk = inventoryService.preDeductStock(req.getVehicleId()); if (!stockOk) { return Result.error(库存不足); } // 2. 创建订单 OrderResponse resp = orderService.createOrder(req); return Result.success(resp); } catch (Exception e) { // 3. 异常处理:如果创建订单失败,要回补库存 // 注意:这里简化了,实际应该用事务或补偿机制 inventoryService.restoreStock(req.getVehicleId()); return Result.error(e.getMessage()); } } } 简化版 InventoryService @Service public class InventoryService { @Autowired private StringRedisTemplate redisTemplate; public boolean preDeductStock(String vehicleId) { String key = car:stock: + vehicleId; // 简化:假设 Redis 里一定有数据 Long result = redisTemplate.opsForValue().decrement(key); return result != null result = 0; } public void restoreStock(String vehicleId) { String key = car:stock: + vehicleId; redisTemplate.opsForValue().increment(key); } } 注意: 这个简化版不安全。 没有处理 Redis 缓存穿透。 没有处理数据库最终一致性。 restoreStock 可能在订单创建成功后也被调用(如果后续步骤失败)。 仅用于学习流程,生产环境务必使用 Lua 脚本 + 数据库事务。 应用场景与职业建议 这套逻辑不仅适用于租车,也适用于电商秒杀、酒店预订、票务系统。 岗位日常职责边界: 初级开发:能读懂 OrderService,能修 NullPointerException,能写简单的 CRUD。 中级开发:能设计 Redis 预扣减方案,能处理幂等性,能写 Lua 脚本。 高级开发:能设计状态机,能处理分布式事务(如 Seata),能做性能压测和优化。 证书补办流程(比喻): 如果把代码比作证书,Bug 就是证书丢失。 排查流程:看日志(找线索) - 断点调试(验身) - 查文档(找补办处) - 修复(补证)。 考试科目: 并发:Redis 原子操作、数据库锁。 一致性:消息队列、分布式事务。 幂等:唯一索引、Redis 去重。 避坑总结: 永远判空:查数据库、查 Redis,结果都可能是 null。 原子操作:扣库存、改状态,必须用原子命令或事务。 幂等设计:所有回调、重试接口,必须幂等。 日志详尽:关键节点打日志,方便排查。 你公司项目里是怎么处理高并发扣库存的?是用 Redis 还是数据库乐观锁?欢迎评论区分享你的踩坑经验。