Redis日记(day04):Java中使用Jedis操作Redis

369 阅读16分钟

第4章:Java中使用Jedis操作Redis

1、Jedis介绍

以前学过一个技术,叫做JDBC,是用Java来操作我们的数据库,而Jedis原理与jdbc相似,我们可以使用java操作Redis,以代替我们前面利用命令行操作Redis的方式

Redis是一个著名的key-value存储系统,也是nosql中的最常见的一种。其实,个人认为,redis最强大的地方不在于其存储,而在于其强大的缓存作用。我们可以把它想象成一个巨大的(多借点集群,聚合多借点的内存)的Map,也就是Key-Value。所以,我们可以把它做成缓存的中间件

官方推荐的Java版的客户端是jedis,非常强大和稳定,支持事务、管道及有jedis自身实现。Jedis 集成了 redis 的一些命令操作,封装了对 redis 命令的 java 客户端。我们对redis数据的操作,都可以通过jedis来完成。

2、Jedis构造方法

public Jedis() {
    super();
}

public Jedis(final String host) {
    super(host);
}

//参数:Redis所在主机的ip地址,Redis端口号
public Jedis(final String host, final int port) { 
    super(host, port);
}
............

3、Jedis基本使用

3.1、Maven依赖

使用jedis需要引入jedis的jar包,下面提供了相关的maven依赖。其中jedis.jar是封装的包

<dependencies>
    #导入jedis的包
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>3.2.0</version>
    </dependency>
    #导入存储数据的fastjson
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.75</version>
    </dependency>

    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.12</version>
        <scope>compile</scope>
    </dependency>
</dependencies>

3.2、新建Maven工程

这里我们先创建一个空项目,然后在创建我们这次练习的Maven工程

1、新建空项目

image.png

2、创建Maven工程

image.png

3、JDK等环境设置

image.png

image.png

4、目录如下

image.png

3.3、测试连接

pom文件中引入上面的依赖,并编写测试类

public class TestJedisDemo1 {
    @Test
    public void run1() {
        //创建jedis对象,传入Redis的主机IP及端口号
        Jedis jedis = new Jedis("192.168.62.130", 6379);

        //权限密码验证,如果设置了密码的话
        //jedis.auth("GgucbkHkV28618hvvj.~_dt#&68qjW");

        //看是否能连上Redis,能就返回一个值PONG,不能就报错
        String value = jedis.ping();
        System.out.println(value);  //PONG
        jedis.close();//要关闭
    }
}

连接Redis注意事项

  • 禁用Linux的防火墙:Linux(CentOS7)里执行命令systemctl stop/disable firewalld.service
  • 查看防火墙状态:systemctl status firewalld(Active表示打开状态)
  • redis.conf中注释掉bind 127.0.0.1 或者设置bind 0.0.0.0。然后 protected-mode = no,即不要Redis处于保护模式下,然后重启Redis(shutdown或kill掉reids,再启动)

4、Jedis测试相关数据类型

jedis的所有API就是我们之前学习的指令

4.1、Jedis-API:String

package com.test.jedis;
import org.junit.Test;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class TestJedisDemo1 {
    @Test
    public void run1() {
        //创建jedis对象
        Jedis jedis = new Jedis("192.168.62.130", 6379);

        jedis.flushDB();
        //Set()方法设置String类型的key-value ,jedis的所有命令就是我们之前学习的指令
        jedis.set("k1", "v1");
        jedis.set("k2", "v2");
        jedis.set("k3", "v3");
        //数目
        System.out.println(keys.size());
        //获取
        System.out.println(jedis.get("k1"));
        System.out.println(jedis.exists("k1")); //存活
        System.out.println(jedis.ttl("k1"));  //过期、存活时间
        //得到含有所有key的Set集合
        Set<String> keys = jedis.keys("*");
        //遍历keys集合
        for(String key : kyes){
            System.out.println(key);
        }

        //设置多个key-Value
        jedis.mset("str1","v1","str2","v2","str3","v3");
        System.out.println(jedis.mget("str1","str2","str3"));
        
        jedis.close();
    }
}

4.2、Jedis-API:List

