如何设计秒杀系统

什么是秒杀设计

秒杀设计 是一种针对高并发、大流量场景的系统设计,通常应用于电商活动中(如限时抢购、促销等),用户在非常短的时间内大量涌入系统,抢购有限的商品或优惠。这种场景下,系统需要能够承受巨大的瞬时并发请求,同时保证数据的一致性和业务的正确性

秒杀技术分析

秒杀系统贯穿活动、商品和下单三个领域,涵盖了页面静态化接口限流Redis预减库存异步下单接口动态化等技术。
需要迎接的挑战有:

  1. 高并发和压力测试:秒杀活动会带来巨大的流量,服务器和数据库的并发处理能力是关键。
  2. 保证数据一致性:抢购涉及到商品库存的实时减少,需要保证在高并发场景下库存数据的准确性和一致性
  3. 防止超卖和重复购买:确保同一商品不会被重复购买,同时避免超卖,即使是在极端的高并发情况下
  4. 分布式锁和限流:使用分布式锁来保护关键资源,限制用户访问频率以免系统崩溃
  5. 性能优化:包括代码层面的优化、数据库的优化、缓存的使用等,以提高系统性能和响应速度。

页面静态化

秒杀页面静态化是将动态生成的秒杀页面转换为静态HTML页面,从而提高页面响应速度和系统性能。页面静态化可以减少服务器动态渲染页面的压力,对于高并发的秒杀场景尤为重要。
页面静态化分为:

  1. 生成静态页面:前端模板引擎(Thymeleaf、Freemarker)生成动态页面,并将其渲染成HTML文件
  2. 静态页面缓存:如CDN缓存、Redis缓存

前端限流具体步骤

  1. 实现静态化步骤
    • 后台预生成静态页面:在秒杀活动开始之前,系统预先生成商品详情的静态 HTML 文件。
    • 用户访问静态页面:用户访问时直接返回这个静态页面,省去数据库查询和服务器逻辑处理。
  2. CDN 分发
    • CDN(内容分发网络,Content Delivery Network) 是一种分布式网络架构,它通过将内容(如静态页面、图片、视频等)缓存到全球多个节点上,用户访问时可以从离自己最近的 CDN 节点获取内容,而不是直接访问主站服务器
    • 将秒杀页面和其他静态资源(如图片、CSS、JS 文件)分发到 CDN 节点,用户访问时会直接从 CDN 获取资源,减少主服务器的负载压力。
  3. 前端排队机制
    • 前端排队页面:当用户进入秒杀页面时,首先加载一个排队页面,告知用户当前排队人数和预计等待时间。
    • 定时轮询:排队页面会通过 JavaScript 代码每隔一段时间轮询服务器,检查用户是否已经排到前面,可以进入秒杀操作。
    • 排队系统维护队列:服务器会根据排队请求,维护一个排队队列,按顺序逐步放行一定数量的用户进行秒杀请求。
  • 静态页面和 CDN 配合:秒杀页面的静态化和 CDN 分发可以大大降低服务器压力,静态页面的生成避免了每次用户请求时都需要服务器动态生成内容,而 CDN 分发将这些静态页面缓存到离用户更近的地方,提高加载速度。
  • 排队机制的使用:即使页面静态化和 CDN 优化了页面加载,实际的秒杀操作(如扣减库存、生成订单)仍然需要访问服务器。为了避免瞬时大量请求冲击服务器,前端排队机制可以控制同时进入秒杀的用户数量,避免瞬间并发过高。

接口限流

秒杀接口限流是为了控制并发请求,防止系统过载,保障系统稳定性和服务可用性的一种手段。在秒杀场景下,大量用户在短时间内同时请求可能导致系统瘫痪或服务质量下降,所以需要对接口进行限流控制。

实现方式

  1. 令牌桶算法: 维护一个令牌桶,每个请求都需要获取令牌才能执行,当令牌不足时限制请求。
  2. 漏桶算法:模拟一个漏桶,每个请求都被放入漏桶,以固定速率请求处理,多余请求会被丢弃或者延迟处理
  3. 计数器算法:限定单位时间内的请求数量,超过限制的请求将被拒绝或者排队等待。(通过 Nginx 的限流模块,限制每秒能够处理的请求数量)

