侧边栏壁纸
博主头像
兰若春夏 博主等级

一日为坤,终生为坤

  • 累计撰写 22 篇文章
  • 累计创建 12 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

Java秒杀系统设计全解析

奥德坤
2025-07-27 / 0 评论 / 0 点赞 / 1 阅读 / 0 字

Java秒杀系统设计全解析

一、 秒杀业务的本质与挑战

秒杀(Flash Sale)业务的核心特征是“瞬时高并发”和“资源稀缺”(狼多肉少)。这带来了四大核心挑战:

  1. 瞬时高并发流量:在极短时间内,系统将承受远超平时数十倍甚至上百倍的请求压力,对服务器、网络、数据库都是巨大考验。
  2. 库存超卖:高并发下,简单的“读-改-写”库存操作极易出现数据不一致,导致卖出的商品数量超过实际库存。
  3. 热点数据瓶颈:所有请求都集中在同一个商品上,导致数据库或缓存中的特定“行”或“Key”成为性能瓶颈。
  4. 恶意请求/机器人:大量“黄牛”使用脚本和机器人抢购,挤占正常用户的机会,必须有效甄别和拦截。

二、 核心设计原则

为了应对上述挑战,我们的设计必须遵循以下原则,层层过滤请求,将压力化解于无形。

  • 将请求拦截在上游:尽可能在系统架构的前端环节过滤掉无效请求。
  • 动静分离:将静态资源(HTML/CSS/JS/图片)与动态业务逻辑分离,利用CDN加速,降低后端服务器压力。
  • 读多写少,尽量异步:秒杀场景是典型的读多写少。读取库存、商品信息等操作应尽可能走缓存。下单、支付等写操作应通过消息队列(MQ)异步处理,削峰填谷。
  • 数据分层与热点隔离:利用多级缓存(本地缓存、分布式缓存)来处理热点数据,避免流量直接穿透到数据库。

三、 系统整体架构

一个成熟的秒杀系统架构应该是分层的,每一层都承担特定的职责。

graph TD
    subgraph 用户端
        A[用户浏览器/App]
    end

    subgraph 接入层
        B[CDN] --> C[Nginx/API Gateway];
    end
    
    subgraph 业务逻辑层
        C -- 秒杀请求 --> D{秒杀服务};
        D -- 校验库存/资格 --> E[Redis集群];
        D -- 下单消息 --> F[消息队列 MQ];
    end

    subgraph 后端服务
        G[订单服务] -- 消费消息 --> F;
        G -- 创建订单 --> H[数据库集群];
    end

    subgraph 辅助系统
        I[风控/安全系统] -- 实时监控 --> C;
        J[数据预热服务] -- 预加载数据 --> E;
    end
    
    A --> B;
    E -.-> H;
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style H fill:#bbf,stroke:#333,stroke-width:2px

架构解析

  1. 用户端 (Client):进行一些前端优化,如点击按钮后置灰,防止用户重复提交。
  2. 接入层 (Access Layer)
    • CDN:缓存商品详情页等静态资源,用户就近访问,源站压力大减。
    • Nginx / API Gateway:作为流量入口,承担负载均衡、动静分离、限流(令牌桶/漏桶算法)、黑白名单、WAF防火墙等职责,这是第一道防线。
  3. 业务逻辑层 (Business Logic)
    • 秒杀服务 (Seckill Service):核心服务,处理秒杀资格校验和库存扣减。它不直接操作数据库
    • Redis 集群:作为主战场,存放库存、用户购买记录等热点数据。利用其单线程原子性特性处理库存。
    • 消息队列 (Message Queue - MQ):如 RocketMQ 或 Kafka。实现业务解耦和流量削峰。秒杀成功后,将订单信息丢入MQ,由下游服务慢慢消费。
  4. 后端服务 (Backend Service)
    • 订单服务 (Order Service):订阅MQ中的消息,负责将订单信息持久化到数据库。
    • 数据库 (Database):作为数据的最终存储。采用主从分离、分库分表来提升性能。
  5. 辅助系统 (Auxiliary System)
    • 风控系统:识别和拦截恶意请求。
    • 数据预热服务:秒杀开始前,将商品库存等信息从数据库加载到Redis中。

