整体思路

在秒杀活动场景中,高并发和库存超卖是两个核心挑战。使用 Redis 的内存操作速度以及原子性能力,可以高效地处理库存扣减与请求过滤,再配合 Java 后端进行异步落库、限流等操作,即可实现一个较为可靠的秒杀下单功能。下面给出一种常见的实现方案。

步骤

  1. 活动开始前:将秒杀商品的库存预加载到 Redis 中,使用 StringHash 结构存储。
  2. 秒杀请求到达
    • 令牌桶/漏桶等限流,防止瞬间流量压垮系统。
    • 基于用户 ID + 商品 ID 做重复下单校验(可放在 Redis 或本地缓存中)。
    • 利用 Redis Lua 脚本 原子地检查库存并扣减,返回是否成功。
  3. 成功者:将下单信息发送到消息队列(如 RocketMQ、RabbitMQ 或 Redis Stream),立即返回“排队中”或“成功”的提示。
  4. 异步消费者:从消息队列拉取订单数据,执行数据库落库操作(订单表、库存扣减记录等),确保最终一致性。
  5. 失败处理:库存不足或重复下单的请求直接返回“秒杀结束”或“已参与过”。

简化版时序图

用户 -> 限流器 -> 重复下单检查 -> Lua脚本(扣减Redis库存)
                                       | 成功
                                       v
                                发送MQ消息 -> 立即返回排队提示
                                       |
                                       v
                                消费者 -> 落库DB

为什么不直接使用分布式锁(如 Redisson)?

分布式锁也可以实现库存扣减,但在秒杀高并发场景下,性能远不如 Lua 脚本。Lua 脚本在单 Redis 实例上可以做到 O(1) 复杂度的原子操作,是最佳实践。

通过上述设计,利用 Redis 高速判断 + 原子扣减 + 异步解耦,可以有效支撑数十万甚至百万级的秒杀 QPS。实际部署时还要结合压测和监控,动态调整限流阈值和 MQ 消费能力。

秒杀功能详细Demo

以下提供一个完整的 Spring Boot + Redis 秒杀示例,重点展示如何使用 Redis setNx 短时标记(3秒)防止同一用户快速重复点击。代码包含:令牌桶限流(Redisson)setNx 防重标记Lua 脚本原子扣库存异步落库(线程池模拟 MQ)。可直接复制运行。

项目结构

src/main/java/com/example/seckill/
├── SeckillApplication.java
├── controller/SeckillController.java
├── service/SeckillService.java
├── service/RedisStockManager.java
├── limiter/RedissonRateLimiter.java
├── dao/OrderDao.java
└── entity/SeckillOrder.java
src/main/resources/
├── application.yml
└── lua/seckill_stock.lua

1. 依赖(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.14</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>seckill-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <properties>
        <java.version>8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.25.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2. 配置文件(application.yml)

spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    lettuce:
      pool:
        max-active: 20
server:
  port: 8080

3. Lua 脚本(src/main/resources/lua/seckill_stock.lua)

-- KEYS[1] : 库存key
-- ARGV[1] : 本次扣减数量(通常为1)
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[1]) then
    return 0
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1

4. 实体类(SeckillOrder.java)

package com.example.seckill.entity;

import java.util.Date;

public class SeckillOrder {
    private Long id;
    private Long userId;
    private Long productId;
    private Date createTime;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
    public Long getProductId() { return productId; }
    public void setProductId(Long productId) { this.productId = productId; }
    public Date getCreateTime() { return createTime; }
    public void setCreateTime(Date createTime) { this.createTime = createTime; }
}

5. 模拟 DAO(OrderDao.java)

package com.example.seckill.dao;

import com.example.seckill.entity.SeckillOrder;
import org.springframework.stereotype.Repository;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@Repository
public class OrderDao {
    private final ConcurrentHashMap<Long, SeckillOrder> orderTable = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    public boolean insertOrder(SeckillOrder order) {
        long id = idGenerator.getAndIncrement();
        order.setId(id);
        orderTable.put(id, order);
        return true;
    }

    // 检查该用户是否已成功秒杀过该商品(用于异步落库时的最终一致性)
    public boolean existsByUserIdAndProductId(Long userId, Long productId) {
        return orderTable.values().stream()
                .anyMatch(o -> o.getUserId().equals(userId) && o.getProductId().equals(productId));
    }
}