@Test  
public void testList(){  
    Jedis jedis = new Jedis("192.168.44.168", 6379);
    //开始前,先移除所有的内容  
    jedis.del("java framework");  
    System.out.println(jedis.lrange("java framework",0,-1));  
    //先向key:java framework中存放三条数据  
    jedis.lpush("java framework","spring");  
    jedis.lpush("java framework","struts");  
    jedis.lpush("java framework","hibernate");  
    //再取出所有数据jedis.lrange是按范围取出,  
    // 第一个是key,第二个是起始位置,第三个是结束位置,jedis.llen获取长度 -1表示取得所有  
    System.out.println(jedis.lrange("java framework",0,-1));  

    jedis.del("java framework");
    jedis.rpush("java framework","spring");  
    jedis.rpush("java framework","struts");  
    jedis.rpush("java framework","hibernate"); 
    System.out.println(jedis.lrange("java framework",0,-1));
}

4.3、Jedis-API:set

@Test
public void testSet(){
    //添加
    jedis.sadd("user","liuling","xinxin","ling");
    jedis.sadd("user","who");
    //移除noname
    jedis.srem("user","who");
    System.out.println(jedis.smembers("user"));//获取所有加入的value
    System.out.println(jedis.sismember("user", "who"));//判断 who 是否是user集合的元素
    System.out.println(jedis.srandmember("user"));
    System.out.println(jedis.scard("user"));//返回集合的元素个数
}

@Test
public void test() throws InterruptedException {
    //jedis 排序
    //注意,此处的rpush和lpush是List的操作。是一个双向链表(但从表现来看的)
    jedis.del("a");//先清除数据,再加入数据进行测试
    jedis.rpush("a", "1");
    jedis.lpush("a","6");
    jedis.lpush("a","3");
    jedis.lpush("a","9");
    System.out.println(jedis.lrange("a",0,-1));// [9, 3, 6, 1]
    System.out.println(jedis.sort("a")); //[1, 3, 6, 9]  //输入排序后结果
    System.out.println(jedis.lrange("a",0,-1));
}

@Test
public void testRedisPool() {
    RedisUtil.getJedis().set("newname", "中文测试");
    System.out.println(RedisUtil.getJedis().get("newname"));
}
}

4.4、Jedis-API:zset

jedis.zadd("zset01", 100d, "z3");
jedis.zadd("zset01", 90d, "l4");
jedis.zadd("zset01", 80d, "w5");
jedis.zadd("zset01", 70d, "z6");
 
Set<String> zrange = jedis.zrange("zset01", 0, -1);
for (String e : zrange) {
	System.out.println(e);
}

参考链接:www.cnblogs.com/liuling/p/2…

5、Jedis实例--模拟一个手机验证码发送功能

image.png

要求:

  1. 输入手机号,点击发送后随机生成6位数字码,2分钟有效
  2. 输入验证码,点击验证,返回成功或失败
  3. 每个手机号每天只能输入3次

实现思路

在redis中设置两个字符串类型的数据:

  1. 手机号为键,验证码为值,发送完验证码要将验证码放入redis,并设置过期时间为2分钟。
  2. 手机号+当前年月日为键,次数为值。实现每日三次次数
  3. 验证时取出redis中的验证码与自己输入的验证码对比。

代码:

1、生成6位随机数验证码【字符串形式的验证码】

方法1:利用字符串相加操作

// 生成六位随机数验证码
public static String getCode(){
    Random random = new Random();
    String code = "";
    for (int i = 0; i < 6; i++) {
        int c = random.nextInt(10);
        code = code+c;
    }
    System.out.println(code);
    return  code;
}

方法2:利用StringBuilder【字符串形式的验证码】

public static String getCode(){
    Random random = new Random();
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 6; i++) {
        sb.append(random.nextInt(10));
    }
    String code = sb.toString();
    System.out.println("您的验证码为:"+code+"、有效时间为两分钟!!!");
    return  code;
}

2、发送验证码

手机号phone为键,验证码为值,并设置过期时间为2分钟。同时,要限制发送验证码的次数, 即以手机号+当前年月日为键,次数为值,实现每日三次次数

public static void sendCode(String phone,Jedis jedis){
    //定义发送次数的key
    String countKey = phone+new SimpleDateFormat("yyyy-MM-dd").format(new Date());
    Boolean exists = jedis.exists(countKey); //判断该key值是否在redis中存在
    if (!exists){  //不存在,说明没有发送,是第一次发送,则次数初始值设置为0
        jedis.set(countKey,"0");  //将发送次数的key及value放入redis
    }
    //key值存在
    //存在就从Redis中取出该key对应的值--次数count
    Integer count = Integer.parseInt(jedis.get(countKey));
    if (count >= 3){  //次数已经为3了或大于3,说明不能发送
        System.out.println("该号码每日发送验证码次数已达上限。");
        return;
    }
    //次数小于3,则count+1
    //count += 1;这样设置的话就要再次将发送次数的key及value放入redis,并且count要转为字符串
    jedis.incr(countKey); //既执行了+1,也放入了redis
    
    //最后验证码放入redis,用于和用户输入进行验证,并设置过期时间为120s,即2分钟
    String code = getCode();
    jedis.setex(phone,120,code);
}