四、 核心流程与技术实现

下面我们深入到最关键的环节:库存扣减

流程图

sequenceDiagram
    participant User as 用户
    participant Gateway as 网关/Nginx
    participant SeckillSvc as 秒杀服务
    participant Redis
    participant MQ as 消息队列
    participant OrderSvc as 订单服务
    participant DB as 数据库

    User->>Gateway: 发起秒杀请求
    activate Gateway
    Gateway->>Gateway: 1. 限流/风控校验
    Gateway->>SeckillSvc: 转发合法请求
    deactivate Gateway
    activate SeckillSvc
    
    SeckillSvc->>Redis: 2. 执行Lua脚本(原子操作)
    activate Redis
    Note right of Redis: 检查库存 & 检查用户是否已购
    Redis-->>SeckillSvc: 返回扣减结果 (成功/失败)
    deactivate Redis
    
    alt 库存扣减成功
        SeckillSvc->>MQ: 3. 发送下单消息 (userId, productId)
        SeckillSvc-->>User: 返回“抢购排队中”
    else 库存不足或已购买
        SeckillSvc-->>User: 返回“已售罄”或“您已购买”
    end
    deactivate SeckillSvc
    
    Note over OrderSvc, DB: --- 以下为异步流程 ---
    
    OrderSvc->>MQ: 4. 消费下单消息
    activate OrderSvc
    OrderSvc->>DB: 5. 创建订单 (乐观锁更新)
    activate DB
    DB-->>OrderSvc: 订单创建成功
    deactivate DB
    OrderSvc->>User: (可选)推送下单成功通知
    deactivate OrderSvc

关键技术点与Java代码实现

1. 数据预热

在秒杀开始前,通过定时任务或管理后台操作,将库存加载到Redis。

// 使用 Spring Boot 和 RedisTemplate 的示例
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductMapper productMapper; // MyBatis Mapper

public void preheatData(Long productId) {
    Product product = productMapper.selectById(productId);
    if (product != null && product.getStock() > 0) {
        String stockKey = "seckill:stock:" + productId;
        String userSetKey = "seckill:users:" + productId;
        
        // 设置库存
        redisTemplate.opsForValue().set(stockKey, String.valueOf(product.getStock()));
        
        // (可选)清空已购买用户集合,为新一轮秒杀做准备
        redisTemplate.delete(userSetKey);
        
        System.out.println("商品ID: " + productId + " 数据预热成功,库存: " + product.getStock());
    }
}

2. 库存扣减:Redis + Lua 脚本(原子性保证)

这是防止超卖和保证性能的最佳实践。Lua脚本在Redis中是原子执行的,可以把多个命令打包,避免了Java应用和Redis之间的多次网络往返和竞态条件。

Lua 脚本 (seckill.lua)

-- KEYS[1]: 库存的Key (e.g., seckill:stock:1001)
-- KEYS[2]: 已购买用户集合的Key (e.g., seckill:users:1001)
-- ARGV[1]: 当前请求的用户ID (e.g., 9527)

-- 检查用户是否已购买
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
    return 2 -- 2: 代表重复购买
end

-- 获取库存
local stock = tonumber(redis.call('get', KEYS[1]))

-- 检查库存是否充足
if stock and stock > 0 then
    -- 扣减库存
    redis.call('decr', KEYS[1])
    -- 将用户ID添加到已购买集合
    redis.call('sadd', KEYS[2], ARGV[1])
    return 1 -- 1: 代表成功
else
    return 0 -- 0: 代表库存不足
end

Java 调用 Lua 脚本

import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;

@Service
public class SeckillService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    private DefaultRedisScript<Long> seckillScript;

    // 在构造函数或@PostConstruct中加载Lua脚本
    @PostConstruct
    public void init() {
        seckillScript = new DefaultRedisScript<>();
        seckillScript.setResultType(Long.class);
        seckillScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/seckill.lua")));
    }

    public long processSeckill(Long productId, Long userId) {
        String stockKey = "seckill:stock:" + productId;
        String userSetKey = "seckill:users:" + productId;

        // 执行Lua脚本
        Long result = redisTemplate.execute(
            seckillScript,
            Arrays.asList(stockKey, userSetKey), // KEYS
            String.valueOf(userId)              // ARGV
        );

        return result;
    }
}