6. Redisson 令牌桶限流器(RedissonRateLimiter.java)

package com.example.seckill.limiter;

import org.redisson.api.RRateLimiter;
import org.redisson.api.RateType;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class RedissonRateLimiter {

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 尝试获取令牌(非阻塞,立即返回)
     * @param key      限流器key
     * @param rate     每秒生成的令牌数
     * @param capacity 桶容量
     * @return true-获取成功,false-被限流
     */
    public boolean tryAcquire(String key, double rate, int capacity) {
        RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
        // trySetRate 只会设置一次,后续调用不会覆盖已有配置
        rateLimiter.trySetRate(RateType.OVERALL, rate, capacity, RateIntervalUnit.SECONDS);
        return rateLimiter.tryAcquire(1);
    }
}

7. Redis 库存管理器(RedisStockManager.java)

package com.example.seckill.service;

import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.Collections;

@Component
public class RedisStockManager {

    private final RedisTemplate<String, Object> redisTemplate;
    private DefaultRedisScript<Long> stockScript;

    public RedisStockManager(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @PostConstruct
    public void init() {
        stockScript = new DefaultRedisScript<>();
        stockScript.setResultType(Long.class);
        stockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/seckill_stock.lua")));
    }

    /**
     * 原子扣减库存
     * @param stockKey 库存key
     * @param num      扣减数量
     * @return true-成功,false-库存不足
     */
    public boolean decrStock(String stockKey, int num) {
        Long result = redisTemplate.execute(stockScript, Collections.singletonList(stockKey), String.valueOf(num));
        return result != null && result == 1L;
    }

    /**
     * 预热库存(秒杀开始前调用)
     */
    public void initStock(Long productId, int stock) {
        String key = "seckill:stock:" + productId;
        redisTemplate.opsForValue().set(key, stock);
    }
}

8. 秒杀核心服务(SeckillService.java)- 重点:setNx 防重标记

package com.example.seckill.service;

import com.example.seckill.dao.OrderDao;
import com.example.seckill.entity.SeckillOrder;
import com.example.seckill.limiter.RedissonRateLimiter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.Duration;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Service
public class SeckillService {
    private static final Logger logger = LoggerFactory.getLogger(SeckillService.class);

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private RedisStockManager stockManager;
    @Autowired
    private RedissonRateLimiter rateLimiter;
    @Autowired
    private OrderDao orderDao;

    private ThreadPoolTaskExecutor asyncExecutor;

    @PostConstruct
    public void initExecutor() {
        asyncExecutor = new ThreadPoolTaskExecutor();
        asyncExecutor.setCorePoolSize(10);
        asyncExecutor.setMaxPoolSize(50);
        asyncExecutor.setQueueCapacity(200);
        asyncExecutor.setThreadNamePrefix("order-async-");
        asyncExecutor.initialize();
    }

    @PreDestroy
    public void destroyExecutor() {
        if (asyncExecutor != null) {
            asyncExecutor.shutdown();
        }
    }

    /**
     * 秒杀下单入口
     */
    public String seckill(Long userId, Long productId) {
        // 1. 全局限流(每秒5000,桶容量8000)
        if (!rateLimiter.tryAcquire("seckill:limiter:global", 5000, 8000)) {
            return "系统繁忙,请稍后再试";
        }

        // 2. 商品限流(每秒1000,桶容量1500)
        String productLimitKey = "seckill:limiter:product:" + productId;
        if (!rateLimiter.tryAcquire(productLimitKey, 1000, 1500)) {
            return "该商品太火爆,请稍后重试";
        }

        // 3. 重复下单检查(使用 Redis setNx + 3秒过期)⭐核心防重⭐
        String repeatKey = "seckill:repeat:" + userId + ":" + productId;
        // setIfAbsent 相当于 setNx,并同时设置过期时间(原子操作)
        Boolean acquired = redisTemplate.opsForValue()
                .setIfAbsent(repeatKey, "1", Duration.ofSeconds(3));
        if (Boolean.FALSE.equals(acquired)) {
            // 3秒内重复请求,直接拦截
            return "请勿重复下单";
        }

        // 4. 原子扣减Redis库存
        String stockKey = "seckill:stock:" + productId;
        boolean stockOk = stockManager.decrStock(stockKey, 1);
        if (!stockOk) {
            return "商品已售罄";
        }

        // 5. 扣减成功 -> 异步生成订单(落库)
        asyncExecutor.submit(() -> {
            try {
                // 二次检查数据库是否已存在订单(兜底,防止极端情况)
                if (orderDao.existsByUserIdAndProductId(userId, productId)) {
                    logger.warn("重复订单,userId={}, productId={},需补偿Redis库存", userId, productId);
                    // 此处可以触发库存回滚(实际生产中需要更可靠机制,这里仅演示)
                    return;
                }
                SeckillOrder order = new SeckillOrder();
                order.setUserId(userId);
                order.setProductId(productId);
                order.setCreateTime(new Date());
                orderDao.insertOrder(order);
                logger.info("订单生成成功,userId={}, productId={}", userId, productId);
            } catch (Exception e) {
                logger.error("异步落库失败,userId={}, productId={}", userId, productId, e);
                // 生产环境需要做补偿:重试或放入死信队列
            }
        });

        return "秒杀成功,订单处理中";
    }