实现步骤

  1. 接口拦截:在接口层面对请求进行拦截,检查请求频率或数量
  2. 限流策略:选择合适的限流算法,并设置阈值或频率控制请求
  3. 失败处理:对被限制的请求进行合适的失败处理,如返回错误信息或者延迟处理
import org.redisson.Redisson;
import org.redisson.api.RSemaphore;
import org.redisson.api.RedissonClient;

public class SeckillService {

    private final RedissonClient redissonClient; // Redisson客户端

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

    public void seckill() {
        // RSemaphore:使用Redisson实现的基于Redis的分布式信号量
        RSemaphore semaphore = redissonClient.getSemaphore("seckill_limit");
        boolean acquired = semaphore.tryAcquire(); // 尝试获取分布式信号量

        if (acquired) {
            try {
                // 处理秒杀逻辑
            } finally {
                semaphore.release(); // 释放分布式信号量
            }
        } else {
            // 请求被限流,处理失败情况
        }
    }
}

Redis预减库存

防止超卖和少卖问题,需要使用Redis和数据库,两者相互配合共同完成预减库存的操作。

import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;

public class SeckillService {

    private final RedisTemplate<String, String> redisTemplate; // RedisTemplate实例

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

    public boolean seckill(String goodsId) {
        Long stock = redisTemplate.opsForValue().decrement("stock:" + goodsId); // Redis中预减库存
        return stock != null && stock >= 0; // 判断减库存后的数量是否大于等于0
    }

    // 在秒杀结束后,可以将预减的库存写回数据库,以保证数据的一致性
    public void restoreStock(String goodsId, long stock) {
        redisTemplate.opsForValue().increment("stock:" + goodsId, stock); // 恢复库存到Redis
    }
}

秒杀场景中,由于并发请求非常多,传统数据库很难在瞬时处理大量的并发库存更新操作,因此我们可以通过 Redis 缓存提前加载库存,并使用 Redis 的原子操作进行库存预减,从而防止超卖。

public boolean seckill(String goodsId) {
    Long stock = redisTemplate.opsForValue().decrement("stock:" + goodsId); // Redis中预减库存
    return stock != null && stock >= 0; // 判断减库存后的数量是否大于等于0
}
  1. 库存预加载:在秒杀活动开始之前,将商品的初始库存加载到 Redis 中。假设某个商品的库存为 10000,将 "stock:goodsId" 的值设置为 10000。
  2. 库存预减:当用户发起秒杀请求时,调用 redisTemplate.opsForValue().decrement("stock:" + goodsId) 来减少 Redis 中的库存值。decrement 操作是 Redis 中的原子操作,可以保证在并发情况下同一时刻只有一个请求能够成功减少库存,避免多个请求同时减少库存而导致超卖。
  3. 判断库存合法性:秒杀操作完成后,检查库存是否大于等于 0。如果库存小于 0,说明库存不足,此时应该拒绝用户的购买请求并返回失败。
    通过 Redis 的原子性操作,可以避免多用户同时减库存导致库存负数的问题,从而解决超卖问题。
public void restoreStock(String goodsId, long stock) {
    redisTemplate.opsForValue().increment("stock:" + goodsId, stock); // 恢复库存到Redis
}
  1. 确保 Redis 和数据库的一致性:为了防止少卖问题,应该保证 Redis 中的库存和最终数据库中的库存一致。在秒杀结束后,将 Redis 中扣减的库存回写到数据库中,确保数据的持久化和一致性。
  2. 恢复库存:在秒杀过程中,可能会有部分用户未完成支付或其他异常情况,此时需要将预扣减的库存恢复,以免实际库存多扣导致少卖。

异步下单 -- RacketMQ

为了提升下单的效率,并且防止下单服务的失败。需要将下单这一操作进行异步处理。最常采用的办法是使用队列,队列最显著的三个优点:异步、削峰、解耦

秒杀接口动态化

在秒杀开始前生成动态的接口链接或签名,并在一定时间内保证链接或签名的有效性。用于提高系统的安全性和可控性,有效防止恶意请求和提前访问秒杀资源。

  1. 接口动态加密
    • 使用加密算法生成动态令牌或签名,将其附加到秒杀接口请求中
    • 例如,使用哈希函数或加密算法对用户身份、商品id等信息进行加密生成签名,作为请求的一部分
  2. 接口有效期设置
    • 在Redis中设置动态令牌/签名作为Key,设置有效期,限定接口有效时间段
    • 当用户发起请求时,验证动态令牌/签名是否存在且有效
