代驾系统实战(三):抢单池的 Redis 锁 + RabbitMQ 异步消费怎么做

前两篇聊了整体架构和长连接心跳,这篇聊一个更刺激的:同一时间几十个司机抢一个订单,怎么保证不超卖、不漏单、不重复派。

抢单是代驾系统里并发压力最大的场景,比计价和派单都刺激------用户下单后系统按半径查附近在线司机推单,如果优推和普通派单都没人接,订单落入抢单池,池子里的订单所有附近司机都能看到,谁先点谁抢。

全部代码来自我们开源的代驾系统。

一、抢单入口:六道校验先挡掉非法请求

司机端点"抢单"按钮调 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 秒内只有第一个司机能进 MQ
  • DRIVER_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,保证订单/司机/详情/抢单池四张表的更新要么全成功要么全回滚。

五、坦白几个不足

  1. 2 秒锁太短。MQ 积压超过 2 秒锁就过期了,虽然二次校验能兜底,但会产生"司机收到抢单成功提示但实际没抢到"的幻读。理想方案是 Redisson 分布式锁,带看门狗续期。

  2. 异常处理有矛盾代码 。catch 块里先 basicNack(..., true) 重新入队,下一行又 basicNack(..., false) 丢弃消息------这两个调用是冲突的,第二个会让第一个失效,最终效果是丢弃。这应该是迭代中留下的 bug,真到生产环境要么重试要么死信队列,不能含糊。

  3. 消费端不是幂等的。二次校验能挡住"订单已变更"的情况,但如果消费到一半异常回滚、消息重新入队再消费,中间涉及的司机状态变更、推送等副作用操作会重复执行。需要用唯一键或状态机保证消费幂等。

  4. 单机 Redis 锁 。Redis 本身不是集群部署时,锁的可靠性等于 Redis 进程的可靠性。Redis 挂了锁就全没了。生产环境用 Redis Sentinel 或 Cluster,锁的命令应该用 SET key value NX PX 2000,而不是先 get 再 set(我们封装的 lock 方法实现上得检查一下)。

写在最后

抢单链路的核心设计是:入口校验 + Redis 双锁 + MQ 异步消费 + 二次校验 + 手动 ack + 事务回滚。整套代码在开源仓库里,RabbitConfig、DriverOrderMainController、OrderCalculateService 都可以直接看,协议以仓库 LICENSE 为准。系列下一篇准备聊聊支付回调的对账机制。

相关推荐
行百里er2 小时前
Redis Streams——有确认、能回溯的消息队列
redis
小马同学-2 小时前
Redis消息队列与客户端编程
数据库·redis
fengkai45453 小时前
十二、Redis -2
运维·数据库·redis·容器
hweiyu003 小时前
Redis命令:BLPOP
redis·缓存
敲个大西瓜4 小时前
langchain笔记(一)
redis·笔记·langchain
小小龙学IT8 小时前
C++ Redis 客户端深度解析(hiredis 与 redis-plus-plus)——工业数采实时缓存 C++ 侧实战
c++·redis·缓存
mengge.cloud9 小时前
Redis
运维·服务器·数据库·redis·缓存·云计算
欣栀♡9 小时前
Redis 缓存穿透、击穿、雪崩,背出定义只是及格线
数据库·redis·缓存
codigger9 小时前
Redis 正式接入 AI:当"最懂速度的数据库"开始解决"记忆问题"
redis·分布式·后端·ai·向量检索