
滴滴租车源码避坑速查手册: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 还是数据库乐观锁?欢迎评论区分享你的踩坑经验。