3、验证验证码

public static void checkCode(String phone,Jedis jedis){
    Scanner scanner = new Scanner(System.in);
    // 获取验证码
    String code = jedis.get(phone); 
    // 验证输入的验证码是否正确
    if (code != null){
        if (code.equals(scanner.next().trim())){ //模拟2分钟之内不输入验证码,失效
            System.out.println("验证通过!");
        }else {
            System.out.println("验证码错误!");
        }
    }else { //得不到验证码,说明验证码失效。
        System.out.println("验证码已过期!!");
    }
}

4、测试

public static void main(String[] args) {
    Scanner scanner = new Scanner(System.in);
    Jedis jedis = new Jedis("192.168.62.130",6379);
    System.out.println(jedis.ping());
    sendCode("1234567",jedis);
    //如果中间睡两分钟,才调用该方法,就会失效
    System.out.println("请输入您的验证码:");
    checkCode("1234567",jedis);
    jedis.close();
}

结果

PONG
您的验证码为:280362、有效时间为两分钟!!!
请输入您的验证码:
280362
验证通过!

小知识点:

为什么这里在判断次数的时候要以手机号+当前年月日为键,次数为值

因为我们要判断次数是否大于3,我们的key值首先要是一个不能变的数,对于value来说,就是要判断它的次数,我们每测试一次,因为key是永远不变的,但是没有发送时,value=0,小于3时,就加1,一直到重复测试3次,Value值就大于3了,就实现了次数限制

6、通过Jedis操作Redis事务

这里利用fastjson在json数据和java对象之间进行操作

1、事务成功运行

package com.lemon;

import com.alibaba.fastjson.JSONObject;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

/**
 * @author lemon
 * @create 2022-02-02 17:29
 * TO:一把青梅换了酒钱
 */
public class TestTX {
    public static void main(String[] args) {

        //1.new Jedis对象
        Jedis jedis = new Jedis("192.168.62.130",6379);
        
        jedis.flushDB();//清空数据,防止上一次的缓存

        //JSON
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("hello","world");
        jsonObject.put("name","lemon");

        //开启事务
        Transaction multi = jedis.multi();

        //将java对象转换成json字符串对象
        String s = jsonObject.toJSONString();

        //命令入队以及命令执行
        try {
            multi.set("user1",s);
            multi.set("user2",s);

            //成功就执行事务
            multi.exec();
        } catch (Exception e) {
            //失败了就放弃事务
            multi.discard();
            e.printStackTrace();
        } finally {
            System.out.println(jedis.get("user1"));
            System.out.println(jedis.get("user2"));
            jedis.close();
        }
    }

}

结果:事务成功执行

{"name":"lemon","hello":"world"}
{"name":"lemon","hello":"world"}

2、运行时异常,事务失败

image.png

3、加上监控,解决事务问题

image.png

7、Redis事务--模拟用户秒杀案例实现

7.1、案列分析

以100个人去抢商品库存里面的商家推出用于秒杀的10个商品(同一种商品)为例,某个人秒杀到了该商品,则商品库存数减1,并且将该用户的账户id加入我们后台的秒杀清单上,用于后续的商品发放。

用Redis实现上述原理,将商品名称商品库存数key-value形式保存在Redis中,其次将秒杀成功的用户idkey-value的形式也保存在Redis中(后台清单)。如下图具体所示:

image.png

7.2、简单实现步骤

新建普通 java-web 工程演示【要导入jedis相关jar包在web-inf下】,这里只演示秒杀过程的实现,不写web页面的实现。

web页面的参考:

