微服务高并发秒杀实战

什么是秒杀

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

秒杀系统场景特点

  • 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。
  • 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功。
  • 秒杀业务流程比较简单,一般就是下订单减库存。

解决高并发(秒杀),我们应该从前端,后台统筹考虑,下面详细讲解具体实现方式:

一 . 前端优化

1. 采用动静分离架构(前端优化核心)

动态数据(后台代码)一般放在ECS等云服务器,静态数据(js/css/img实现压缩减少带宽的传输)放入七牛云或者阿里云OSS等第三方资源服务器,并使用CDN加速,从而减少客户端与服务端带宽传输。

如下图所示一般详情页的图片是不变的,可以直接在页面写死即可,如果想做成动态的,只要保证后台数据库商品图片存的路径为oss即可,这样可以减少图片传输所占用的带宽。

2. 防止重复提交:提交后按钮disabled,防止用户重复提交

3. 商品详情页面:使用Nginx+Lua+OpenResty实现商品详情页面的优化(需要掌握lua语言,不在本文讨论范围内)

二. 后端优化

1. 基于乐观锁/redis锁,防止库存超卖

这里拿乐观锁举例,redis锁原理都是一样的

① 不加版本号的行级悲观锁(多个用户操作一个商品,线程本身就是安全的,因为数据库默认开启行级锁)

@Update("update goods_table set inventory=inventory-1 where inventory > 0 and goods_id=#{goodsId}")
int inventoryDeduction(@Param("goodsId") Long goodsId);

② 带版本号的乐观锁(数据库需单独新增一个version字段,每次调接口,version加1)

@Select("SELECT order_id,goods_name,inventory,create_time,version from gooods_table where goods_id=#{goodsId}")
GoodsDto getByGoodsId(@Param("goodsId")Long goodsId);
@Update("update goods_table set inventory=inventory-1,version=version+1 where inventory > 0 and goods_id=#{goodsId} and version=#{version}")
int inventoryDeduction(@Param("goodsId")Long goodsId,@Param("version")Long version);
@Transactional
public BaseResponse<JSONObject> spike(String phone, Long GoodsId) {
    GoodsDto goodsDto = seckillMapper.getByGoodsId(GoodsId);
    if (goodsDto == null) {
        return setResultError("商品信息不存在!");
    }
    Long version = goodsDto.getVersion();// 先获取版本号
    int row = seckillMapper.inventoryDeduction(GoodsId, version);// 减库存
    // 后续可以生成订单到订单表。。。
}

使用Jmeter工具压测一下,库存初始化100,模拟200个请求,结论是无论如何都不会出现库存为0的现象(超卖)

但方式①,200个请求,会有100个抢购成功,剩余库存为0,方式②,200个请求只有66个请求成功,剩余库存为34。

实际开发中也可以使用redis锁进行控制,详细参考博主之前的博客!

2. 基于Redis对用户实现频率限制(限流一般都会在网关做)

为了防止某个客户端一直刷下单接口,可以基于redis的setNx命令,实现抢购时用户的频率限制:

@Transactional
public BaseResponse<JSONObject> spike(String phone, Long GoodsId) {
    GoodsDto goodsDto = seckillMapper.getByGoodsId(GoodsId);
    if (goodsDto == null) {
        return setResultError("商品信息不存在!");
    }
    // 用户频率限制 setnx 如果key存在话
    Boolean reusltNx = redisUtil.setNx(phone, seckillId + "", 10l);
    if (!reusltNx) {
        return setResultError("访问次数过多,10秒后再实现重试!");// 直接return,无需执行下面的version++操作
    }
    Long version = goodsDto.getVersion();// 先获取版本号
    int row = seckillMapper.inventoryDeduction(GoodsId, version);// 减库存
    // 后续可以生成订单到订单表。。。
}
@Component
public class RedisUtil {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 如果key存在的话返回fasle 不存在的话返回true(原生redis的setNx命令会返回0或1)
    public Boolean setNx(String key, String value, Long timeout) {
	Boolean setIfAbsent = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
	if (timeout != null) {
	    stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);
	}
	return setIfAbsent;
    }

    public void setList(String key, List<String> listToken) {
        stringRedisTemplate.opsForList().leftPushAll(key, listToken);
    }
}

    

