外卖霸王餐券“超发”问题:Redisson 分布式信号量与 Lua 脚本双重校验方案

在外卖平台的“霸王餐”营销活动中,高并发抢券场景下的库存超发是极具挑战的技术难题。传统数据库行锁在万级 QPS 下极易成为瓶颈,而单纯依赖 Redis 原子操作又可能因网络抖动或代码逻辑漏洞导致数据不一致。本文提出一种基于 Redisson 分布式信号量(RSemaphore)与 Lua 脚本双重校验的解决方案,旨在确保极端流量下库存扣减的绝对准确性与系统的高可用性。

超发问题的根源与架构选型

超发的核心原因在于“读 - 改 - 写”非原子性。在集群环境下,多个应用实例同时读取库存、判断剩余数量、执行扣减,若缺乏强一致性锁机制,必然导致库存变为负数。虽然 Redis 的 DECR 命令是原子的,但在复杂的业务场景中(如需要校验用户资格、活动状态等),单纯依靠单个命令无法满足需求。

本方案采用“漏斗式”防御架构:第一层利用 Redisson 的 RSemaphore 进行粗粒度的并发控制,限制同时进入扣减逻辑的线程总数;第二层在 Redis 服务端执行 Lua 脚本,实现细粒度的库存校验与扣减原子操作。这种双重保障既避免了数据库锁的性能陷阱,又杜绝了客户端逻辑竞态条件带来的风险。

Redisson 分布式信号量限流

首先,利用 Redisson 的 RSemaphore 作为入口网关,控制并发线程数。信号量的初始值设置为库存总量或系统允许的最大并发阈值。

package com.baodanbao.com.cn.coupon.service;

import org.redisson.api.RSemaphore;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import com.baodanbao.com.cn.coupon.exception.CouponExhaustedException;

import java.util.concurrent.TimeUnit;

@Service
public class CouponConcurrencyControl {

    private final RedissonClient redissonClient;

    public CouponConcurrencyControl(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    /**
     * 尝试获取并发许可
     * @param activityId 活动ID
     * @param timeout 等待超时时间
     * @return 是否获取成功
     */
    public boolean tryAcquire(String activityId, long timeout) throws InterruptedException {
        String semaphoreKey = "lock:coupon:semaphore:" + activityId;
        RSemaphore semaphore = redissonClient.getSemaphore(semaphoreKey);
        
        // 初始化信号量,仅在首次执行时设置,实际生产中需结合活动配置动态设置
        // semaphore.trySetPermits(totalStock); 
        
        // 尝试在指定时间内获取许可,避免线程无限阻塞
        return semaphore.tryAcquire(1, timeout, TimeUnit.MILLISECONDS);
    }

    /**
     * 释放许可
     */
    public void release(String activityId) {
        String semaphoreKey = "lock:coupon:semaphore:" + activityId;
        RSemaphore semaphore = redissonClient.getSemaphore(semaphoreKey);
        if (semaphore != null) {
            semaphore.release();
        }
    }
}

在这里插入图片描述

Lua 脚本原子扣减逻辑

通过信号量后,必须执行 Lua 脚本进行最终的库存校验与扣减。Lua 脚本在 Redis 服务端单线程执行,保证了操作的原子性。脚本逻辑包括:检查库存是否大于 0、检查用户是否已领取、执行扣减并记录领取信息。

-- key[1]: 库存键 (stock:activity_id)
-- key[2]: 用户领取记录键 (user:record:activity_id)
-- argv[1]: 用户ID
-- argv[2]: 领取数量
-- 返回: 1-成功, 0-库存不足, 2-重复领取

local stockKey = KEYS[1]
local recordKey = KEYS[2]
local userId = ARGV[1]
local count = tonumber(ARGV[2])

-- 1. 校验是否重复领取
if redis.call('EXISTS', recordKey .. ':' .. userId) == 1 then
    return 2
end

-- 2. 获取当前库存
local currentStock = tonumber(redis.call('GET', stockKey))

-- 3. 库存不足直接返回
if not currentStock or currentStock < count then
    return 0
end

-- 4. 原子扣减库存
redis.call('DECRBY', stockKey, count)

-- 5. 记录用户领取信息 (设置过期时间,防止数据堆积)
redis.call('SET', recordKey .. ':' .. userId, '1', 'EX', 86400)

-- 6. 推送消息队列异步处理后续业务(可选,此处仅返回成功标记)
return 1

Java 服务层双重校验整合

在服务层将上述两个组件有机结合,形成完整的防超发链路。

package com.baodanbao.com.cn.coupon.service.impl;

import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import com.baodanbao.com.cn.coupon.service.CouponConcurrencyControl;
import com.baodanbao.com.cn.coupon.model.CouponResult;
import com.baodanbao.com.cn.coupon.exception.CouponExhaustedException;
import com.baodanbao.com.cn.coupon.exception.DuplicateClaimException;

import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.List;

@Service
public class CouponGrabService {

    private final StringRedisTemplate redisTemplate;
    private final CouponConcurrencyControl concurrencyControl;
    private DefaultRedisScript<Long> grabScript;

    public CouponGrabService(StringRedisTemplate redisTemplate, CouponConcurrencyControl concurrencyControl) {
        this.redisTemplate = redisTemplate;
        this.concurrencyControl = concurrencyControl;
    }

    @PostConstruct
    public void initScript() {
        ClassPathResource resource = new ClassPathResource("scripts/grab_coupon.lua");
        // 实际项目中建议从文件加载,此处为示例简化
        String scriptContent = loadScriptContent(); 
        grabScript = new DefaultRedisScript<>(scriptContent, Long.class);
    }

    public CouponResult grabCoupon(String activityId, String userId) {
        String stockKey = "stock:" + activityId;
        String recordKey = "user:record:" + activityId;
        List<String> keys = Collections.singletonList(stockKey); // 实际需传递两个key,此处演示逻辑
        
        // 第一重:分布式信号量限流
        try {
            if (!concurrencyControl.tryAcquire(activityId, 2000)) {
                throw new CouponExhaustedException("系统繁忙,请稍后重试");
            }
            
            // 第二重:Lua 脚本原子校验与扣减
            // 注意:keys 列表应包含 stockKey 和 recordKey
            Long result = redisTemplate.execute(
                grabScript, 
                Collections.singletonList(stockKey), // 实际应为 Arrays.asList(stockKey, recordKey)
                userId, 
                "1"
            );

            if (result == 1) {
                return new CouponResult(true, "领取成功");
            } else if (result == 0) {
                throw new CouponExhaustedException("优惠券已抢光");
            } else if (result == 2) {
                throw new DuplicateClaimException("您已领取过该优惠券");
            } else {
                throw new RuntimeException("未知错误");
            }
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("线程中断");
        } finally {
            // 务必释放信号量
            concurrencyControl.release(activityId);
        }
    }
    
    private String loadScriptContent() {
        // 模拟加载 Lua 脚本内容
        return "local stockKey = KEYS[1] ... return 1"; 
    }
}

异常处理与数据一致性保障

在该方案中,即使应用实例在扣减成功后宕机,由于 Lua 脚本已保证 Redis 中库存数据的原子变更,不会产生超发。后续的订单创建等操作可通过监听 Redis Key 失效或消息队列重试机制来保证最终一致性。若 Lua 脚本执行失败,信号量会在 finally 块中释放,确保不会发生死锁。

此方案通过 Redisson 信号量削峰填谷,利用 Lua 脚本在存储层锁定数据状态,完美解决了高并发下的超发痛点,适用于各类高价值券码的发放场景。

本文著作权归 俱美开放平台 ,转载请注明出处!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