//秒杀过程
public static boolean SecKill_redis(String uid,String prodid) throws IOException {
    //uid和prodid非空判断,为空说明用户没有登录或者说商品库存为空,不能秒杀
    if(uid == null || prodid == null) {
        return false;
    }
    //2 连接redis
    Jedis jedis = new Jedis("192.168.62.130",6379);
    //3 拼接key
    String kcKey = "sk:"+prodid+":qt"; // 3.1 库存key
    String userKey = "sk:"+prodid+":user"; // 3.2 秒杀成功用户key
    //4 获取库存,如果库存null,秒杀还没有开始 kcKey:kc
    String kc = jedis.get(kcKey);
    if(kc == null) {
        System.out.println("秒杀还没有开始,请等待");
        jedis.close();
        return false;
    }
    // 5 判断用户是否重复秒杀操作,set去重特点
    if(jedis.sismember(userKey, uid)) {  //set中取数据,能取到uid说明已经秒杀过了
        System.out.println("已经秒杀成功了,不能重复秒杀");
        jedis.close();
        return false;
    }
    //6 判断如果商品库存数量小于1,秒杀结束
    if(Integer.parseInt(kc)<=0) {
        System.out.println("秒杀已经结束了");
        jedis.close();
        return false;
    }
    //7 大于等于1,可以进行秒杀,秒杀过程
    jedis.decr(kcKey);  //7.1 库存-1,并且还能放入redis
    jedis.sadd(userKey,uid); //7.2 把秒杀成功用户添加清单里面

    System.out.println("秒杀成功了..");
    jedis.close();
    return true;
}

7.3、测试:

往库存里面加入要抢的10个商品:

set sk:0101:qt 10  #终端进行设置

启动服务器进行测试:控制台打印结果和查询Redis剩余库存和用户清单结果如下,正常的

image.png

页面每点击一次秒杀按钮,就输出一次"秒杀成功了..",库存也相应的减1,用户id也随机生成一次加入了清单【点击一次,即该秒杀程序执行一次】,但是这里演示的是每一次就只有一个用户,即一个线程在参与秒杀。上述的例子只是大致实现一下秒杀的思路,实际上所有的秒杀功能都必须考虑并发调用下的可用性和数据一致性

7.4、考虑高并发秒杀案列实现

考虑多个用户购买商品的例子,在不加锁的情况下,秒杀结束时会出现负数库存和超出限定商品个数的秒杀成功者(商品已经秒光,但是还有库存)的情况,而且还需要考虑连接超时等问题…

7.4.1、使用Apache工具ab模拟高并发【或者直接使用jmeter】

  • CentOS6 默认安装
  • CentOS7需要手动安装,联网:yum install httpd-tools

测试

通过浏览器进行该工具的测试:基本语法:

ab -r -n 1000 -c 100 http://localhost:8099/api/testRedis
#其中-n表示请求数,-c表示并发数,表示1000个请求中有100个是并发操作
  1. vim postfile 输入内容:prodid=0101&,模拟表单提交参数,以&符号结尾,存放当前目录。
  2. 然后执行命令ab -n 1000 -c 100 -p ~/postfile -T application/x-www-form-urlencoded http://192.168.62.130:8081/Seckill/doseckill 开始模拟高并发请求次数
  3. 这里模拟1000个请求中有100个是并发操作
  4. 后面地址属于页面请求接口的地址
  5. 利用工具进行执行之前先flushdb清空redis的数据,并重新设置库存数为10

控制台打印结果和查询Redis剩余库存和用户清单结果如下:

image.png

发现库存 -5,并且在秒杀结束又成功了,所以出现超卖了,所以我们的代码在高并发的情况下会出现超卖的问题,那么针对这个问题我们需要使用乐观锁来解决,并且通过ab工具模拟高并发请求,如果将并发请求次数扩大至300,则还有可能出现商品已经秒光,但是还有库存或者Redis连接超时问题

7.4.2、问题总结:

通过ab工具或者jemter模拟高并发请求测试,出现的问题有:

  • 超卖(问题已经演示)
  • 商品已经秒光,但是还有库存(参考下面)
  • 连接超时

7.5、连接池解决连接超时问题

问题:

image.png

image.png 用户每请求一次秒杀,都会创建一个Jedis对象并将请求连接打到redis服务器上,但是后到的请求需要排队等待被处理,如果长时间未处理时,则本次连接超时,用户的秒杀请求失败,并且多次创建Jedis对象是一种浪费,因此就需要节省每次连接redis服务带来的消耗,把连接好的Redis实例反复利用。 对于连接超时问题,可以采用连接池来解决,其功能类似数据库连接池通过参数管理连接的行为