3. 基于库存令牌桶 + MQ 实现异步修改库存并提交订单

在高并发情况下,如果有1万个用户同时秒杀某一商品,对数据库频繁的IO操作,可能会产生数据库崩溃问题(分表分库,读写分离治标不治本),解决方法:基于MQ+库存令牌桶实现

同时有1万个请求实现秒杀,商品库存只有100个, 实现只需要修改库存100次就可以了:

方案实现流程:商品库存是多少,就提前在redis生成多少对应的库存令牌(这里即为100),在1万个请求中,只要谁能够获取到令牌谁就能够秒杀成功, 获取到秒杀令牌后,在使用mq异步实现修改减去库存。

代码实现:

① 编写生成100个令牌桶接口

    

② 编写获取token代码,并用mq异步发送

 RabbitMQ消费者

@Component
Slf4j
public class StockConsumer {
    @Autowired
    private SeckillMapper seckillMapper;
    @Autowired
    private OrderMapper orderMapper;
    @RabbitListener(queues = "modify_inventory_queue")
    @Transactional
    public void process(Message message, @Headers Map<String, Object> headers, Channel channel) throws IOException {
	String messageId = message.getMessageProperties().getMessageId();
	String msg = new String(message.getBody(), "UTF-8");
	JSONObject jsonObject = JSONObject.parseObject(msg);
	// 1.获取秒杀id
	Long goodsId = jsonObject.getLong("seckillId");
	SeckillEntity seckillEntity = seckillMapper.findBySeckillId(goodsId);
	if (seckillEntity == null) {
	    log.warn("goodsId:{},商品信息不存在!", goodsId);
	    return;
	}
	Long version = seckillEntity.getVersion();
        // 2.减库存
	int inventoryDeduction = seckillMapper.inventoryDeduction(goodsId, version);
	if (!toDaoResult(inventoryDeduction)) {
	    log.info(">>>seckillId:{}修改库存失败", goodsId);
	    return;
	}
	// 3.添加订单
	OrderEntity orderEntity = new OrderEntity();
	String phone = jsonObject.getString("phone");
	orderEntity.setUserPhone(phone);
	orderEntity.setSeckillId(goodsId);
	orderEntity.setState(1l);
	int insertOrder = orderMapper.insertOrder(orderEntity);
	if (!toDaoResult(insertOrder)) {
	    return;
	}
	log.info(">>>成功消费seckillId:{},秒杀成功!", goodsId);
    }
    // 调用数据库层判断
    public Boolean toDaoResult(int result) {
        return result > 0 ? true : false;
    }
}

③  提供一个根据用户信息查询秒杀结果接口(实际开发中,也可以根据userId)

@GetMapping("/checkSpike")
public BaseResponse<JSONObject> getOrder(String phone, Long goodsId) {
    if (StringUtils.isEmpty(phone)) {
	return setResultError("手机号码不能为空!");
    }
    if (goodsId== null) {
    	return setResultError("商品库存id不能为空!");
    }
    OrderEntity orderEntity = orderMapper.findByOrder(phone, goodsId);
    if (orderEntity == null) {
    	return setResultError("正在排队中.....");// 要么还没被mq消费,要么没抢到token令牌
    }
    return setResultSuccess("恭喜你秒杀成功!");
}
public interface OrderMapper {
    @Select("SELECT goods_id,user_phone,stateFROM order_table WHERE USER_PHONE=#{phone} and goods_id=#{goodsId} AND STATE=1")
    OrderEntity findByOrder(@Param("phone")String phone, @Param("goodsId")Long goodsId);
}

前端需要写一个定时器,用于查询秒杀成功状态:

前端调用秒杀接口spike,如果秒杀成功的话,返回正在排队中。。。

前端写一个定时器调用checkSpike接口,使用手机号/userId + 商品id查询是否秒杀成功。

(如果调用spike返回售罄,则前端不用写定时器)

    

三. 网关优化

1. 基于Google的guava实现限流(基于令牌桶)