import org.springframework.data.redis.core.RedisTemplate;

public class SeckillService {

    private final RedisTemplate<String, String> redisTemplate; // RedisTemplate实例

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

    public String generateToken(String userId, String goodsId) {
        // 根据用户ID和商品ID生成动态签名/令牌
        String token = encrypt(userId + ":" + goodsId); // 这里假设encrypt方法是加密函数
        // 将动态签名/令牌存储在Redis中并设置有效期,例如设置为30分钟
        redisTemplate.opsForValue().set(token, "valid", 30, TimeUnit.MINUTES);
        return token;
    }

    public boolean validateToken(String token) {
        // 验证动态签名/令牌是否在Redis中存在且有效
        Boolean exists = redisTemplate.hasKey(token);
        return exists != null && exists;
    }
}

秒杀系统表设计

一个简单的秒杀系统的表设计可以包括以下几个主要的表:

  1. 秒杀活动表(Seckill Activity):
    • 存储秒杀活动的基本信息,如活动ID、名称、开始时间、结束时间等。
  2. 秒杀商品表(Seckill Goods):
    • 包含秒杀活动涉及的商品信息,如商品ID、名称、库存数量、秒杀价格等。
  3. 秒杀订单表(Seckill Order):
    • 记录用户的秒杀订单信息,包括订单ID、用户ID、商品ID、购买数量、价格、创建时间等。
  4. 用户表(User):
    • 存储系统用户的基本信息,如用户ID、用户名、密码等(用于身份验证和授权)。
  5. 秒杀日志表(Seckill Log):
    • 记录秒杀系统的操作日志,例如用户秒杀行为、系统异常、日志记录时间等。
-- 秒杀活动表
CREATE TABLE seckill_activity (
    id INT PRIMARY KEY AUTO_INCREMENT, -- 活动ID(主键)
    name VARCHAR(255) NOT NULL, -- 活动名称
    start_time DATETIME NOT NULL, -- 活动开始时间
    end_time DATETIME NOT NULL, -- 活动结束时间
    create_time DATETIME NOT NULL -- 活动创建时间
);

-- 秒杀商品表
CREATE TABLE seckill_goods (
    id INT PRIMARY KEY AUTO_INCREMENT, -- 商品ID(主键)
    activity_id INT NOT NULL, -- 活动ID(外键)
    name VARCHAR(255) NOT NULL, -- 商品名称
    price DECIMAL(10, 2) NOT NULL, -- 商品价格
    stock INT NOT NULL -- 商品库存数量
);

-- 秒杀订单表
CREATE TABLE seckill_order (
    id INT PRIMARY KEY AUTO_INCREMENT, -- 订单ID(主键)
    user_id INT NOT NULL, -- 用户ID(外键)
    goods_id INT NOT NULL, -- 商品ID(外键)
    quantity INT NOT NULL, -- 购买数量
    create_time DATETIME NOT NULL -- 订单创建时间
);

-- 用户表
CREATE TABLE user (
    id INT PRIMARY KEY AUTO_INCREMENT, -- 用户ID(主键)
    username VARCHAR(100) NOT NULL, -- 用户名
    password VARCHAR(255) NOT NULL -- 密码
    -- ... 其他用户信息字段
);

-- 秒杀日志表
CREATE TABLE seckill_log (
    id INT PRIMARY KEY AUTO_INCREMENT, -- 日志ID(主键)
    user_id INT NOT NULL, -- 用户ID(外键)
    activity_id INT NOT NULL, -- 活动ID(外键)
    action VARCHAR(50) NOT NULL, -- 日志操作类型(如秒杀)
    log_time DATETIME NOT NULL -- 日志记录时间
);

秒杀安全问题

限流

目的:防止服务器宕机
具体实现在 秒杀技术分析 章节

接口防刷

