如何设计秒杀系统
什么是秒杀设计
秒杀设计 是一种针对高并发、大流量场景的系统设计,通常应用于电商活动中(如限时抢购、促销等),用户在非常短的时间内大量涌入系统,抢购有限的商品或优惠。这种场景下,系统需要能够承受巨大的瞬时并发请求,同时保证数据的一致性和业务的正确性。
秒杀技术分析
秒杀系统贯穿活动、商品和下单三个领域,涵盖了页面静态化、接口限流、Redis预减库存、异步下单和接口动态化等技术。
需要迎接的挑战有:
- 高并发和压力测试:秒杀活动会带来巨大的流量,服务器和数据库的并发处理能力是关键。
- 保证数据一致性:抢购涉及到商品库存的实时减少,需要保证在高并发场景下库存数据的准确性和一致性
- 防止超卖和重复购买:确保同一商品不会被重复购买,同时避免超卖,即使是在极端的高并发情况下
- 分布式锁和限流:使用分布式锁来保护关键资源,限制用户访问频率以免系统崩溃
- 性能优化:包括代码层面的优化、数据库的优化、缓存的使用等,以提高系统性能和响应速度。
页面静态化
秒杀页面静态化是将动态生成的秒杀页面转换为静态HTML页面,从而提高页面响应速度和系统性能。页面静态化可以减少服务器动态渲染页面的压力,对于高并发的秒杀场景尤为重要。
页面静态化分为:
- 生成静态页面:前端模板引擎(Thymeleaf、Freemarker)生成动态页面,并将其渲染成HTML文件
- 静态页面缓存:如CDN缓存、Redis缓存
前端限流具体步骤
- 实现静态化步骤
- 后台预生成静态页面:在秒杀活动开始之前,系统预先生成商品详情的静态 HTML 文件。
- 用户访问静态页面:用户访问时直接返回这个静态页面,省去数据库查询和服务器逻辑处理。
- CDN 分发
- CDN(内容分发网络,Content Delivery Network) 是一种分布式网络架构,它通过将内容(如静态页面、图片、视频等)缓存到全球多个节点上,用户访问时可以从离自己最近的 CDN 节点获取内容,而不是直接访问主站服务器
- 将秒杀页面和其他静态资源(如图片、CSS、JS 文件)分发到 CDN 节点,用户访问时会直接从 CDN 获取资源,减少主服务器的负载压力。
- 前端排队机制
- 前端排队页面:当用户进入秒杀页面时,首先加载一个排队页面,告知用户当前排队人数和预计等待时间。
- 定时轮询:排队页面会通过 JavaScript 代码每隔一段时间轮询服务器,检查用户是否已经排到前面,可以进入秒杀操作。
- 排队系统维护队列:服务器会根据排队请求,维护一个排队队列,按顺序逐步放行一定数量的用户进行秒杀请求。
- 静态页面和 CDN 配合:秒杀页面的静态化和 CDN 分发可以大大降低服务器压力,静态页面的生成避免了每次用户请求时都需要服务器动态生成内容,而 CDN 分发将这些静态页面缓存到离用户更近的地方,提高加载速度。
- 排队机制的使用:即使页面静态化和 CDN 优化了页面加载,实际的秒杀操作(如扣减库存、生成订单)仍然需要访问服务器。为了避免瞬时大量请求冲击服务器,前端排队机制可以控制同时进入秒杀的用户数量,避免瞬间并发过高。
接口限流
秒杀接口限流是为了控制并发请求,防止系统过载,保障系统稳定性和服务可用性的一种手段。在秒杀场景下,大量用户在短时间内同时请求可能导致系统瘫痪或服务质量下降,所以需要对接口进行限流控制。
实现方式
- 令牌桶算法: 维护一个令牌桶,每个请求都需要获取令牌才能执行,当令牌不足时限制请求。
- 漏桶算法:模拟一个漏桶,每个请求都被放入漏桶,以固定速率请求处理,多余请求会被丢弃或者延迟处理
- 计数器算法:限定单位时间内的请求数量,超过限制的请求将被拒绝或者排队等待。(通过 Nginx 的限流模块,限制每秒能够处理的请求数量)
实现步骤
- 接口拦截:在接口层面对请求进行拦截,检查请求频率或数量
- 限流策略:选择合适的限流算法,并设置阈值或频率控制请求
- 失败处理:对被限制的请求进行合适的失败处理,如返回错误信息或者延迟处理
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
}
- 库存预加载:在秒杀活动开始之前,将商品的初始库存加载到 Redis 中。假设某个商品的库存为 10000,将 "stock:goodsId" 的值设置为 10000。
- 库存预减:当用户发起秒杀请求时,调用 redisTemplate.opsForValue().decrement("stock:" + goodsId) 来减少 Redis 中的库存值。decrement 操作是 Redis 中的原子操作,可以保证在并发情况下同一时刻只有一个请求能够成功减少库存,避免多个请求同时减少库存而导致超卖。
- 判断库存合法性:秒杀操作完成后,检查库存是否大于等于 0。如果库存小于 0,说明库存不足,此时应该拒绝用户的购买请求并返回失败。
通过 Redis 的原子性操作,可以避免多用户同时减库存导致库存负数的问题,从而解决超卖问题。
public void restoreStock(String goodsId, long stock) {
redisTemplate.opsForValue().increment("stock:" + goodsId, stock); // 恢复库存到Redis
}
- 确保 Redis 和数据库的一致性:为了防止少卖问题,应该保证 Redis 中的库存和最终数据库中的库存一致。在秒杀结束后,将 Redis 中扣减的库存回写到数据库中,确保数据的持久化和一致性。
- 恢复库存:在秒杀过程中,可能会有部分用户未完成支付或其他异常情况,此时需要将预扣减的库存恢复,以免实际库存多扣导致少卖。
异步下单 -- RacketMQ
为了提升下单的效率,并且防止下单服务的失败。需要将下单这一操作进行异步处理。最常采用的办法是使用队列,队列最显著的三个优点:异步、削峰、解耦
秒杀接口动态化
在秒杀开始前生成动态的接口链接或签名,并在一定时间内保证链接或签名的有效性。用于提高系统的安全性和可控性,有效防止恶意请求和提前访问秒杀资源。
- 接口动态加密
- 使用加密算法生成动态令牌或签名,将其附加到秒杀接口请求中
- 例如,使用哈希函数或加密算法对用户身份、商品id等信息进行加密生成签名,作为请求的一部分
- 接口有效期设置
- 在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;
}
}
秒杀系统表设计
一个简单的秒杀系统的表设计可以包括以下几个主要的表:
- 秒杀活动表(Seckill Activity):
- 存储秒杀活动的基本信息,如活动ID、名称、开始时间、结束时间等。
- 秒杀商品表(Seckill Goods):
- 包含秒杀活动涉及的商品信息,如商品ID、名称、库存数量、秒杀价格等。
- 秒杀订单表(Seckill Order):
- 记录用户的秒杀订单信息,包括订单ID、用户ID、商品ID、购买数量、价格、创建时间等。
- 用户表(User):
- 存储系统用户的基本信息,如用户ID、用户名、密码等(用于身份验证和授权)。
- 秒杀日志表(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 -- 日志记录时间
);
秒杀安全问题
限流
目的:防止服务器宕机
具体实现在 秒杀技术分析 章节
接口防刷
目的:防止恶意用户对秒杀接口进行频繁请求或者攻击,导致系统压力过大或不公平性
防刷策略:
- 访问频率限制:根据ip和用户id限制访问频率过大
- 验证码验证:在用户行为异常时,要求用户先完成验证码验证,确保用户是真实的人类用户而不是机器人
- 动态令牌/签名:为每一个用户生成唯一的动态令牌或签名,在请求接口时需要携带有效的令牌或签名,有效期内才允许访问
- 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("秒杀失败,库存不足或秒杀活动已结束!");
}
}
}