链接池参数

  • MaxTotal:控制一个pool可分配多少个jedis实例,通过pool.getResource()来获取;如果赋值为-1,则表示不限制;如果pool已经分配了MaxTotal个jedis实例,则此时pool的状态为exhausted。
  • maxIdle:控制一个pool最多有多少个状态为idle(空闲)的jedis实例;
  • MaxWaitMillis:表示当borrow一个jedis实例时,最大的等待毫秒数,如果超过等待时间,则直接抛JedisConnectionException;
  • testOnBorrow:获得一个jedis实例的时候是否检查连接可用性(ping());如果为true,则得到的jedis实例均是可用的;

代码实现

工具类:JedisPoolUtil

package com.xiaoqiu;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class JedisPoolUtil {
	private static volatile JedisPool jedisPool = null;

	private JedisPoolUtil() {
	}

    //从连接池获取连接
	public static JedisPool getJedisPoolInstance() {
		if (null == jedisPool) {
			synchronized (JedisPoolUtil.class) {
				if (null == jedisPool) {
					JedisPoolConfig poolConfig = new JedisPoolConfig();
					poolConfig.setMaxTotal(200);
					poolConfig.setMaxIdle(32);
					poolConfig.setMaxWaitMillis(100*1000);
					poolConfig.setBlockWhenExhausted(true);
					poolConfig.setTestOnBorrow(true);  // ping  PONG
				 
					jedisPool = new JedisPool(poolConfig, "192.168.174.132", 6379, 60000 );
				}
			}
		}
		return jedisPool;
	}
	
    //释放连接
	public static void close(JedisPool jedisPool, Jedis jedis) {
		if (null != jedis) {
			jedisPool.returnResource(jedis);
		}
	}

}

有了连接池,就可以在代码中使用以替代直接连接的方式

//  直接连接redis
Jedis jedis = new Jedis("127.0.0.1", 6379);

// 使用连接池连接redis
JedisPoolUtil jedisPool = JedisPool.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();

7.6、使用乐观锁解决高并发下的超卖问题

出现超卖问题的原因就是没有加事务导致多个请求互相影响,导致库存为负数,利用乐观锁watch版本机制淘汰用户,解决超卖问题。

image.png

代码实现

采用乐观锁watch监控住库存的value,并将秒杀过程放入multi队列处理

package com.atguigu;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Transaction;

import java.io.IOException;
import java.util.List;


public class SecKill_redis {

    //秒杀过程
    public static boolean doSecKill(String uid, String prodid) throws IOException {
        //1 uid和prodid非空判断
        if (uid == null || prodid == null) {
            return false;
        }

        //2 使用连接池获取连接redis
       JedisPoolUtil jedisPool = JedisPool.getJedisPoolInstance();
		Jedis jedis = jedisPoolInstance.getResource();
       

        //3 拼接key
        // 3.1 库存key
        String kcKey = "sk:" + prodid + ":qt";
        // 3.2 秒杀成功用户key
        String userKey = "sk:" + prodid + ":user";

        //监视库存
        jedis.watch(kcKey);

        //4 获取库存,如果库存null,秒杀还没有开始
        String kc = jedis.get(kcKey);
        if (kc == null) {
            System.out.println("秒杀还没有开始,请等待");
            jedis.close();
            return false;
        }

        // 5 判断用户是否重复秒杀操作
        if (jedis.sismember(userKey, uid)) {
            System.out.println("已经秒杀成功了,不能重复秒杀");
            jedis.close();
            return false;
        }

        //6 判断如果商品数量,库存数量小于1,秒杀结束
        if (Integer.parseInt(kc) <= 0) {
            System.out.println("秒杀已经结束了");
            jedis.close();
            return false;
        }

        //7 秒杀过程
        //使用事务
        Transaction multi = jedis.multi();

        //组队操作
        multi.decr(kcKey);
        multi.sadd(userKey,uid);

        //执行
        List<Object> results = multi.exec();

		if(results == null || results.size()==0) {
			System.out.println("秒杀失败了....");
			jedis.close();
			return false;
		}
        System.out.println("秒杀成功了..");
        JedisPoolUtil.close();
        return true;
    }
}

7.7、使用LUA脚本解决库存遗留问题

7.7.1、问题分析

在上面的代码的基础上,增加商品库存到500个,并还是模拟300个并发请求,会出现什么情况呢?

  • 执行命令:ab -n 2000 -c 300 -p ~/postfile -T application/x-www-form-urlencoded http://172.56.119.21:8080/Seckill/doseckill模拟300个并发请求
  • 通过查看打印和库存结果发现库存已经秒光,可是还有库存

