星哥带你学秒杀-我看你还是有机会的

星哥带你学秒杀-我看你还是有机会的_第1张图片

星哥带你学秒杀-我看你还是有机会的_第2张图片

一、前言

大家好,好久不发文章了。最近自己把秒杀系统搭建了一下,想给有需要帮助的童鞋学习。整个项目已经放到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

星哥带你学秒杀-我看你还是有机会的_第3张图片
项目地址:https://github.com/chenxingxing6/second_skill


二、如何理解秒杀系统

秒杀场景一般会在电商网站举行一些活动或者节假日在12306网站上抢票时遇到。对于电商网站中一些稀缺或者特价商品,电商网站一般会在约定时间点对其进行限量销售,因为这些商品的特殊性,会吸引大量用户前来抢购,并且会在约定的时间点同时在秒杀页面进行抢购。

2.1 秒杀系统场景特点
  • 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。 秒杀一般是访问请求。
  • 数量远远大于库存数量,只有少部分用户能够秒杀成功。
  • 秒杀业务流程比较简单,一般就是下订单减库存。
2.2 秒杀系统设计理念
  • 限流
  • 降级
  • 解决写问题:流量肖峰
  • 解决读问题:缓存
  • 机器可弹性扩容
  • 底层需要有个好的架构:分布式服务,读写分离

三、代码预览

星哥带你学秒杀-我看你还是有机会的_第4张图片

3.1 架构设计

星哥带你学秒杀-我看你还是有机会的_第5张图片

星哥带你学秒杀-我看你还是有机会的_第6张图片

3.2 项目启动说明

1、启动前,进行相关redis、mysql、rocketMq地址
2、登录地址:http://localhost:8888/page/login
3、商品秒杀列表地址:http://localhost:8888/goods/list
4、账号:18077200000,密码:123456

PS:测试时需要修改秒杀活动时间seckill_goods表开始和结束时间,然后确保库存足够。

3.3 如何模拟高并发(测试)

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();
           }
       });
   }
}
3.4 技术实现

1、页面缓存、商品详情静态化、订单静态化(生成html放缓存里面)
2、消息队列RocketMq进行流量肖峰
3、解决超卖问题 (文末有提3种方式,本应用用redis预减库存实现)
4、接口限流防刷(Redis,Guava)

3.5 页面截图

星哥带你学秒杀-我看你还是有机会的_第7张图片
星哥带你学秒杀-我看你还是有机会的_第8张图片
中奖名单:

【排名: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
.......

星哥带你学秒杀-我看你还是有机会的_第9张图片
星哥带你学秒杀-我看你还是有机会的_第10张图片


四、秒杀系统常见问题

4.1 高并发

特点:时间短,瞬间用户请求量巨大

大量的请求进来,我们需要考虑的点就很多了,缓存雪崩,缓存击穿,缓存穿透这些我之前提到的点都是有可能发生的,出现问题打挂DB那就很难受了,活动失败用户体验差,活动人气没了,最后背锅的还是开发。

4.2 超卖

但凡是个秒杀,都怕超卖,100个华为Pro50,商家的预算经费卖100个可以赚点还可以造势,结果你写错程序多卖出去200个,你不发货用户投诉你,平台封你店,你发货就血亏,你怎么办?

4.3 恶意请求

搞个几十台机器搞点脚本,我也模拟出来十几万个人左右的请求,那我是不是意味着我基本上有80%的成功率了。真实情况可能远远不止,因为机器请求的速度比人的手速往往快太多了。假如我抢到了,我转手卖掉我不是血赚?就算我不卖我也不亏啊!!!

4.4 秒杀链接加盐

把URL动态化,就连写代码的人都不知道,你就通过MD5之类的加密算法加密随机的字符串去做url,然后通过前端代码获取url后台校验才能通过。

4.5 服务单一职责

设计个能抗住高并发的系统,我觉得还是得单一职责。什么意思呢,大家都知道现在设计都是微服务的设计思想,然后再用分布式的部署方式单一职责的好处就是就算秒杀没抗住,秒杀库崩了,服务挂了,也不会影响到其他的服务。

4.6 Redis集群

我们知道单机的Redis顶不住,那简单多找几个兄弟啊,秒杀本来就是读多写少,那你们是不是瞬间想起来我之前跟你们提到过的,Redis集群,主从同步、读写分离,我们还搞点哨兵,开启持久化直接无敌高可用!

4.7 Nginx负载均衡

星哥带你学秒杀-我看你还是有机会的_第11张图片

4.8 资源静态化

秒杀一般都是特定的商品还有页面模板,现在一般都是前后端分离的,所以页面一般都是不会经过后端的,但是前端也要自己的服务器啊,那就把能提前放入cdn服务器的东西都放进去,反正把所有能提升效率的步骤都做一下,减少真正秒杀时候服务器的压力。

4.9 按钮控制

大家有没有发现没到秒杀前,一般按钮都是置灰的,只有时间到了,才能点击。这是因为怕大家在时间快到的最后几秒秒疯狂请求服务器,然后还没到秒杀的时候基本上服务器就挂了。这个时候就需要前端的配合,定时去请求你的后端服务器,获取最新的北京时间,到时间点再给按钮可用状态。

4.10 库存预热

秒杀的本质,就是对库存的抢夺,每个秒杀的用户来你都去数据库查询库存校验库存,然后扣减库存,撇开性能因数,你不觉得这样好繁琐,对业务开发人员都不友好,而且数据库顶不住啊。所以我们可以先将库存加载到redis中预热,然后秒杀了,在对db库存进行异步修改。

4.11 限流

前端限流:秒杀一般不会让你一直点的,一般都是点击一下或者两下然后几秒之后才可以继续点击,这也是保护服务器的一种手段。
后端限流:秒杀的时候肯定是涉及到后续的订单生成和支付等操作,但这时只有秒杀成功的请求才会执行后续操作。常用技术方案(Sentinel,Hystrix)

4.12 流量肖峰

把所有请求它入消息队列,然后一点点消费去改库存就好了嘛


五、解决超卖实现的3种方式

5.1 Mysql排他锁

update goods set num = num - 1 WHERE id = 1001 and num > 0

排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排 他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就 行读取和修改。就是类似于我在执行update操作的时候,这一行是一个事务(默认加了排他锁)。这一行不能 被任何其他线程修改和读写。

5.2 乐观锁版本控制

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的原理。

5.3 Redis单线程预减库存(lua脚本)

利用redis的单线程预减库存。比如商品有100件。那么我在redis存储一个k,v。例如 每一个用户线程进来,key值就减1,等减到0的时候,全部拒绝剩下的请求。那么也就是只有100个线程会进入到后续操作。所以一定不会出现超卖的现象。

 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。


六、限流(Redis实现)

@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
	int seconds();
	int maxCount();
	boolean needLogin() default true;
}

星哥带你学秒杀-我看你还是有机会的_第12张图片

  //接口限流
 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分支里面:下载地址

7.1 基于数据库实现 (效率低,不推荐使用)
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();操作来释放锁

7.2 Redis (使用redisson,释放锁要小心)

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()));
    }
    }
7.3 Zookeeper(临时节点,效率高)

原理:使用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;
}

八、MQ异步处理

8.1 发送消息
 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);
    }

星哥带你学秒杀-我看你还是有机会的_第13张图片

星哥带你学秒杀-我看你还是有机会的_第14张图片

8.2 处理消息
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);
		}
	}
}


你可能感兴趣的:(蓝星花,秒杀,高并发,入门,demo,java)