ElIauk

高并发系统设计:秒杀与库存扣减

秒杀的问题不是“卖出去”,是“抢进来” 一场一万件商品的活动,开场瞬间可能有几十万请求涌进来,而系统真正需要处理的只有一万次成功扣减。剩下 95% 以上的流量的正确归宿是“被快速拒绝”,而不是“排进数据库慢慢算”。 所以秒杀设计的第一原则是把无效流量挡在离数据库最远的地方:越靠前拒绝,成本越低。网关

评论
高并发系统设计:秒杀与库存扣减

秒杀的问题不是“卖出去”,是“抢进来”

一场一万件商品的活动,开场瞬间可能有几十万请求涌进来,而系统真正需要处理的只有一万次成功扣减。剩下 95% 以上的流量的正确归宿是“被快速拒绝”,而不是“排进数据库慢慢算”。

所以秒杀设计的第一原则是把无效流量挡在离数据库最远的地方:越靠前拒绝,成本越低。网关层限流一次拒绝的成本是微秒级,到了数据库层一次失败事务的成本是毫秒级,还可能带锁等待。第二原则是库存判断必须原子,任何“先查再改”的两步操作,在并发下都会超卖。

三道闸门

第一道是限流。按活动维度做全局限流,同时按用户、设备、IP 做频控,超出直接返回“活动太火爆”。这一步的目标不是精确,而是削峰:把 50 万 QPS 削到后端能承受的量级。静态资源与秒杀按钮也要前置到 CDN,页面本身不要每次都回源。

第二道是 Redis 预扣。库存提前预热到 Redis,用 Lua 脚本在服务端原子完成“校验用户是否已参与、校验库存、扣减、记录用户”四件事。脚本在 Redis 里单线程执行,天然互斥,不需要额外加分布式锁。

第三道是异步下单。预扣成功的请求写入 MQ 就立即返回“排队中”,订单创建、库存落库、优惠券核销由消费端慢慢做。这样前端响应时间和数据库压力彻底解耦,用户感知的是“抢到了,等待结果”。

Redis 侧原子扣减

-- KEYS[1] 活动库存 key,KEYS[2] 已参与用户集合
-- ARGV[1] 用户 id,ARGV[2] 扣减数量
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
  return -2
end

local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
local quantity = tonumber(ARGV[2])
if stock < quantity then
  return -1
end

redis.call('DECRBY', KEYS[1], quantity)
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], 172800)
return stock - quantity

返回值约定越简单越好:-2 表示重复参与,-1 表示售罄,非负数表示扣减后的余量。业务代码只做三个分支,不做二次判断。

@Service
public class SeckillService {

    private static final Logger log = LoggerFactory.getLogger(SeckillService.class);
    private static final DefaultRedisScript<Long> DEDUCT_SCRIPT =
            new DefaultRedisScript<>(DEDUCT_LUA, Long.class);

    private final StringRedisTemplate redisTemplate;
    private final SeckillProducer seckillProducer;

    public SeckillService(StringRedisTemplate redisTemplate, SeckillProducer seckillProducer) {
        this.redisTemplate = redisTemplate;
        this.seckillProducer = seckillProducer;
    }

    public SeckillResult trySeckill(Long activityId, Long userId, int quantity) {
        String stockKey = "seckill:stock:" + activityId;
        String userKey = "seckill:users:" + activityId;
        Long remain = redisTemplate.execute(DEDUCT_SCRIPT, List.of(stockKey, userKey),
                String.valueOf(userId), String.valueOf(quantity));
        if (remain == null) {
            log.error("秒杀脚本返回为空, activityId={}, userId={}", activityId, userId);
            return SeckillResult.fail("系统繁忙,请稍后重试");
        }
        if (remain == -2L) {
            return SeckillResult.fail("您已参与过本次活动");
        }
        if (remain == -1L) {
            return SeckillResult.fail("已售罄");
        }
        seckillProducer.send(new SeckillMessage(activityId, userId, quantity));
        return SeckillResult.queued(remain);
    }
}

数据库侧还要有一道兜底,防止消费端重放或补偿逻辑出错:

UPDATE seckill_stock
   SET available = available - #{quantity}, version = version + 1
 WHERE activity_id = #{activityId}
   AND available >= #{quantity};

影响行数为 0 就说明库存真的没了,此时要把 Redis 侧的扣减回补,并记录一条异常单供人工核对。悲观锁也能防超卖,但会把并发压到几百,秒杀场景不划算。

幂等与对账

MQ 是至少一次投递,重复消息一定会来。消费端的幂等不要靠“先查订单表再插入”,那在并发下同样有竞态。正确做法是给 (activity_id, user_id) 建唯一索引,插入时捕获 DuplicateKeyException 并当作成功处理——让数据库的唯一约束来做最终裁决。

对账要分清口径:活动进行中,Redis 库存是准入依据;活动结束后,订单表和支付流水才是事实来源。定时任务按这三个数据集做差集,差额落在“已扣未下单”“已下单未支付”“已支付未扣减”三个桶里,分别走补单、回补库存、人工核对。常见现象是活动结束后库存显示余 3 件却卖不出去,多半是回补逻辑把库存加回去了但用户集合没清理。

常见坑

用 SETNX 或 DECR 单独实现库存。 只能保证扣减原子,保证不了“一人一单”,超卖或单人抢多件的漏洞往往出在这里。

先 GET 再 DECR。 两步之间被其他请求插进来,库存变负,事后只能靠人工退款。

加分布式锁串行化整个下单流程。 锁的粒度一旦是活动维度,QPS 会掉到锁的吞吐上,秒杀变成排队。

库存预热和活动开关不同步。 活动开始前库存没进 Redis,脚本读到 -1 直接全员售罄;活动结束后忘记清理用户集合,下一场开始所有人都是“已参与”。

只防机器不防真人。 脚本、多设备、批量账号是秒杀流量的主要来源,验证码、设备指纹、风控黑名单要在第一道闸门之前就生效。

忘记回补。 用户取消订单、支付超时、履约失败都应该回补库存,否则看起来卖完了,实际少卖几十件。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者