秒杀还可能出现这样的问题,库存设置为500,当整个秒杀快结束时,后到的用户发出请求时发现失败(版本不一样),但此时的库存却还未到0,这就是库存遗留问题,以为卖完了,其实没卖完,出现这样的状况是由于乐观锁的版本机制导致的

开始时使用乐观锁 watch 了库存数值时,此时的库存数据版本是1.0,当秒杀快结束时,有10个人读取到了当前库存值10,版本5.0,假设第一个人的秒杀请求先处理,库存变为9,版本号变为5.1,其他9个人发秒杀请求想改库存数据时,却发现版本号对不上,无法修改库存数,此时秒杀时间结束,就出现了库存仍有遗留的问题。这样的问题很容易想到死锁、悲观锁解决,但redis中并不支持死锁、悲观锁

对此的解决方案可以采用Lua脚本,实质上是Redis利用其单线程的特性,用任务队列的方式解决多任务并发问题

7.7.2、LUA脚本

简介

image.png

  • Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。
  • 很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。
  • 这其中包括魔兽争霸地图、魔兽世界、博德之门、愤怒的小鸟等众多游戏插件或外挂。
  • www.w3cschool.cn/lua/

LUA脚本在Redis中的优势

  • 将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。提升性能。

  • LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。

  • 但是注意redis的lua脚本功能,只有在Redis 2.6以上的版本才可以使用。

  • 利用lua脚本淘汰用户,解决超卖问题。

  • redis 2.6版本以后,通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。

image.png

7.7.3、代码实现

将库存-1和将id加入成功者队列的操作使用Lua脚本一次性提交给Redis执行,Lua脚本类似redis事务,有一定原子性,不会被其他命令插队

lua脚本:

local userId=KEYS[1];
local stockKey=KEYS[2];
local userIdKey=KEYS[3];
local userExists=redis.call("sismember",userIdKey,userId); 
if tonumber(userExists)==1
    then
    return 2;
end
local num= redis.call("get" ,stockKey);
if tonumber(num)<=0 then   return 0;
else
    redis.call("decr",stockKey);
    redis.call("sadd",userIdKey,userId);
end
return 1;

使用Lua脚本解决库存遗留的问题,代码如下:

/**
     * 秒杀过程3(LUA解决库存剩余问题)
     *
     * @param usrId 用户id
     * @param atcId 活动id
     * @return
     * @throws IOException
     */
private boolean doSecKill(String usrId, String atcId) throws IOException {

    String luaScript = "local userId=KEYS[1];\r\n" +
        "local stockKey=KEYS[2];\r\n" +
        "local userIdKey=KEYS[3];\r\n" +
        "local userExists=redis.call(\"sismember\",userIdKey,userId); \r\n" +
        "if tonumber(userExists)==1 \r\n" +
        "then \r\n" +
        "  return 2;\r\n" +
        "end \r\n" +
        "local num= redis.call(\"get\" ,stockKey);\r\n" +
        "if tonumber(num)<=0 then   return 0;\r\n" +
        "else \r\n " +
        " redis.call(\"decr\",stockKey);\r\n" +
        "redis.call(\"sadd\",userIdKey,userId);\r\n" +
        "end \r\n" +
        "return 1;";

    // 指定 lua 脚本,并且指定返回值类型
    // (为什么返回值不用 Integer 接收而是用 Long。这里是因为 spring-boot-starter-data-redis 提供的返回类型里面不支持 Integer。)
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
    List<String> keys = new ArrayList<>();
    keys.add(usrId);
    keys.add(atcId + ":stock");
    keys.add(atcId + ":userId");
    // 参数一:redisScript,参数二:key列表,参数三:arg(可多个)
    Long result = (Long) redisTemplate.execute(redisScript, keys);
    if (0 == result) {
        System.out.println("秒杀结束了。。。");
    } else if (1 == result) {
        System.out.println("恭喜你!秒杀成功了!");
        return true;
    } else if (2 == result) {
        System.out.println("你已经秒杀成功了,不能重复秒杀");
    } else {
        System.out.println("秒杀异常啦~");
    }

    return false;
}

7.8、总结和补充

第一版:单个请求下正常,使用工具ab模拟并发测试,会出现超卖情况。查看库存会出现负数。

第二版:加事务-乐观锁(解决超卖),但出现遗留库存和连接超时

第三版:连接池解决超时问题

第四版:解决库存依赖问题,LUA脚本

其实也可以使用jmeter工具来进行高并发的测试