秒杀系统

1.分布式session问题

在高并发的情况下,每次请求Nginx的负载均衡算法会分配不同的服务器,而我们的session在某一服务器的tomcat中,这样无法维护我们的登陆状态。我们采用redis集中存储的方法来解决此问题。其逻辑为:通过uuid生成值作为cookie值返回给客户端,在后端,我们将uuid值为key,value为用户对象,将其存入redis中即可。每次页面跳转我们只需要根据cookie值去redis找用户信息即可。

 

//生成Cookie
        String userTicket = UUIDUtil.uuid();
        //将用户信息存入redis
        redisTemplate.opsForValue().set("user:" + userTicket, user);

//        request.getSession().setAttribute(userTicket, user);
        CookieUtil.setCookie(request, response, "userTicket", userTicket);

 

同时我们优化每次页面跳转的状态验证,类似拦截器的操作,我们重写webmvcconfigure中的addArgumentResolver方法,并在实现HandlerMethodargumentResolver中实现每次页面跳转状态验证的逻辑,其逻辑为根据我们的cookie值判断redis中是否存在user对象若存在正常跳转,不存在则跳转登陆页面。

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private UserArgumentResolver userArgumentResolver;
    @Autowired
    private AccessLimitInterceptor accessLimitInterceptor;

    //添加解析器以支持自定义控制器方法参数类型。
    //该方法可以用在对于Controller中方法参数传入之前对该参数进行处理。然后将处理好的参数在传给Controller中的方法。
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
//        WebMvcConfigurer.super.addArgumentResolvers(resolvers);
        resolvers.add(userArgumentResolver);
    }

  

@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    private ITUserService itUserService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> parameterType = parameter.getParameterType();
        return parameterType == TUser.class;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        return UserContext.getUser();

        //        HttpServletRequest nativeRequest = webRequest.getNativeRequest(HttpServletRequest.class);
//        HttpServletResponse nativeResponse = webRequest.getNativeResponse(HttpServletResponse.class);
//        String userTicket = CookieUtil.getCookieValue(nativeRequest, "userTicket");
//        if (StringUtils.isEmpty(userTicket)) {
//            return null;
//        }
//        return itUserService.getUserByCookie(userTicket, nativeRequest, nativeResponse);
    }

}

 

2.表结构  

秒杀系统中一共存在五张表,分别是用户表,商品表,秒杀商品表,订单表,秒杀订单表。五张表对应于entity层的五个不同的类。

 