// 在Controller中调用
@RestController
public class SeckillController {
    @Autowired
    private SeckillService seckillService;
    @Autowired
    private MQSender mqSender;

    @PostMapping("/seckill/{productId}")
    public ResponseEntity<String> doSeckill(@PathVariable Long productId, @RequestHeader("userId") Long userId) {
        // ... (前置校验,如活动是否开始等) ...

        long result = seckillService.processSeckill(productId, userId);

        if (result == 1) {
            // 成功,发送异步下单消息
            mqSender.sendSeckillMessage(new SeckillMessage(userId, productId));
            return ResponseEntity.ok("恭喜您,正在排队中,请稍后查看订单...");
        } else if (result == 2) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("您已经抢购过了,请勿重复操作!");
        } else { // result == 0 or null
            return ResponseEntity.status(HttpStatus.GONE).body("非常抱歉,商品已售罄!");
        }
    }
}

3. 异步下单与数据库最终一致性

订单服务消费MQ消息,创建真实订单。为防止MQ重复消费等问题导致订单重复创建,数据库层面也需要一道防线。

  • 唯一索引:在订单表上建立 (user_id, product_id) 的唯一索引,防止同一用户对同一商品下多个订单。
  • 乐观锁:在商品库存表(如果需要同步更新)上使用version字段,确保更新时数据未被其他线程修改。
// 订单服务消费端 (伪代码)
@Service
public class OrderConsumer {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ProductMapper productMapper;

    @RabbitListener(queues = "seckill.queue")
    public void receiveMessage(SeckillMessage message) {
        try {
            // 1. 检查是否已生成订单 (利用唯一索引的特性)
            if (orderMapper.findOrderByUserAndProduct(message.getUserId(), message.getProductId()) != null) {
                System.out.println("订单已存在,忽略重复消息: " + message);
                return;
            }

            // 2. 创建订单
            Order order = new Order();
            // ... 设置订单属性
            orderMapper.insert(order);

            // 3. (可选) 扣减数据库库存,使用乐观锁
            // int affectedRows = productMapper.decreaseStockOptimistic(message.getProductId(), version);
            // if (affectedRows == 0) {
            //     // 出现了极端情况,数据库库存不足,需要记录日志或补偿
            // }

        } catch (DuplicateKeyException e) {
            // 唯一索引冲突,说明是重复消费,正常忽略
            System.out.println("唯一键冲突,忽略重复消息: " + message);
        } catch (Exception e) {
            // 其他异常,需要记录日志,考虑消息重试或人工干预
            System.err.println("创建订单失败: " + message);
        }
    }
}

五、 安全与防刷

  • 隐藏秒杀接口地址:秒杀开始前,接口地址不暴露,通过前端动态获取。
  • 图形验证码:增加机器人操作成本,但会影响用户体验,谨慎使用。
  • 单用户/IP频率限制:在Nginx或网关层实现,限制单位时间内的请求次数。
  • 黑名单机制:对识别出的恶意IP或用户ID,直接拉黑。

六、 总结

设计一个高质量的秒杀系统,是一个典型的性能优化和架构权衡的过程。核心思想是将压力逐层化解:

  1. 前端/CDN:过滤静态资源请求。
  2. 网关:过滤无效和恶意请求。
  3. Redis:利用其高性能和原子操作,快速处理核心的库存校验和扣减,这是整个系统的咽喉。
  4. 消息队列:将写操作异步化,削平数据库的写入洪峰。
  5. 数据库:作为最终数据一致性的保障,承担兜底角色。

通过这样的分层设计和技术选型,即使面对海啸般的流量,系统也能保持稳定和高可用,确保业务的顺利进行。这套架构思想不仅适用于秒杀,也适用于任何高并发、热点数据的业务场景。

0

评论区