令牌桶实现原理: 以规定的速率往令牌桶中存入Token,用户请求必须获取到令牌中的Token才可以处理 请求,如果没有从令牌桶中获取到令牌则丢失该请求。 例如:令牌桶中最多只能存放50个Token,以规定速率存入Token实现在高并发情况下限流 。

Google的Guava工具包中就提供了一个限流工具类——RateLimiter,本文也是通过使用该工具类来实现限流功能。RateLimiter是基于“令牌通算法”来实现限流的,具体步骤如下:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
</dependency>

在zuul网关过滤器的run方法里,加入以下代码:

@Component
@Slf4j
public class GatewayFilter extends ZuulFilter {
    // 每秒存入令牌中token数为1
    private RateLimiter rateLimiter = RateLimiter.create(1);
    public Object run() throws ZuulException {
        /** 这里省略一系列的验证token,黑名单白名单等... */
        // 阻塞等待超时时间,这里设为0,如果没有拿到令牌,直接拒绝访问,无需等待
        boolean tryAcquire = rateLimiter.tryAcquire(0, TimeUnit.SECONDS);
	if (!tryAcquire) {// 返回false,表示没有获取到令牌,直接return
	    resultError(500, ctx, "现在抢购的人数过多,请稍等一下下哦!");
	    return;// 如果
	}
        // 否则,获取到令牌,放行,继续执行后续逻辑,待所有过滤都通过,则直接访问秒杀接口...
    }
}

2. 使用Hystrix实现服务线程池隔离

默认情况下,上面的秒杀接口spike和查询秒杀结果接口checkSpike,都在一个线程池;在高并发场景,秒杀接口的压力会非常大,当一秒内用户全部请求秒杀接口,线程池都去处理秒杀接口,没有空闲线程去处理查询秒杀结果接口,这时候会产生延迟等待问题(默认tomcat只有一个线程池去处理所有请求,一旦线程池满了,导致其他线程无法访问)。

同时请求spike和checkSpike接口,打印日志如下:

>>>>>秒杀接口线程池名称:http-nio-9800-exec-1
>>>>>查询秒杀结果线程名称:http-nio-9800-exec-2

可以看到,两个接口处于同一个线程池,下面引入Hystrix实现服务降级,隔离:

<!-- 引入hystrix依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

  启动类加入该注解:@EnableHystrix

  秒杀接口,加入hystrix实现服务降级,线程池隔离

@HystrixCommand(fallbackMethod = "spikeFallback")
public BaseResponse<JSONObject> spike(String phone, Long seckillId) {
    // 上面spike里面的逻辑...
}
private BaseResponse<JSONObject> spikeFallback(String phone, Long seckillId) {
	return setResultError("服务器忙,请稍后重试!");
}

再次请求spike和checkSpike接口,打印日志如下:

>>>>>秒杀接口线程池名称:hystrix-SpikeApiImpl-1
>>>>>查询秒杀结果线程名称:http-nio-9800-exec-3

可以看到,两个接口的线程池是不同的;网络延迟的情况下,防止用户一直等待,会走服务降级方法spikeFallback,返回一个友好提示!

秒杀思路总结

前端:①.动静分离  ②.防止表单重复提交  ③.秒杀详情页面,使用定时器根据用户信息查询(对应后台第4点)

网关:①.限流  ②.用户黑名单和白名单拦截

后台:

1. 服务降级,隔离,熔断(Hystrix)

2. 从redis中获取秒杀的令牌(能够获取到令牌就能够秒杀成功,否则就秒杀失败)

3. 异步使用MQ执行修改库存,提交订单记录操作

4. 提供一个根据用户信 息查询秒杀结果接口(对应前端第③点)

拓展:

现在有100个商品同时抢购秒杀,每个商品库存为100个,那么基于库存令牌桶+mq会生成10000个操作,数据库IO压力还是非常大,如何解决?

答:可以借鉴12306的分时段秒杀,同时我们秒杀商品,也不可能一次性秒杀光!

发布了45 篇原创文章 · 获赞 20 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/AkiraNicky/article/details/93197610
今日推荐