前两篇聊了整体架构和长连接心跳,这篇聊一个更刺激的:同一时间几十个司机抢一个订单,怎么保证不超卖、不漏单、不重复派。
抢单是代驾系统里并发压力最大的场景,比计价和派单都刺激------用户下单后系统按半径查附近在线司机推单,如果优推和普通派单都没人接,订单落入抢单池,池子里的订单所有附近司机都能看到,谁先点谁抢。
全部代码来自我们开源的代驾系统。
一、抢单入口:六道校验先挡掉非法请求
司机端点"抢单"按钮调 grabSingle 接口,入口处先做六道校验:
java
@PostMapping(OrderMapping.GRAB_SINGLE)
public RestResult grabSingle(@RequestBody OrderGrabSingleVo vo) {
Integer did = loginTokenService.validLoginDriverid(token);
DDriver driver = dDriverService.getById(did);
if (driver == null) return RestResult.fail("当前司机信息错误");
//1.司机是否被禁用
if (cal.isEquals(status, StaticUtils.STATUS_NO)) return RestResult.fail("当前司机信息无效");
//2.司机当前是否在接单状态
if (!cal.isEquals(state, StaticUtils.STATUS_YES)) return RestResult.fail("您当前不在接单状态");
//3.是否通过审核
if (!cal.isEquals(isAudit, StaticUtils.STATUS_YES)) return RestResult.fail("尚未通过审核");
//4.是否支持线上单
if (cal.isEquals(orderType, StaticUtils.STATUS_NO)) return RestResult.fail("当前账号不支持线上单");
//5.账号是否正常
RestResult restResult = dAccountService.checkAccount(did);
if (!restResult.status) return restResult;
//6.订单是否还在派单中
if (!cal.isEquals(orderStatus, OrderStatusEnums.SEND_ORDER.getId())) {
MsgVo msgVo = new MsgVo(NettyCodeEnums.DRIVER_GRAB_SINGLE_ORDERED);
UserChanelRel.get(StaticUtils.DRIVER + did).writeAndFlush(msgVo.toJsonStringbuf());
return RestResult.fail("订单已经被抢了");
}
这里有个细节:订单已被抢时,不是简单返回"已被抢",而是通过长连接推 DRIVER_GRAB_SINGLE_ORDERED,让司机端 App 主动刷新抢单池列表------否则司机看到的是一个已经不存在的订单,点了还是"已被抢",体验很差。
二、Redis 锁:2 秒锁住订单 + 司机锁
校验通过后,上锁:
java
//查看当前司机是否已被锁定(一司机一单)
String lock_driver = redisUtilsService.getKey(StaticUtils.DRIVER_LOCK_ORDER + did);
if (StringUtils.isNotBlank(lock_driver))
return RestResult.fail("您已经抢了一单了");
//缓存到redis加锁锁住订单
boolean oLock = true;
try {
oLock = redisUtilsService.lock("GRAB_SINGLE_" + orderCode, 2000);//锁住2秒
} catch (Exception e) {
e.printStackTrace();
oLock = false;
}
if (oLock) {
Map<String, Object> map = new HashMap<>();
map.put("orderCode", orderCode);
map.put("did", did);
map.put("handleType", 1);
map.put("lonAndLat", lonAndLat);
map.put("address", address);
Boolean aBoolean = orderCalculateService.sendGrabSingle(map);//发MQ
redisUtilsService.deleteKey("GRAB_SINGLE_" + orderCode);
if (!aBoolean) return RestResult.fail("您的网络有点慢,请稍后重试");
} else {
MsgVo msgVo = new MsgVo(NettyCodeEnums.DRIVER_GRAB_SINGLE_ORDERED);
UserChanelRel.get(StaticUtils.DRIVER + did).writeAndFlush(msgVo.toJsonStringbuf());
redisUtilsService.deleteKey(StaticUtils.DRIVER_LOCK_ORDER + did);
return RestResult.fail("订单已经被抢了哦");
}
return RestResult.success("正在抢单");
两层锁的设计:
GRAB_SINGLE_<orderCode>锁订单,2 秒过期------同一订单 2 秒内只有第一个司机能进 MQDRIVER_LOCK_ORDER_<did>锁司机------同一司机不能同时抢两单
锁成功后立即发 MQ,发完立刻删锁(不等消费完)。这样锁的作用范围是"防止同一秒内重复投递到 MQ",而不是"保证整个消费过程独占"------后者由 MQ 消费端的二次校验保证。
注意返回的是"正在抢单"而不是"抢单成功"------因为真正的抢单逻辑在 MQ 消费端异步执行,接口只负责"把请求塞进队列"。
三、RabbitMQ 配置:mandatory + confirm 保消息不丢
java
@Configuration
public class RabbitConfig {
public static final String GRAB_SINGLE = "grab_single";
@Bean
public Queue grabSingleQueue() {
return new Queue(GRAB_SINGLE);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMandatory(true);
//消息发送失败返回到队列(需 yml 配 publisher-returns: true)
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
log.debug("消息:{} 发送失败, 应答码:{} 原因:{} 交换机: {} 路由键: {}",
message.getMessageProperties().getCorrelationId(), replyCode, replyText, exchange, routingKey);
});
//消息确认(需 yml 配 publisher-confirms: true)
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
if (!ack) log.debug("消息发送到exchange失败,原因: {}", cause);
});
return rabbitTemplate;
}
}
mandatory=true 的作用是:消息找不到队列时不会静默丢弃,而是触发 ReturnCallback。配合 ConfirmCallback(确认消息是否到达 exchange),生产端的消息可靠性就闭环了。注意这两个回调都需要 yml 里开启对应的配置项,否则配了也不生效。
四、消费端:二次校验 + 手动 ack + 异常重试
java
@RabbitListener(queues = RabbitConfig.GRAB_SINGLE)
public void handleGrabSingle(Message message, Channel channel) throws IOException {
JSONObject map = JSONObject.parseObject(new String(message.getBody()));
String orderCode = map.getString("orderCode");
int did = Integer.parseInt(map.getString("did"));
//二次校验订单状态------防止锁过期后的重复消费
OrderMain orderMain = orderMainService.getOne(orderCode);
if (!cal.isEquals(orderStatus, OrderStatusEnums.SEND_ORDER.getId())) {
MsgVo msgVo = new MsgVo(NettyCodeEnums.DRIVER_GRAB_SINGLE_ORDERED);
UserChanelRel.get(StaticUtils.DRIVER + did).writeAndFlush(msgVo.toJsonStringbuf());
channel.basicAck(message.getMessageProperties().getDeliveryTag(), true);
return;
}
try {
//更新订单状态、司机状态、接单详情、抢单池记录
orderMain.setDid(did);
orderMain.setOrderStatus(OrderStatusEnums.AWAIT_CAR.getId());
orderMainService.updateById(orderMain);
driver.setState(StaticUtils.STATUS_SUCCESS);
dDriverService.updateById(driver);
grabSinglePondService.updateGrabSinglePond(orderMain.getId(), did);
//双通道推送:司机端"开始服务"+语音播报,用户端"司机已接单"+公众号模板消息
...
} catch (Exception e) {
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);//重新入队
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
channel.basicAck(message.getMessageProperties().getDeliveryTag(), true);
}
几个关键点:
二次校验:MQ 消费时再查一次订单状态。因为 Redis 锁只锁 2 秒,如果 MQ 积压了,2 秒后锁被删了第二个司机也能进 MQ。二次校验保证即使消息重复消费,也只有第一个能真正改订单状态。
手动 ack :用 basicAck 而非自动 ack。成功才确认,失败 basicNack 重新入队。这样即使消费过程中崩溃,消息也不会丢。
事务回滚 :setRollbackOnly() 配合 @Transactional,保证订单/司机/详情/抢单池四张表的更新要么全成功要么全回滚。
五、坦白几个不足
-
2 秒锁太短。MQ 积压超过 2 秒锁就过期了,虽然二次校验能兜底,但会产生"司机收到抢单成功提示但实际没抢到"的幻读。理想方案是 Redisson 分布式锁,带看门狗续期。
-
异常处理有矛盾代码 。catch 块里先
basicNack(..., true)重新入队,下一行又basicNack(..., false)丢弃消息------这两个调用是冲突的,第二个会让第一个失效,最终效果是丢弃。这应该是迭代中留下的 bug,真到生产环境要么重试要么死信队列,不能含糊。 -
消费端不是幂等的。二次校验能挡住"订单已变更"的情况,但如果消费到一半异常回滚、消息重新入队再消费,中间涉及的司机状态变更、推送等副作用操作会重复执行。需要用唯一键或状态机保证消费幂等。
-
单机 Redis 锁 。Redis 本身不是集群部署时,锁的可靠性等于 Redis 进程的可靠性。Redis 挂了锁就全没了。生产环境用 Redis Sentinel 或 Cluster,锁的命令应该用
SET key value NX PX 2000,而不是先 get 再 set(我们封装的 lock 方法实现上得检查一下)。
写在最后
抢单链路的核心设计是:入口校验 + Redis 双锁 + MQ 异步消费 + 二次校验 + 手动 ack + 事务回滚。整套代码在开源仓库里,RabbitConfig、DriverOrderMainController、OrderCalculateService 都可以直接看,协议以仓库 LICENSE 为准。系列下一篇准备聊聊支付回调的对账机制。