    /**
     * 预热库存(供管理员调用)
     */
    public void preloadStock(Long productId, int stock) {
        stockManager.initStock(productId, stock);
        logger.info("商品 {} 库存已预热,数量:{}", productId, stock);
    }
}

9. Controller(SeckillController.java)

package com.example.seckill.controller;

import com.example.seckill.service.SeckillService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SeckillController {

    @Autowired
    private SeckillService seckillService;

    // 预热接口(实际由后台系统调用)
    @PostMapping("/preload")
    public String preload(@RequestParam Long productId, @RequestParam int stock) {
        seckillService.preloadStock(productId, stock);
        return "商品 " + productId + " 库存预热完成";
    }

    // 秒杀接口
    @PostMapping("/seckill")
    public String seckill(@RequestParam Long userId, @RequestParam Long productId) {
        return seckillService.seckill(userId, productId);
    }
}

10. 启动类(SeckillApplication.java)

package com.example.seckill;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SeckillApplication {
    public static void main(String[] args) {
        SpringApplication.run(SeckillApplication.class, args);
    }
}

如何运行与测试

  1. 确保本地 Redis 已启动(默认 6379)。
  2. 运行 SeckillApplication
  3. 预热库存:执行 POST http://localhost:8080/preload?productId=1001&stock=10
  4. 模拟秒杀:用 JMeter 或 curl 多次请求 POST http://localhost:8080/seckill?userId=1&productId=1001。同一用户在 3 秒内重复请求会被 setNx 拦截,提示“请勿重复下单”。
  5. 3 秒后再次请求同一用户,可正常尝试(若库存还有剩余)。

关键点说明

  • setNx 短时标记setIfAbsent(repeatKey, "1", Duration.ofSeconds(3)) 原子性完成“不存在则设置+过期时间”,3 秒内同一用户+商品只能通过一次请求。
  • 为什么是 3 秒:覆盖正常网络往返时间 + 服务处理时间,同时允许用户因超时或网络问题在稍后重试,避免永久锁定。
  • 数据库兜底:异步落库前依然检查 orderDao.existsByUserIdAndProductId,防止因标记失效或 Redis 故障导致重复订单。
  • 限流与防重分工:限流保护系统不被流量冲垮,setNx 针对单个用户短时防重复点击,二者不矛盾且互补。

补充优化与注意事项

  • 提前返回:库存扣减失败或重复下单等检查应尽早返回,不要在业务上锁。
  • 热点数据:Redis 单点故障问题可使用 Redis Cluster 或 代理层;但秒杀场景下最好用主从 + 哨兵。
  • 库存数据库同步:可以在活动结束后通过定时任务比对 Redis 和 DB 库存,或者每次秒杀成功异步扣减 DB 库存(用 乐观锁 + version 字段)。
  • 客户端防重:前端点击秒杀后立即置灰,并配合后端重复标记。
  • 缓存击穿:秒杀开始时所有请求可能打在一个 key 上,可以使用 本地缓存(Caffeine) 做一级过滤,但必须保证一致性不强求时可用。
  • 订单查询:用户秒杀后要提供结果查询接口,从 Redis 或 DB 中读取生成状态。
Logo

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

更多推荐