基于Springboot+Redis+RabbitMQ消息队列实现的秒杀方案
1.项目简介
如何设计一个秒杀系统
秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。秒杀然而,这对于我们系统而言是一个巨大的考验。
那么,如何才能更好地理解秒杀系统呢?我觉得作为一个程序员,你首先需要从高维度出发,从整体上思考问题。在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
其实,秒杀的整体架构可以概括为“稳、准、快”几个关键字。
-
所谓“稳”,就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提
-
然后就是“准”,就是秒杀 10 台 iPhone,那就只能成交 10 台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性
-
最后再看“快”,“快”其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢
不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求:
-
高性能 :秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。对应的方案比如动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化
-
一致性 :秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为“拍下减库存”“付款减库存”以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知
-
高可用 :现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对
技术栈
2.数据库设计
2.1 表结构
用户表
商品表
订单表
秒杀商品表
秒杀订单表
2.2 E-R图
3.项目实现
3.1 添加依赖
```xml
修改配置文件
```yaml spring:
thymeleaf配置
thymeleaf:
关闭缓存
cache: false
数据源配置
datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/seckill? useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: root hikari:
连接池名
pool-name: DateHikariCP
最小空闲连接数
minimum-idle: 5
空闲连接存活最大时间,默认600000(10分钟)
idle-timeout: 180000
最大连接数,默认10
maximum-pool-size: 10
从连接池返回的连接的自动提交
auto-commit: true
连接最大存活时间,0表示永久存活,默认1800000(30分钟)
max-lifetime: 1800000
连接超时时间,默认30000(30秒)
connection-timeout: 30000
测试连接是否可用的查询语句
connection-test-query: SELECT 1
Mybatis-plus配置
mybatis-plus:
配置Mapper映射文件
mapper-locations: classpath :/mapper/ Mapper.xml
配置MyBatis数据返回类型别名(默认别名是类名)
type-aliases-package: com.xxxx.seckill.pojo
Mybatis SQL 打印(方法接口所在的包,不是Mapper.xml所在的包)
logging: level: com.xxxx.seckill.mapper: debug ```
3.2 搭建秒杀架构
3.2.1 架构层级
一般商家在做活动的时候,经常会遇到各种不怀好意的DDOS攻击(利用无辜的吃瓜群众夺取资源),导致真正的我们无法获得服务!所以说高防IP还是很有必要的。搞活动就意味着人多,接入SLB,对多台云服务器进行流量分发,可以通过流量分发扩展应用系统对外的服务能力,通过消除单点故障提升应用系统的可用性。基于SLB价格以及灵活性考虑后面我们接入Nginx做限流分发,来保障后端服务的正常运行。
后端秒杀业务逻辑,基于Redis 或者 Zookeeper 分布式锁,Kafka 或者 Redis 做消息队列,DRDS数据库中间件实现数据的读写分离。
3.2.2 优化思路
-
分流 :再牛逼的机器也抵挡不住高级别的并发
-
限流 :毕竟秒杀商品有限,防刷的前提下没有绝对的公平,根据每个服务的负载能力,设定流量极限
-
缓存 :尽量不要让大量请求穿透到DB层,活动开始前商品信息可以推送至分布式缓存
-
异步 :分析并识别出可以异步处理的逻辑,比如日志,缩短系统响应时间
-
主备 :如果有条件做好主备容灾方案也是非常有必要的(参考某年锤子的活动被攻击)
-
最后,为了支撑更高的并发,追求更好的性能,可以对服务器的部署模型进行优化,部分请求走正常的秒杀流程,部分请求直接返回秒杀失败,缺点是开发部署时需要维护两套逻辑
3.2.3 分层优化
-
前端优化 :活动开始前生成静态商品页面推送缓存和CDN,静态文(JS/CSS)请求推送至文件服务器和CDN
-
网络优化 :如果是全国用户,最好是BGP多线机房,减少网络延迟
-
应用服务优化 :Nginx最佳配置、Tomcat连接池优化、数据库配置优化、数据库连接池优化
3.2.4 全链路压测
-
分析需压测业务场景涉及系统
-
协调各个压测系统资源并搭建压测环境
-
压测数据隔离以及监控(响应时间、吞吐量、错误率等数据以图表形式实时显示)
-
压测结果统计(平均响应时间、平均吞吐量等数据以图表形式在测试结束后显示)
-
优化单个系统性能、关联流程以及整个业务流程
整个压测优化过程就是一个不断优化不断改进的过程,事先通过测试不断发现问题,优化系统,避免问题,指定应急方案,才能让系统的稳定性和性能都得到质的提升。
3.3 分布式锁
-
在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
-
高可用的获取锁与释放锁
-
高性能的获取锁与释放锁
-
具备可重入特性
-
具备锁失效机制,防止死锁
-
具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
分布式锁
-
基于数据库实现分布式锁
-
基于缓存(Redis等)实现分布式锁
-
基于Zookeeper实现分布式锁
3.4 测试
秒杀未开始
秒杀进行中
秒杀结束
实现代码
OrderServiceImpl.java
```java
/*
*
*
服务实现类
*
/**
* 秒杀
* @param user
* @param goods
* @return
*/
@Override
@Transactional
public Order seckill(User user, GoodsVo goods) {
//秒杀商品表减库存
SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq(
"goods_id", goods.getId()));
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
seckillGoodsService.updateById(seckillGoods);
//生成订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goods.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goods.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
orderMapper.insert(order);
//生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
seckillOrder.setGoodsId(goods.getId());
seckillOrderService.save(seckillOrder);
return order;
}
} ```
SeckillController.java
```java
/*
*
* 前端控制器
*
@RequestMapping("/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goods.getStockCount() < 1) {
model.addAttribute("errmsg", RespBeanEnum.EMPTY_STOCK.getMessage());
return "seckillFail";
}
//判断是否重复抢购
SeckillOrder seckillOrder = seckillOrderService.getOne(new QueryWrapper<SeckillOrder>().eq(
"user_id", user.getId()).eq("goods_id", goodsId));
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "seckillFail";
}
Order order = orderService.seckill(user, goods);
model.addAttribute("order", order);
model.addAttribute("goods", goods);
return "orderDetail";
}
} ```
秒杀成功进入订单详情注意查看库存是否正确扣减,订单是否正确生成
库存不足
重复抢购
至此,简单的秒杀功能逻辑就完成了,下面进入优化阶段。
3.5 优化
压测秒杀接口
创建用户,使用工具类往数据库插入5000用户,并且调用登录接口获取token,写入config.txt。
UserUtil.java
```java
/*
* 生成用户工具类
*
* @author zhoubin
* @since 1.0.0
/
public class UserUtil {
private static void createUser(int count) throws Exception {
List
for (int i = 0; i < count; i++) {
User user = new User();
user.setId(13000000000L + i);
user.setLoginCount(1);
user.setUsername("user" + i);
user.setRegisterDate(new Date());
user.setSalt("1a2b3c");
user.setPassword(MD5Util.inputPassToDbPass("123456", user.getSalt()));
users.add(user);
}
System.out.println("create user");
//插入数据库
Connection conn = getConn();
String sql = "insert into t_user(login_count, username, register_date, salt, password, id)values(?,?,?,?,?,?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
pstmt.setInt(1, user.getLoginCount());
pstmt.setString(2, user.getUsername());
pstmt.setTimestamp(3, new Timestamp(user.getRegisterDate().getTime()));
pstmt.setString(4, user.getSalt());
pstmt.setString(5, user.getPassword());
pstmt.setLong(6, user.getId());
pstmt.addBatch();
}
pstmt.executeBatch();
pstmt.close();
conn.close();
System.out.println("insert to db");
//登录,生成token
String urlString = "http://localhost:8080/login/doLogin";
File file = new File("C:\\Users\\Administrator\\Desktop\\config.txt");
if (file.exists()) {
file.delete();
}
RandomAccessFile raf = new RandomAccessFile(file, "rw");
file.createNewFile();
raf.seek(0);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
URL url = new URL(urlString);
HttpURLConnection co = (HttpURLConnection) url.openConnection();
co.setRequestMethod("POST");
co.setDoOutput(true);
OutputStream out = co.getOutputStream();
String params = "mobile=" + user.getId() + "&password=" + MD5Util.inputPassToFormPass("123456");
out.write(params.getBytes());
out.flush();
InputStream inputStream = co.getInputStream();
ByteArrayOutputStream bout = new ByteArrayOutputStream();
byte buff[] = new byte[1024];
int len = 0;
while ((len = inputStream.read(buff)) >= 0) {
bout.write(buff, 0, len);
}
inputStream.close();
bout.close();
String response = new String(bout.toByteArray());
ObjectMapper mapper = new ObjectMapper();
RespBean respBean = mapper.readValue(response, RespBean.class);
String userTicket = ((String) respBean.getObj());
System.out.println("create userTicket : " + user.getId());
String row = user.getId() + "," + userTicket;
raf.seek(raf.length());
raf.write(row.getBytes());
raf.write("\r\n".getBytes());
System.out.println("write to file : " + user.getId());
}
raf.close();
System.out.println("over");
}
private static Connection getConn() throws Exception {
String url = "jdbc:mysql://localhost:3306/seckill?
useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
String username = "root";
String password = "root";
String driver = "com.mysql.cj.jdbc.Driver";
Class.forName(driver);
return DriverManager.getConnection(url, username, password);
}
public static void main(String[] args) throws Exception {
createUser(5000);
}
} ```
生成的config.txt
测试结果
可以看出已经出现了库存超卖的情况
3.5 接口优化
思路:减少数据库访问
-
系统初始化,把商品库存数量加载到Redis
-
收到请求,Redis预减库存。库存不足,直接返回。否则进入第3步
-
请求入队,立即返回排队中
-
请求出队,生成订单,减少库存
-
客户端轮询,是否秒杀成功
Redis操作库存
SeckillController.java
```java
/
*
* 前端控制器
*
* 秒杀
*
* @param user
* @param goodsId
* @return
*/
@RequestMapping(value = "/doSeckill", method = RequestMethod.POST)
@ResponseBody
public RespBean doSeckill(User user, Long goodsId) {
if (user == null) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}
/*GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goods.getStockCount() < 1) {
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
//判断是否重复抢购
// SeckillOrder seckillOrder = seckillOrderService.getOne(new
QueryWrapper<SeckillOrder>().eq("user_id",user.getId()).eq("goods_id", goodsId));
String seckillOrderJson = (String)redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (!StringUtils.isEmpty(seckillOrderJson)) {
return RespBean.error(RespBeanEnum.REPEATE_ERROR);
}
Order order = orderService.seckill(user, goods);
if (null != order) {
return RespBean.success(order);
}
ValueOperations valueOperations = redisTemplate.opsForValue();
//判断是否重复抢购
String seckillOrderJson = (String) valueOperations.get("order:" + user.getId() + ":" + goodsId);
if (!StringUtils.isEmpty(seckillOrderJson)) {
return RespBean.error(RespBeanEnum.REPEATE_ERROR);
}
//内存标记,减少Redis访问
if (EmptyStockMap.get(goodsId)) {
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
//预减库存
Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
if (stock < 0) {
EmptyStockMap.put(goodsId,true);
valueOperations.increment("seckillGoods:" + goodsId);
return RespBean.error(RespBeanEnum.EMPTY_STOCK);
}
// 请求入队,立即返回排队中
SeckillMessage message = new SeckillMessage(user, goodsId);
mqSender.sendsecKillMessage(JsonUtil.object2JsonStr(message));
return RespBean.success(0);
}
/**
* 系统初始化,把商品库存数量加载到Redis
*
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
List<GoodsVo> list = goodsService.findGoodsVo();
if (CollectionUtils.isEmpty(list)) {
return;
}
list.forEach(goodsVo -> {
redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(),
goodsVo.getStockCount());
EmptyStockMap.put(goodsVo.getId(), false);
});
}
} ```
RabbitMQ秒杀
MQReceiver.java
```java
/*
* @author zhoubin
* @since 1.0.0
/
@Service
@Slf4j
public class MQReceiver {
@Autowired
private IGoodsService goodsService;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private IOrderService orderService;
@RabbitListener(queues = "seckillQueue")
public void receive(String msg) {
log.info("QUEUE接受消息:" + msg);
SeckillMessage message = JsonUtil.jsonStr2Object(msg, SeckillMessage.class);
Long goodsId = message.getGoodsId();
User user = message.getUser();
GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goods.getStockCount() < 1) {
return;
}
//判断是否重复抢购
QueryWrapper
测试
项目启动,Redis预加载库存
秒杀成功,数据库及Redis库存数量正确
优化Redis操作库存
上面代码实际演示会发现Redis的库存有问题,原因在于Redis没有做到原子性。我们采用锁去解决。
分布式锁
进来一个线程先占位,当别的线程进来操作时,发现已经有人占位了,就会放弃或者稍后再试。
线程操作执行完成后,需要调用del指令释放位子。
上面例子,如果业务非常耗时会紊乱。举例:第一个线程首先获得锁,然后执行业务代码,但是业务代码耗时8秒,这样会在第一个线程的任务还未执行成功锁就会被释放,这时第二个线程会获取到锁开始执行,在第二个线程开执行了3秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,他释放的第二个现成的锁,释放之后,第三个线程进来。
解决方案:
-
尽量避免在获取锁之后,执行耗时操作
-
将锁的value设置为一个随机字符串,每次释放锁的时候,都去比较随机字符串是否一致,如果一致,再去释放,否则不释放
-
释放锁时要去查看所得value,比较value是否正确,释放锁总共三个步骤,这三个步骤不具备原子性
参考文献
- 基于SpringBoot的电商秒杀系统的设计与实现(西安电子科技大学·李宜镓)
- 基于微服务架构之电商秒杀系统的设计与实现(华东师范大学·闫保正)
- 分布式环境中任务下发系统的设计与实现(南京大学·蔡慧)
- 基于Redis技术的分布式缓存电商平台设计与实现(南昌大学·欧阳文臣)
- 基于Spring技术的大型视频网站后台上传系统的设计与实现(南京大学·徐悦轩)
- 面向视频监控系统的云存储运维系统的设计与实现(浙江工业大学·段东亮)
- 电子商城系统中订单模块与秒杀模块的设计与实现(南京大学·徐士川)
- 面向视频监控系统的云存储运维系统的设计与实现(浙江工业大学·段东亮)
- 分布式消息推送系统的设计与实现(华南理工大学·阮伟标)
- 基于SSM框架的短信接入系统的设计与实现(华中科技大学·姚浩宇)
- 基于Spring Cloud的埋点管理与分析系统的设计与实现(华中科技大学·王雅琪)
- 基于微服务架构的秒杀系统服务端设计与实现(东南大学·徐冉)
- 基于RocketMQ的消息服务系统设计与实现(华中科技大学·张文杰)
- 基于可定制化规则新闻抓取系统的设计与实现(北京邮电大学·周昂)
- 面向视频监控系统的云存储运维系统的设计与实现(浙江工业大学·段东亮)
本文内容包括但不限于文字、数据、图表及超链接等)均来源于该信息及资料的相关主题。发布者:毕设货栈 ,原文地址:https://bishedaima.com/yuanma/35449.html