前言
疫情期间,闲来无事,空闲时间利用起来,秒杀系统走起。
秒杀业务流程
要做秒杀系统,先弄明白具体业务流程。
用户点击秒杀按钮、跳转到订单页面、填写好订单信息后(地址、数量等信息)、点击提交订单按钮、生成订单。
以上就是一个成功下单的基本流程。这里我们不关心前端的控制,只实现java后台。所以直接从用户点击提交按钮开始。
概括为:用户点击提交订单按钮向后台发送请求。请求内容包括秒杀id,商品id,用户id等信息。后台根据信息查看库存是否充足,如果充足扣减库存,如果扣减库存成功,生成订单信息,提示用户下单成功。
当然前端向服务器发送消息后,nginx需要做限流处理,尤其是秒杀业务,比如后台tomcat集群最多能撑住10000/s的请求,那么需要做一些限流措施,确保到达集群的请求小于这个请求数。关于限流这块不做介绍,我们只考虑到达接口后的情形。
以上就是一个最简单的秒杀系统流程。像淘宝、京东等比这个秒杀流程要更繁琐。这里为了实现方便还是以最简单的流程为主。只要掌握这个流程,其他繁琐的流程也是信手拈来。
难点
一说秒杀系统,离不开的就是多线程、高并发,这也是该系统的难点。如何处理高并发,如何处理多线程同时修改一条库存记录呢?
思路一
上边已经说过,难点是解决高并发,高并发的难点是多个线程同时想要修改一条库存记录,如何保证数据的正确性。
我们可以利用redis,在秒杀前将需要秒杀的商品id和库存数量存入redis中,由于redis的原子性,多个线程竞争也是会先后执行,保证了数据的准确性。
假如现有某商品参加秒杀系统,库存数量为10,现有1000个线程来同时去减库存。
如果想测试上边的假设也很简单,准备一个redis服务,写个测试用例,用1000个线程就测试即可。
话不多说,开干!
首选,准备redis服务,以及java操作redis环境redisTemplate。如果这块不清楚的参看我的另外一篇文章
接着,在测试类中,通过redisTemplate向redis中存入一个商品及库存,代码如下:
@Test
public void test(){
redisTemplate.opsForValue().set("20200217",10);
}
接着查看是否插入成功:
@Test
public void test1(){
Object value = redisTemplate.opsForValue().get("20200217");
System.out.println(value);
}
接着写一个线程任务,用来测试上边的假设,
这块要明确减库存的步骤,首先判断库存数量-需求数量是否大于0,如果不满足条件,那么不能减库存。如果满足条件,执行减库存。想要让这个逻辑是原子性的,在redis中,只能通过lua脚本来保证。
lua脚本如下:
//lua脚本,先获取库存数量,假如库存数量大于等于当前需求数量,则执行库存数量减去需求数量,否则返回-1
private static final String LUA_SCRIPT = "if tonumber(redis.call('get',KEYS[1]))>=tonumber(ARGV[1]) then return redis.call('decrby',KEYS[1],ARGV[1]) else return -1 end";
线程任务如下:
private class MyRunnable implements Runnable{
@Override
public void run() {
Object result = redisTemplate.execute(
(RedisConnection connection) -> connection.eval(
LUA_SCRIPT.getBytes(),
ReturnType.INTEGER,
1,
"20200217".getBytes(),//商品id
"1".getBytes()));//这块的需求数量可以修改为其他值
System.out.println(result);
if(result.toString().equals("-1")){
System.out.println("库存为空,秒杀结束!");
}else {
System.out.println("秒杀成功!生成订单");
//下边执行生成订单的逻辑,
//假如生成订单失败,库存可以回滚
}
}
}
最后是测试程序代码:
@Test
public void test5(){
ExecutorService executorService = Executors.newCachedThreadPool();
//生成1000个线程来执行任务。
for(int i=0;i<1000;i++){
executorService.execute(new MyRunnable());
}
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
控制台成功输出结果,10次秒杀成功,其他都是秒杀失败。
对于这种方式,笔者觉得实现简单,效率也很高。但网上对这种实现方式的介绍很少,不明白为什么。
思路二
其实思路是跟思路一一样的,只是实现方式不一样,思路一是通过lua来实现原子性操作,思路二是通过锁的方式来实现原子性,就是说同一时间,只允许一个线程进入锁区域来操作库存记录。
由于高并发场景多数是集群环境,那么传统的sychronized关键字是不起作用的,这里就直接上分布式锁。
分布式锁的实现方式一般是通过redis或者zookeeper来实现,这里就使用redis来实现。
那么redis如何实现加锁呢?
加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。
SET lock_key random_value NX PX 5000
这个random_value要求是唯一的,因为等到解除锁时,需要通过该值来判断是否删除,如果不是唯一,有可能存在误删除锁的情况。
解锁的过程就是将Key键删除。但也不能乱删。通过random_value
来删除。
为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。
lua代码如下:
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
知道以上原理后,就可以通过java代码来实现了。
环境同思路一
先创建一个RedisLock工具类。这里就直接贴代码了,主要就是2个方法,加锁、解锁:
package com.ming.seckillredistest.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisLock {
private final long TIME_OUT=5000;//5秒钟后还未获取到锁设定为超时。
//释放锁的脚本,
private final String LUA_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
@Autowired
private RedisTemplate redisTemplate;
/**
* 加锁方法
* @param key
* @param value
* @return
*/
public boolean lock(String key,String value){
//开始获取锁的时间
long startTime=System.currentTimeMillis();
//如果获取不到锁一直阻塞,并尝试继续获取锁。
while (true){
boolean result=redisTemplate.opsForValue().setIfAbsent(key,value,3000, TimeUnit.MILLISECONDS);
if(result){
return true;
}
long tempTime=System.currentTimeMillis()-startTime;
if(tempTime>TIME_OUT){
return false;
}
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public boolean unlock(String key,String value){
String tempValue="\""+value+"\"";//由于config中对序列号的配置,这块需要做处理。
Object result = redisTemplate.execute((RedisConnection connection) -> connection.eval(
LUA_SCRIPT.getBytes(),
ReturnType.INTEGER,
1,
key.getBytes(),
tempValue.getBytes()));
if(result.toString().equals("0")){
return false;
}
return true;
}
}
锁完成了,就准备线程任务代码:
private class MyRunnable1 implements Runnable{
@Override
public void run() {
String lockKey="202002172031";
String lockValue= "202002172031001";
boolean lockResult = redisLock.lock(lockKey, lockValue);
if(lockResult){
Object value=redisTemplate.opsForValue().get("20200217");
if(Integer.parseInt(value.toString())>0){
redisTemplate.opsForValue().decrement("20200217");
System.out.println("秒杀成功,生成订单");
}else {
System.out.println("秒杀失败");
}
boolean unlock = redisLock.unlock(lockKey, lockValue);
if(!unlock){
System.out.println("释放锁失败!");
}
}else {
System.out.println("锁超时,秒杀失败");
}
}
}
最后,测试类调用:
@Test
public void test6(){
ExecutorService executorService = Executors.newCachedThreadPool();
//生成1000个线程来执行任务。
for(int i=0;i<1000;i++){
executorService.execute(new MyRunnable1());
}
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
测试结果成功输出到控制台,没有问题。
思路二的方式在网上比较常见,但相比思路一,感觉有些多余了。这块也是比较好奇,为什么没有人讨论思路一的方式。
最后,引用熬丙博客上的一张图片,来概括秒杀流程。
总结
本来想通过一篇文章来结束,写到这发现篇幅太长了,这篇文章定性为【高并发减库存】,接下来写tomcat的并发。