大家好,好久不发文章了。最近自己把秒杀系统搭建了一下,想给有需要帮助的童鞋学习。整个项目已经放到Github上去了,项目Pull下来,修改好配置文件就可以跑起来,减少你们项目框架搭建的时间,希望对你们有帮助!!!
JDK | Maven | Mysql | SpringBoot | Redis | RocketMq |
---|---|---|---|---|---|
1.8 | 3.2.2 | 5.8.X | 1.5.10.RELEASE | 3.2 | 4.3.X |
项目地址:https://github.com/chenxingxing6/second_skill
秒杀场景一般会在电商网站举行一些活动或者节假日在12306网站上抢票时遇到。对于电商网站中一些稀缺或者特价商品,电商网站一般会在约定时间点对其进行限量销售,因为这些商品的特殊性,会吸引大量用户前来抢购,并且会在约定的时间点同时在秒杀页面进行抢购。
1、启动前,进行相关redis、mysql、rocketMq地址
2、登录地址:http://localhost:8888/page/login
3、商品秒杀列表地址:http://localhost:8888/goods/list
4、账号:18077200000,密码:123456
PS:测试时需要修改秒杀活动时间seckill_goods表开始和结束时间,然后确保库存足够。
1、数据库共有一千个用户左右(手机号:从18077200000~18077200998 密码为:123456)
2、使用CyclicBarrier模拟高并发,1000个用户秒杀某个商品
3、读:Redis
4、写:RocketMq
ExecutorService executorService = Executors.newCachedThreadPool();
CyclicBarrier barrier = new CyclicBarrier(size);
for (int i = 0; i < size; i++) {
int finalI = i;
int finalI1 = i;
executorService.execute(()->{
try {
barrier.await();
// 1000个人模拟高并发
businessDoHandler(users.get(finalI), goodsId);
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
1、页面缓存、商品详情静态化、订单静态化(生成html放缓存里面)
2、消息队列RocketMq进行流量肖峰
3、解决超卖问题 (文末有提3种方式,本应用用redis预减库存实现)
4、接口限流防刷(Redis,Guava)
【排名:1】,用户名:user4,秒杀商品: iPhone X
【排名:2】,用户名:user2,秒杀商品: iPhone X
【排名:3】,用户名:user69,秒杀商品: iPhone X
【排名:4】,用户名:user6,秒杀商品: iPhone X
【排名:5】,用户名:user7,秒杀商品: iPhone X
【排名:6】,用户名:user9,秒杀商品: iPhone X
【排名:7】,用户名:user0,秒杀商品: iPhone X
【排名:8】,用户名:user1,秒杀商品: iPhone X
【排名:9】,用户名:user56,秒杀商品: iPhone X
【排名:10】,用户名:user59,秒杀商品: iPhone X
【排名:11】,用户名:user119,秒杀商品: iPhone X
【排名:12】,用户名:user122,秒杀商品: iPhone X
【排名:13】,用户名:user123,秒杀商品: iPhone X
【排名:14】,用户名:user100,秒杀商品: iPhone X
【排名:15】,用户名:user999,秒杀商品: iPhone X
【排名:16】,用户名:user127,秒杀商品: iPhone X
【排名:17】,用户名:user137,秒杀商品: iPhone X
【排名:18】,用户名:user160,秒杀商品: iPhone X
【排名:19】,用户名:user5,秒杀商品: iPhone X
【排名:20】,用户名:user146,秒杀商品: iPhone X
【排名:21】,用户名:user157,秒杀商品: iPhone X
【排名:22】,用户名:user180,秒杀商品: iPhone X
【排名:23】,用户名:user185,秒杀商品: iPhone X
【排名:24】,用户名:user435,秒杀商品: iPhone X
【排名:25】,用户名:user60,秒杀商品: iPhone X
.......
特点:时间短,瞬间用户请求量巨大
大量的请求进来,我们需要考虑的点就很多了,缓存雪崩,缓存击穿,缓存穿透这些我之前提到的点都是有可能发生的,出现问题打挂DB那就很难受了,活动失败用户体验差,活动人气没了,最后背锅的还是开发。
但凡是个秒杀,都怕超卖,100个华为Pro50,商家的预算经费卖100个可以赚点还可以造势,结果你写错程序多卖出去200个,你不发货用户投诉你,平台封你店,你发货就血亏,你怎么办?
搞个几十台机器搞点脚本,我也模拟出来十几万个人左右的请求,那我是不是意味着我基本上有80%的成功率了。真实情况可能远远不止,因为机器请求的速度比人的手速往往快太多了。假如我抢到了,我转手卖掉我不是血赚?就算我不卖我也不亏啊!!!
把URL动态化,就连写代码的人都不知道,你就通过MD5之类的加密算法加密随机的字符串去做url,然后通过前端代码获取url后台校验才能通过。
设计个能抗住高并发的系统,我觉得还是得单一职责。什么意思呢,大家都知道现在设计都是微服务的设计思想,然后再用分布式的部署方式单一职责的好处就是就算秒杀没抗住,秒杀库崩了,服务挂了,也不会影响到其他的服务。
我们知道单机的Redis顶不住,那简单多找几个兄弟啊,秒杀本来就是读多写少,那你们是不是瞬间想起来我之前跟你们提到过的,Redis集群,主从同步、读写分离,我们还搞点哨兵,开启持久化直接无敌高可用!
秒杀一般都是特定的商品还有页面模板,现在一般都是前后端分离的,所以页面一般都是不会经过后端的,但是前端也要自己的服务器啊,那就把能提前放入cdn服务器的东西都放进去,反正把所有能提升效率的步骤都做一下,减少真正秒杀时候服务器的压力。
大家有没有发现没到秒杀前,一般按钮都是置灰的,只有时间到了,才能点击。这是因为怕大家在时间快到的最后几秒秒疯狂请求服务器,然后还没到秒杀的时候基本上服务器就挂了。这个时候就需要前端的配合,定时去请求你的后端服务器,获取最新的北京时间,到时间点再给按钮可用状态。
秒杀的本质,就是对库存的抢夺,每个秒杀的用户来你都去数据库查询库存校验库存,然后扣减库存,撇开性能因数,你不觉得这样好繁琐,对业务开发人员都不友好,而且数据库顶不住啊。所以我们可以先将库存加载到redis中预热,然后秒杀了,在对db库存进行异步修改。
前端限流:秒杀一般不会让你一直点的,一般都是点击一下或者两下然后几秒之后才可以继续点击,这也是保护服务器的一种手段。
后端限流:秒杀的时候肯定是涉及到后续的订单生成和支付等操作,但这时只有秒杀成功的请求才会执行后续操作。常用技术方案(Sentinel,Hystrix)
把所有请求它入消息队列,然后一点点消费去改库存就好了嘛
update goods set num = num - 1 WHERE id = 1001 and num > 0
排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排 他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就 行读取和修改。就是类似于我在执行update操作的时候,这一行是一个事务(默认加了排他锁)。这一行不能 被任何其他线程修改和读写。
select version from goods WHERE id= 1001
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);
这种方式采用了版本号的方式,其实也就是CAS的原理。
利用redis的单线程预减库存。比如商品有100件。那么我在redis存储一个k,v。例如
static {
/**
* @desc 扣减库存Lua脚本
* 库存(stock)-1:表示不限库存
* 库存(stock)0:表示没有库存
* 库存(stock)大于0:表示剩余库存
*
* @params 库存key
* @return
* -3:库存未初始化
* -2:库存不足
* -1:不限库存
* 大于等于0:剩余库存(扣减之后剩余的库存)
* redis缓存的库存(value)是-1表示不限库存,直接返回1
*/
StringBuilder sb = new StringBuilder();
sb.append("if (redis.call('exists', KEYS[1]) == 1) then");
sb.append(" local stock = tonumber(redis.call('get', KEYS[1]));");
sb.append(" local num = tonumber(ARGV[1]);");
sb.append(" if (stock == -1) then");
sb.append(" return -1;");
sb.append(" end;");
sb.append(" if (stock >= num) then");
sb.append(" return redis.call('incrby', KEYS[1], 0 - num);");
sb.append(" end;");
sb.append(" return -2;");
sb.append("end;");
sb.append("return -3;");
STOCK_LUA = sb.toString();
}
总结:第二种CAS是失败重试,并无加锁。应该比第一种加锁效率要高很多。类似于Java中的Synchronize和CAS。
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
int seconds();
int maxCount();
boolean needLogin() default true;
}
//接口限流
AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
if(accessLimit == null) {
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
AccessKey ak = AccessKey.withExpire;
Integer count = redisService.get(ak, key, Integer.class);
if(count == null) {
redisService.set(ak, key, 1, seconds);
}else if(count < maxCount) {
redisService.incr(ak, key);
}else {
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
测试代码:在该项目lxhv1分支里面:下载地址
CREATE TABLE `my_lock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`gmt_modify` datetime DEFAULT NULL,
`lock_desc` varchar(255) DEFAULT '',
`lock_type` varchar(255) DEFAULT '',
`version` bigint(10) DEFAULT '0' COMMENT '版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `UK_key` (`lock_type`)
) ENGINE=InnoDB AUTO_INCREMENT=664 DEFAULT CHARSET=utf8
1)唯一索引 UNIQUE KEY
当想要锁住某个方法时执行insert方法,插入一条数据,lock_type有唯一约束,可以保证多次提交只有一次成功,而成功的
这次就可以认为其获得了锁,而执行完成后执行delete语句释放锁。
缺点:
1.强依赖与数据库
2.非阻塞的,获取失败直接失败
3.没有失效时间
4.非重入锁
2)乐观锁
-- 线程1查询,当前left_count为1,则有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程1,更新完成后当前的version为1235,update状态为1,更新成功
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
-- 线程2,更新由于当前的version为1235,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
3)悲观锁(排他锁)for update
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过connection.commit();操作来释放锁
1.se
tnx(lockkey, 1) 如果返回0,则说明占位失败;如果返回1,则说明占位成功
2.expire()命令对lockkey设置超时时间,为的是避免死锁问题。
3.执行完业务代码后,可以通过delete命令删除key。
private void handler_redis(User user, Long goodId){
String key = "MY_KEY_"+goodId;
try {
boolean lock = lock4.getLock(key, String.valueOf(user.getId()), 10);
if (lock){
System.out.println("获取到锁....");
}else {
System.out.println("没获取到锁");
}
}finally {
lock4.unLock(key, String.valueOf(user.getId()));
}
}
原理:使用zookeeper创建临时序列节点来实现分布式锁,适用于顺序执行的程序,大体思路就是创建临时序列节点,找出最小的序列节点,获取分布式锁,程序执行完成之后此序列节点消失,通过watch来监控节点的变化,从剩下的节点的找到最小的序列节点,获取分布式锁,执行相应处理,依次类推……
<dependency>
<groupId>org.apache.curatorgroupId>
<artifactId>curator-recipesartifactId>
<version>2.8.0version>
dependency>
/**
* 获取分布式锁
*/
public Boolean tryLock(String path) {
String keyPath = "/" + ROOT_PATH_LOCK + "/" + path;
try {
client.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.EPHEMERAL)
.withACL(ZooDefs.Ids.OPEN_ACL_UNSAFE)
.forPath(keyPath);
System.out.println("success to acquire lock for path " + keyPath);
return true;
} catch (Exception e) {
System.out.println("failed to acquire lock for path");
return false;
}
}
/**
* 释放分布式锁
*/
public boolean unLock(String path) {
try {
String keyPath = "/" + ROOT_PATH_LOCK + "/" + path;
if (client.checkExists().forPath(keyPath) != null) {
client.delete().forPath(keyPath);
}
} catch (Exception e) {
System.out.println("failed to release lock");
return false;
}
return true;
}
private void businessDoHandler(User user, long goodId){
//内存标记,减少redis访问
boolean over = localOverMap.get(goodId);
if (over){
System.out.println(String.format("【内存标记】用户:%s,秒杀失败:%s", user.getUserName(), CodeMsg.MIAO_SHA_OVER.getMsg()));
return;
}
//预减库存(volatile保证原子可见性)
stock = redisService.decr(GoodsKey.getSeckillGoodsStock, "" + goodId);
if (stock < 0) {
System.out.println(String.format("【预减库存】用户:%s,秒杀失败:%s", user.getUserName(), CodeMsg.MIAO_SHA_OVER.getMsg()));
localOverMap.put(goodId, true);
return;
}
//判断是否已经秒杀到了
SeckillOrder order = seckillOrderService.getSeckillOrderByUserIdGoodsId(user.getId(), goodId);
if (order != null) {
System.out.println(String.format("用户:%s,秒杀失败:%s", user.getUserName(), CodeMsg.REPEATE_MIAOSHA.getMsg()));
return;
}
SeckillMessage mm = new SeckillMessage();
mm.setUser(user);
mm.setGoodsId(goodId);
mm.setTime(System.currentTimeMillis());
mqSender.sendSeckillMessage(mm);
}
package com.lxh.seckill.mq;
import com.lxh.seckill.bo.GoodsBo;
import com.lxh.seckill.common.Const;
import com.lxh.seckill.entity.User;
import com.lxh.seckill.mq.common.AbstractRocketConsumer;
import com.lxh.seckill.redis.RedisService;
import com.lxh.seckill.redis.SeckillKey;
import com.lxh.seckill.service.OrderService;
import com.lxh.seckill.service.SeckillGoodsService;
import com.lxh.seckill.service.SeckillOrderService;
import com.lxh.seckill.service.UserService;
import com.lxh.seckill.thread.ThreadPoolScheduleManager;
import com.lxh.seckill.util.MD5Util;
import com.lxh.seckill.websocket.WebSocketServer;
import org.apache.commons.collections.CollectionUtils;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.message.MessageExt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@Service
public class MQReceiver extends AbstractRocketConsumer {
private static Logger log = LoggerFactory.getLogger(MQReceiver.class);
@Autowired
RedisService redisService;
@Autowired
SeckillGoodsService goodsService;
@Autowired
OrderService orderService;
@Autowired
SeckillOrderService seckillOrderService;
@Autowired
WebSocketServer webSocketServer;
@Autowired
UserService userService;
private AtomicInteger num = new AtomicInteger(0);
private volatile int stock = 0;
@Override
public void init() {
// 设置主题,标签与消费者标题
super.config("skill", "*", "标题");
registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
list.forEach(msg->{
String content = new String(msg.getBody());
String keyMd5 = MD5Util.md5(msg.getTags() + msg.getMsgId() + msg.getKeys());
receive(content, keyMd5);
});
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
}
public void receive(String content, String md5) {
if (redisService.setnx(md5, 5, "1") == 0){
System.out.println("消息重复消费");
return;
}
SeckillMessage mm = RedisService.stringToBean(content, SeckillMessage.class);
User user = mm.getUser();
long goodsId = mm.getGoodsId();
Long grabTime = mm.getTime();
//判断是否已经秒杀到了
String key = user.getId() + ":" + goodsId;
Boolean aBoolean = redisService.get(SeckillKey.skillUser, key, Boolean.class);
if (aBoolean != null && Boolean.TRUE == aBoolean){
return;
}
// 用户排名sorted Set
redisService.zadd("activity_"+goodsId, grabTime, String.valueOf(user.getId()), 10*60);
// 分布式锁(5s内抢完,延时发送消息)
if(redisService.setnx("activity_lock"+goodsId, 10, "1") == 1){
ScheduledExecutorService scheduledExecutorService = ThreadPoolScheduleManager.getInstance();
scheduledExecutorService.schedule(() -> {
execute5SecondsDeliver(goodsId);
}, 3, TimeUnit.SECONDS);
}
}
private void execute5SecondsDeliver(Long goodId){
// 根据抢的时间进行升序排序
List<String> userIds = redisService.zrange("activity_"+goodId,0, -1, 1);
if (CollectionUtils.isEmpty(userIds)){
return;
}
List<User> users = userService.selectAll();
GoodsBo goodsBo = goodsService.getseckillGoodsBoByGoodsId(goodId);
Map<Integer, User> userMap = users.stream().collect(Collectors.toMap(e -> e.getId(), u -> u));
//减库存,下订单,写入秒杀订单
userIds = userIds.stream().limit(100).collect(Collectors.toList());
for (String userId : userIds) {
User user = userMap.get(Integer.valueOf(userId));
if (Objects.isNull(user)){
continue;
}
webSocketServer.sendMsg(String.format("【排名:%s】,用户名:%s,秒杀商品:%s", num.incrementAndGet(), user.getUserName(), goodsBo.getGoodsName()));
String key = user.getId() + ":" + goodId;
redisService.set(SeckillKey.skillUser, key, Boolean.TRUE, Const.RedisCacheExtime.Second60);
seckillOrderService.insert(user, goodsBo);
}
}
}