滴滴租车源码避坑速查手册:3个Bug教你调通
滴滴租车源码避坑速查手册:3个Bug教你调通
复制来的代码跑不通,报错信息像天书?别急,这坑我踩过。
做后端或全栈开发,常遇到“拿来主义”的代码。尤其是像滴滴租车这类高并发、复杂状态机业务,直接拷贝Demo往往因为环境依赖、状态初始化缺失而崩盘。
很多人卡在 NullPointerException 或 状态流转错误 上,其实不是逻辑错,而是上下文缺失。
这篇速查手册不灌鸡汤,直接拆解核心逻辑。我们不看大而全的架构,只抓最痛的三个点:订单状态机、库存扣减、回调幂等。
入口定位:从Controller到Service的断点
调试第一步,别盯着报错行。先找入口。
以租车订单创建为例,HTTP请求进来,经过网关、鉴权,最终落到 OrderService.createOrder。
很多拷贝代码在这里就断了。为什么?
因为 Demo 里的 OrderContext 没初始化。
核心代码片段 1:订单创建入口
@Service
public class OrderService {@Autowiredprivate VehicleInventoryDao inventoryDao;@Autowiredprivate PaymentGateway paymentGateway;/*** 创建租车订单* @param request 包含用户ID、车辆ID、起止时间*/public OrderResponse createOrder(OrderRequest request) {// 1. 参数校验:这里很多Demo会省略,导致后续NPEif (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 {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate 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(车辆不存在);}// 重新加载到 RedisredisTemplate.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 {@Autowiredprivate OrderDao orderDao;@Autowiredprivate 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 状态的订单才能流转到 PAIDif (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 {@Autowiredprivate OrderService orderService;@Autowiredprivate InventoryService inventoryService;@PostMappingpublic 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 {@Autowiredprivate 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 还是数据库乐观锁?欢迎评论区分享你的踩坑经验。