目的:防止恶意用户对秒杀接口进行频繁请求或者攻击,导致系统压力过大或不公平性
防刷策略:

  1. 访问频率限制:根据ip和用户id限制访问频率过大
  2. 验证码验证:在用户行为异常时,要求用户先完成验证码验证,确保用户是真实的人类用户而不是机器人
  3. 动态令牌/签名:为每一个用户生成唯一的动态令牌或签名,在请求接口时需要携带有效的令牌或签名,有效期内才允许访问
  4. API网关:通过使用API网关来控制访问流量和行为,实现对请求的限制和管控
    根据ip和用户id访问频率限制代码示范,同一个ip在一分钟之内只能请求访问10次,同一个id一分钟内只能访问5次
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;

public class DistributedRateLimiter {
    private final RedissonClient redissonClient;
    private final ConcurrentMap<String, RLock> locks = new ConcurrentHashMap<>();

    public DistributedRateLimiter() {
        // Redisson 配置,连接 Redis 集群节点
        Config config = new Config();
        config.useClusterServers()
                .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001"); // 填入实际的 Redis 集群节点地址

        this.redissonClient = Redisson.create(config);
    }

    // 根据 IP 地址限制访问频率
    public boolean allowRequestByIp(String ip, int limit, long interval, TimeUnit timeUnit) {
        String counterKey = "counter:ip:" + ip;
        RLock lock = locks.computeIfAbsent(counterKey, k -> redissonClient.getLock("lock:" + k));
        try {
            lock.lock();
            long count = redissonClient.getAtomicLong(counterKey).addAndGet(1);
            if (count <= limit) {
                redissonClient.getKeys().expire(counterKey, interval, timeUnit);
                return true; // 允许访问
            }
            return false; // 限制访问
        } finally {
            lock.unlock();
        }
    }

    // 根据用户 ID 限制访问频率
    public boolean allowRequestByUserId(String userId, int limit, long interval, TimeUnit timeUnit) {
        String counterKey = "counter:user:" + userId;
        RLock lock = locks.computeIfAbsent(counterKey, k -> redissonClient.getLock("lock:" + k));
        try {
            lock.lock();
            long count = redissonClient.getAtomicLong(counterKey).addAndGet(1);
            if (count <= limit) {
                redissonClient.getKeys().expire(counterKey, interval, timeUnit);
                return true; // 允许访问
            }
            return false; // 限制访问
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        DistributedRateLimiter rateLimiter = new DistributedRateLimiter();
        String ip = "192.168.1.100"; // 替换成实际的 IP 地址
        String userId = "user123"; // 替换成实际的用户 ID

        // IP 地址访问频率限制示例
        if (rateLimiter.allowRequestByIp(ip, 10, 1, TimeUnit.SECONDS)) {
            System.out.println("允许访问 (IP)"); // 允许访问 (IP)
        } else {
            System.out.println("限制访问 (IP)"); // 限制访问 (IP)
        }

        // 用户 ID 访问频率限制示例
        if (rateLimiter.allowRequestByUserId(userId, 5, 1, TimeUnit.SECONDS)) {
            System.out.println("允许访问 (User ID)"); // 允许访问 (User ID)
        } else {
            System.out.println("限制访问 (User ID)"); // 限制访问 (User ID)
        }
    }
}

秒杀接口隐藏

目的: 防止暴露秒杀接口。
如果秒杀接口是固定的,攻击者可以通过提前知道的接口地址对系统进行恶意攻击(如 DDoS 攻击、抢占资源等)。接口动态化可以让每次秒杀活动生成不同的唯一接口或令牌,让恶意攻击者无法直接找到具体的秒杀接口,减轻系统的压力
具体减 秒杀技术分析->秒杀接口动态化章节

超卖

目的:防止超卖
使用分布式锁防止超卖

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

import java.util.concurrent.TimeUnit;

public class DistributedSeckill {
    private final RedissonClient redissonClient;

    public DistributedSeckill() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379"); // 设置 Redis 地址
        this.redissonClient = Redisson.create(config);
    }