CREATE TABLE t_user(
	`id` BIGINT(20) NOT NULL COMMENT '用户ID,手机号码',
	`nickname` VARCHAR(255) not NULL,
	`password` VARCHAR(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt)+salt)',
	`salt` VARCHAR(10) DEFAULT NULL,
	`head` VARCHAR(128) DEFAULT NULL COMMENT '头像',
	`register_date` datetime DEFAULT NULL COMMENT '注册时间',
	`last_login_date` datetime DEFAULT NULL COMMENT '最后一次登录事件',
	`login_count` int(11) DEFAULT '0' COMMENT '登录次数',
	PRIMARY KEY(`id`)
)
COMMENT '用户表';
------------------------------------------------
CREATE TABLE t_goods(
	id BIGINT(20) not NULL AuTO_increment COMMENT '商品ID',
	goods_name VARCHAR(16) DEFAULT NULL COMMENT '商品名称',
	goods_title VARCHAR(64) DEFAULT NULL COMMENT '商品标题',
	goods_img VARCHAR(64) DEFAULT NULL COMMENT '商品图片',
	goods_detail LONGTEXT COMMENT '商品详情',
	goods_price DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品价格',
	goods_stock INT(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
	PRIMARY KEY(id)
)
COMMENT '商品表';
------------------------------------------------
CREATE TABLE `t_order` (
	`id` BIGINT(20) NOT NULL  AUTO_INCREMENT COMMENT '订单ID',
	`user_id` BIGINT(20) DEFAULT NULL COMMENT '用户ID',
	`goods_id` BIGINT(20) DEFAULT NULL COMMENT '商品ID',
	`delivery_addr_id` BIGINT(20) DEFAULT NULL  COMMENT '收获地址ID',
	`goods_name` VARCHAR(16) DEFAULT NULL  COMMENT '商品名字',
	`goods_count` INT(20) DEFAULT '0'  COMMENT '商品数量',
	`goods_price` DECIMAL(10,2) DEFAULT '0.00'  COMMENT '商品价格',
	`order_channel` TINYINT(4) DEFAULT '0'  COMMENT '1 pc,2 android, 3 ios',
	`status` TINYINT(4) DEFAULT '0'  COMMENT '订单状态,0新建未支付,1已支付,2已发货,3已收货,4已退货,5已完成',
	`create_date` datetime DEFAULT NULL  COMMENT '订单创建时间',
	`pay_date` datetime DEFAULT NULL  COMMENT '支付时间',
	PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT=12 DEFAULT CHARSET = utf8mb4;
COMMENT '订单表'
;
------------------------------------------------
CREATE TABLE `t_seckill_goods`(
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀商品ID',
	`goods_id` BIGINT(20) NOT NULL COMMENT '商品ID',
	`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀家',
	`stock_count` INT(10) NOT NULL  COMMENT '库存数量',
	`start_date` datetime NOT NULL  COMMENT '秒杀开始时间',
	`end_date` datetime NOT NULL COMMENT '秒杀结束时间',
	PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT=3 DEFAULT CHARSET = utf8mb4
COMMENT '秒杀商品表'
;
------------------------------------------------
CREATE TABLE `t_seckill_order` (
	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀订单ID',
	`user_id` BIGINT(20) NOT NULL  COMMENT '用户ID',
	`order_id` BIGINT(20) NOT NULL  COMMENT '订单ID',
	`goods_id` BIGINT(20) NOT NULL  COMMENT '商品ID',
	PRIMARY KEY(`id`)
)ENGINE = INNODB AUTO_INCREMENT=3 DEFAULT CHARSET = utf8mb4
COMMENT '秒杀订单表'
;

 

当前端页面需要返回秒杀商品列表页面,我们新建一个vo对象,通过秒杀商品表外建关联商品表查询结果组合成新的vo对象,其具体方法如下:

@ApiModel("商品返回对象")
//新建vo对象继承goods类
public class GoodsVo extends TGoods {

    /**
     * 秒杀价格
     **/
    @ApiModelProperty("秒杀价格")
    private BigDecimal seckillPrice;

    /**
     * 剩余数量
     **/
    @ApiModelProperty("剩余数量")
    private Integer stockCount;

    /**
     * 开始时间
     **/
    @ApiModelProperty("开始时间")
    private Date startDate;

    /**
     * 结束时间
     **/
    @ApiModelProperty("结束时间")
    private Date endDate;

    public BigDecimal getSeckillPrice() {
        return seckillPrice;
    }

    public void setSeckillPrice(BigDecimal seckillPrice) {
        this.seckillPrice = seckillPrice;
    }

    public Integer getStockCount() {
        return stockCount;
    }

    public void setStockCount(Integer stockCount) {
        this.stockCount = stockCount;
    }

    public Date getStartDate() {
        return startDate;
    }

    public void setStartDate(Date startDate) {
        this.startDate = startDate;
    }

    public Date getEndDate() {
        return endDate;
    }

    public void setEndDate(Date endDate) {
        this.endDate = endDate;
    }
}

  

在controler层调用service层的findgoodvo接口,返回vo列表
model.addAttribute("goodsList", itGoodsService.findGoodsVo());

在goodservice中findgoodvo的具体实现
 @Override
    public List<GoodsVo> findGoodsVo() {
        return tGoodsMapper.findGoodsVo();
    }


然后在mapper中声明该方法,到tgoodsmapper中定义sql
<select id="findGoodsVo" resultType="com.example.seckilldemo.vo.GoodsVo">
        SELECT g.id,
               g.goods_name,
               g.goods_title,
               g.goods_img,
               g.goods_price,
               g.goods_stock,
               sg.seckill_price,
               sg.stock_count,
               sg.start_date,
               sg.end_date
        FROM t_goods g
                 LEFT JOIN t_seckill_goods sg on g.id = sg.goods_id
    </select>

3.秒杀接口的实现 

秒杀按钮一共有三种状态,秒杀倒计时、秒杀进行中、秒杀已结束,通过后端传的标记位来对应不同的状态。后端逻辑:从秒杀商品详情中获取秒杀开始时间,然后与当前时间比较返回不同的状态值,如果是秒杀倒计时,通过前端的定时器刷新时间,避免频繁调用后端,当倒计时时间为0后跳转到秒杀进行中。  

 

 

4.解决超卖问题(1)

1.解决同一用户多次秒杀问题,该用户第一次秒杀成功生成秒杀订单后,在redis中保存userid+goodid生成的唯一标志最为key,在秒杀的controller层加入判断redis是否存在此key的逻辑

2.解决多个用户并发秒杀问题,首先在controller会判断数据库中是否有库存,若有库存进入秒杀,秒杀接口开启注解@Transactional实现事务用来保证秒杀库存的一致性(mvcc)实现,这里默认的数据库隔离级别是可重复读,可以解决相同用户同时秒杀的并发问题。当然这种操作需要访问数据库处理十分慢。

 

4.解决超卖问题(2)

如果不加锁,多个用户同时进行下单时,同一时间查询到的库存数量假设为1,但有十个用户去update数据库会产生超卖现象。

加锁有两种方式:悲观锁与乐观锁

悲观锁依靠数据库提供的锁机制,流程如下:

  1. 在对记录修改前,先尝试为该记录加上排他锁
  2. 如果加锁失败,说明当前记录正在被修改,当前查询需要等待
  3. 如果加锁成功,就可以对记录做出修改,事务完成后就可以解锁
//0.开始事务
begin; 
//1.查询出商品库存信息
select quantity from items where id=1 for update;
//2.修改商品库存为2
update items set quantity=2 where id = 1;
//3.提交事务
commit; 

乐观锁借助cas机制,当多个线程尝试使用cas同时更新一个变量时,只有其中一个线程能更新变量的值,其他线程竞争失败,然后在此尝试竞争

在数据库中乐观锁的具体实现时➕version字段,若更新时版本号与当前数据库中版本号一致,则可以成功更新,若多个线程有相同的版本号同时更新,只能有一个成功。

 

//查询出商品信息,version = 1
select version from items where id=1
//修改商品库存为2
update items set quantity=2,version = 2 where id=1 and version = 1;

 

该方法在高并发的时候,只有一个线程可以修改成功,那么就会存在大量的失败,所以要减小乐观锁的力度,提高并发能力。

//修改商品库存
update item 
set quantity=quantity - 1 
where id = 1 and quantity - 1 > 0
//通过quantity-1>0进行乐观锁的控制

  

4.解决超卖问题(3)

该方法开启了事务的注解,默认情况下通过MVCC实现可重复读(MVCC相当于一种乐观锁机制),在可重复读中未提交的更改数据对于新事务是不可见的。假设现在有A,B两个线程同时进行秒杀,此时库存数量是1,A先查询数据库数量是1,然后B查询数量,因为此时A事务并未提交所以B查询的数量仍未1,然后Aupdate库存,若此时B也想update库存,则一定要等到A事务提交,因为Aupdate的时候会给此数据加上排他锁,只有当事务A提交是才会释放锁,而B事务更新时不在利用mvcc版本号读取当前库存,而是要做到当前读,也就意味着事务A要提交后,事务B才能update库存,这时候库存数量会变成-1。

  

 

5.redis预减库存+rabbitmq异步下单

1.若每次秒杀判断库存都访问数据库会给数据库造成很大压力,这里在项目启动时,将不同秒杀商品的库存数量存入redis中,判断库存数量则可以直接从redis访问,同时设置一个内存标记,若redis中商品数量减为零则直接返回,减少redis的访问。

2.rabbitmq的topic模式实现异步下单,在秒杀的controller层预减库存后,将user与goodid封装成对象通过rabbitmq发送,然后直接返回给前端正在秒杀中,rabbitmq接受者接收到消息后,再进行一次判断库存是否为零,同一用户是否重复秒杀,然后再进行下单服务的操作。

 

6.客户端轮询秒杀结果

redis预减库存后,返回标记为0代表正在秒杀中,然后后段进行秒杀服务。这时前端会不断轮询getResult接口,然后后段根据user和goodsid会去查询秒杀订单数据库是否有订单,若存在订单则直接返回订单号,若后端在访问数据库时发现内存已满,会在redis中存一个标记,若订单不存在则访问redis是否存在该标记,若存在则返回秒杀失败,不存在则返回正在秒杀中。

 

7.redis分布式锁解决缓存击穿问题

为了减少对数据库的访问,我们采用redis预减库存的方法进行流量削峰,在项目启动时就会将秒杀商品库存加入到redis中,但可能会存在redis key过期或者被误删等操作,若此时有大量的请求进行秒杀,但redis中无法查到库存,则会同时将请求打到数据库中,造成缓存击穿问题。

这是我们采用分布式锁,当某一个请求在redis中无法找到秒杀商品对应库存,这时首先加上分布式锁,然后到数据库中查询并将其库存放入redis中。(目的是不想让大量请求直接访问数据库)

 

        //判断redis中是否存在秒杀商品的库存数量。
        Integer seckillGoodscount = (Integer) redisTemplate.opsForValue().get("seckillGoods:" + goodsId);
        //如果redis不存在秒杀商品的数量,加分布式锁访问数据库将秒杀商品数量加入redis中
        String value = UUID.randomUUID().toString();
        Boolean isLock = valueOperations.setIfAbsent("seckillGoodsCount", value,5,TimeUnit.SECONDS);
        if(seckillGoodscount == null){
            if(isLock){
                TSeckillGoods seckillGoods = itSeckillGoodsService.getOne(new QueryWrapper<TSeckillGoods>().eq("goods_id", goodsId));
                redisTemplate.opsForValue().set("seckillGoods:" + seckillGoods.getId(), seckillGoods.getStockCount());
                EmptyStockMap.put(seckillGoods.getId(), false);
                Boolean result = (Boolean) redisTemplate.execute(script,Collections.singletonList("seckillGoodsCount"),value);
            }else{
                return RespBean.error(RespBeanEnum.REQUST_AGAIN);
            }

        }

 

分布式锁的实现方法?

首先通过redistemplete中setifabsent设置key值作为一个锁占坑,同时要加上通过uuid生成的一个随机数作为该锁的随机值或者版本号,并加上一个过期时间。如果加锁成功就处理具体业务逻辑。处理完成后需要删除锁,其逻辑为查询该锁的随机值,比较该锁的随机值与当前的随机值,如果一致则删除锁。由于这三个操作并不是原子性的,我们可以通过lua脚本实现redis原子性操作。  

 

 

8.在拦截器中实现限流

1)如何定义拦截器

  • AccessLimitInterceptor继承HandlerInterceptor类,其中可以重写preHandle、postHandler、afterCompletion三个方法
  • 我们在preHandle中实现限流的逻辑
  • 在WebMVConfigurer中的addInterceptors中添加我们定义的拦截器,这里可以添加具体对哪些接口拦截,若不添加则对所有接口拦截。

2) 手写令牌桶算法实现限流

这里我们实现非阻塞式令牌桶算法,其主要数据结构有令牌生成速度(limitqps)、最大令牌数(maxPermits)、当前以有令牌数(currentPermits)、下次请求可以执行的时间(nextRequestTIMEstamp)。实现主要接口为获取令牌--acquire()、惰性增加令牌--increasePermits()、获取令牌需要花费的时间--getSpendTime()

  • acquire(int permits,int permitTIme):  获取当前时间,调用getSpendTime()得到消费令牌需要花费的时间,若该时间大于permitTime,返回false否则若为0直接返回为具体数字则睡眠该数字即可。
  • getSpendTime(int permits, long nowTimeStamp):首先将nextRequestTIMEstamp设置为当前的时间,然后惰性增加令牌桶中的数量,然后判断当前桶中的数量是否够消费,若不够则需要提前借令牌,即计算需要产生差额令牌的时间,然后下一次请求的时间需要加上该时间,返回max(下一次请求时间-当前时间,0),正常来说下一次请求时间就是上次请求提供的时间,大概率是效率当前时间的,但是可能会存在上次差额令牌消耗过多时间导致下一次请求时间大于下一次请求时间。注;该接口需要加锁因为涉及需要修改成员变量。
  • increasePermits(long nowtimeStamp):首先计算当前时间与下一次请求时间的差值,然后计算这段时间可以产生多少令牌,若当前令牌加上该令牌数量小于总令牌数量则加上否则设置为总令牌数量,下一次请求可以执行的时间改为nextRequestTIMEstamp。
import org.springframework.stereotype.Component;

@Component
public class MyRateLimiter {
    // 令牌生成的速度
    int limitQps = 1;
    // 最大令牌数
    int maxPermits = 100000;
    // 当前已有令牌数
    int currentPermits = 0;
    // 下次请求可以执行的时间
    long nextRequestExcuteTimeStamp = System.currentTimeMillis();

    // 惰性增加令牌数
    void increasePermits(long nowTimeStamp) {
        // 当前时间晚于下次请求可以执行的时间,也就意味着会有多余的令牌生成
        if (nowTimeStamp > nextRequestExcuteTimeStamp) {
            // 算一下晚了多少毫秒
            long interval = nowTimeStamp - nextRequestExcuteTimeStamp;
            // 用时间间隔 * qps 得出这段间隔能生成多少令牌
            int increasePermits = (int) ((limitQps * interval) / 10000);
            if ((increasePermits + currentPermits) <= maxPermits)
                currentPermits += increasePermits;
            // 因为令牌数已经刷新了,所以时间也要改一下
            nextRequestExcuteTimeStamp = nowTimeStamp;
        }
    }

    public boolean acquire(int permits,long permitTime) {
        long nowTimeStamp = System.currentTimeMillis();
        // 计算获取令牌要等多长时间
        long toWaitTime = getSpendTime(permits, nowTimeStamp);
        System.out.println(toWaitTime);
        if (toWaitTime > 0&&toWaitTime<permitTime) {
            try {
                Thread.sleep(toWaitTime);
                return true;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else if(toWaitTime == 0){
            return true;
        }

        return false;
    }

    public synchronized long getSpendTime(int permits, long nowTimeStamp) {
        long retrunTime = nextRequestExcuteTimeStamp;
        // 刷新当前令牌数
        increasePermits(nowTimeStamp);
        // 判断当前令牌够不够用,如果不够,需要多长时间
        long toWaitTime = 0L;
        if (permits > currentPermits) {
            // 计算额外获取的令牌要多次时间生成
            int toWaitPermits = permits - currentPermits;
            toWaitTime = toWaitPermits * (1 / limitQps);
            // 当前令牌数自然就消耗完了
            currentPermits = 0;
        } else {
            // 如果当前够用,就减掉令牌数
            currentPermits -= permits;
        }

        nextRequestExcuteTimeStamp += toWaitTime;
        // 就算第一次有额外令牌消耗也不等待
        return Math.max(retrunTime - nowTimeStamp, 0);
    }
}

3)redis实现计数器算法针对单个用户限流  

秒杀是同一用户在同一时间可能会有大量请求,我们利用redis的计数器算法针对次进行限流,其具体逻辑是我们针对同一用户设定一个key:该接口的url+用户id,value是该用户在规定时间内的访问数量(即该key的超时时间)。若该key不在redis中则加进去,若可以查到key则判断是否小于我们设置的count,若小于则自增1(redis的自增是线程安全的)否则返回秒杀失败

 

 

 

 

 

  

 

posted @ 2023-01-26 21:54  lyjps  阅读(124)  评论(0)    收藏  举报