    public boolean seckill(String productId, int quantity) {
        RLock lock = redissonClient.getLock("seckill:" + productId);
        try {
            // 尝试获取分布式锁,等待时间设置为5秒
            if (lock.tryLock(5, TimeUnit.SECONDS)) {
                // 在获得锁的情况下,执行秒杀操作
                // 例如减少库存等操作

                // 这里模拟减少库存的操作
                // 假设初始库存为10,当库存不足时,不能进行秒杀
                int stock = getStockFromDatabase(productId);
                if (stock >= quantity) {
                    updateStockInDatabase(productId, stock - quantity); // 更新库存
                    return true; // 秒杀成功
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock(); // 释放锁
        }
        return false; // 秒杀失败
    }

    // 模拟从数据库中获取商品库存
    private int getStockFromDatabase(String productId) {
        // 实际从数据库中获取商品库存
        // 此处仅作示例,返回固定值10表示初始库存为10
        return 10;
    }

    // 模拟更新数据库中的商品库存
    private void updateStockInDatabase(String productId, int newStock) {
        // 实际更新数据库中的商品库存
        // 这里仅作示例,没有真正的数据库操作
        System.out.println("商品 " + productId + " 库存更新为:" + newStock);
    }

    public static void main(String[] args) {
        DistributedSeckill seckill = new DistributedSeckill();
        String productId = "1001"; // 商品ID
        int quantity = 3; // 秒杀数量

        boolean success = seckill.seckill(productId, quantity);
        if (success) {
            System.out.println("秒杀成功!");
        } else {
            System.out.println("秒杀失败,库存不足或秒杀活动已结束!");
        }
    }
}

秒杀核心代码

Read more

分布式系统下雪花算法生成全局唯一ID

雪花算法 在分布式系统中,为了避免多个节点生成相同的唯一标识符id,我们通常使用一种全局唯一ID生成策略,而Snowflake Algorithm就是广泛使用的解决方案之一。 雪花算法简介 雪花算法生成的 ID 是一个 64 位的二进制整数,具有以下组成部分: 部分 字节长度 描述 符号位 1bit 固定为0,因为生成的ID是整数 时间戳 41bits 表示时间戳,单位是毫秒,可以存储大约68年的时间 机器ID 10bits 用于标识不同的机器(节点)。10位可以将其分数据中心ID(5位)和机器ID(5位),共支持1024台机器同时生成ID 序列号 12bits 同一毫秒内的序列号,用来区分在同一台机器、同一时间戳下生成的多个ID,最大支持同一毫秒生成4096个唯一ID 代码实现 package com.can.springbootmessage; public class SnowflakeIdGenerator { // 起始时间戳,Long是64位,

By Yucan Huang

责任链设计模式

什么是责任链模式 项目有个请求,需要有对应的服务来处理,然后这个请求可能需要被很多个层级权限的服务来处理。我们将这些处理该请求的服务放在一条链上,链从前往后,是层级更高的服务,第一个服务处理不了,传递到链上的下一个服务,直到这个请求被处理成功。 责任链模式(Chain of Responsibility)是一种处理请求的模式,它让多个处理器都有机会处理该请求,直到其中某个处理器成功处理该请求,责任链模式把多个处理器串成链,然后让请求在链上传递。 1. 如何把多个处理器串成链,然后让请求在链上传递 客户端发送请求,处理类去处理它的请求,所以有个处理方法(handleRequest),处理类要连接在一起,所以**处理类要有一个方法(成员变量nextHandler)**指向下一个处理类。 我们抽象出一个公共的父类,然后去定义不同的处理类,这些处理类通过nextHandler连接起来。 2. 代码演示 package interview.pattern; public class ChainRespPattern { Handl

By Yucan Huang
外卖点餐系统07

外卖点餐系统07

缓存菜品 问题说明 用户通过微信小程序查询菜品,小程序会将请求发送给后端服务,后端就会查询MySQL数据库。 用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大,就会造成系统响应慢,用户体验差。 解决思路 * 通过Redis来缓存菜品数据,减少数据库查询操作。 微信小程序查询数据后,会向后端服务发送请求,判断请求的数据在缓存中是否存在,如果存在直接读取缓存,不存在再查询MySQL,并将该数据载入缓存。 * 缓存逻辑分析 微信小程序展示菜品粒度是根据分类展示,所以缓存数据应该根据分类缓存菜品。 1. 每个分类下的菜品保存一份缓存数据 * 使用分类id作为key,分类下的菜品数据使用String字符串保存,分类下的菜品应该是List集合,在Java中可以通过序列化将这个集合转换成Redis的字符串类型 2. 数据库中菜品数据有变更时及时清理缓存数据 代码开发 1. 修改用户端接口 DishContr

By Yucan Huang