Springboot 中的Redis 事务使用

简述

有一些场景我们可以在一段代码中多次操作redis,每次请求Redis都要去Jedis/Lettuce连接池申请一个连接请求一次redis服务进行缓存操作。
Springboot 中的Redis 事务使用_第1张图片
这样不仅有网络的消耗,假如在redis连接数吃紧的情况下多次请求redis很有可能回造成redis获取连接超时。懂得redis的兄弟这时候会说可以使用pipeline解决啊!确实我们使用pipeline批量命令去请求redis会解决上述问题,但还有一种场景就是我上一个redis请求成功了,但下一个请求失败了,这个时候上一个请求我需要回滚怎么办?
Springboot 中的Redis 事务使用_第2张图片
那这个时候我们就需要使用lua脚本做事务请求。在lua脚本中如果说有一个命令失败,整个脚本都会执行失败。也就是像我们数据库(ACID)一样可以支持事务的执行,让整段redis的命令具有原子性的。

当然我们还可以使用TCC,对每一步redis的命令操作进行try catch。如果出现异常就再次执行命令将之前修改的缓存状态修改回来。

Lua脚本使用

Java 代码

public int executeLuaScript () {
    // Redis脚本对象
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
    //设置返回值类型
    redisScript.setResultType(Long.class); // 设置返回类型
    // 这里处理传resource也可以传文本,一般情况下建议传一个constant string进来,不需要每次走IO读取文件,也可以将resource缓存q起来
    // redisScript.setScriptText("xxxx");
    redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("RedisTest.lua")));

    // 设置lua脚本文件路径
    List<Object> keys = new ArrayList<>(); // lua脚本中所有的KEY
    keys.add("TEST-LUA1");

    // 缓存类型根据具体情况而定
    redisTemplate.opsForValue().set("TEST-LUA1", 0);
    Long executeObj = redisTemplate.opsForValue()
            .getOperations()
            .execute(redisScript, keys, 60, "1");
    System.out.println(executeObj);

    assert executeObj != null;
    return executeObj.intValue();
}

lua脚本 RedisTest.lua

local num = redis.call('incr', KEYS[1])
if tonumber(num) == 1 then
    redis.call('expire', KEYS[1], ARGV[1])
    return 1
elseif tonumber(num) > tonumber(ARGV[2]) then
    return 0
else
    return 1
end
  • lua脚本中keys[1]、keys[2]、keys[3]对应keys中的元素keys.get(0) / keys.get(2)…
  • lua 脚本中使用redis.call 调用redis命令, 第一个参数是redis命令名称,第二个参数是键,第三个参数(可选参数)是值。
  • tonumber 是lua 函数,我们可以使用lua语法和库来控制脚本逻辑和计算。

简单的lua语法介绍

// 定义变量
local strings val = "world"

// 打印
print(val)

// 定义数组
local tables myArray = {"redis", "jedis", true, 88.0}
print(myArray[3])

// for
local int sum = 0
for i = 1, 100
do
  sum = sum + i
end
print(sum) -- 5050

for index, value in ipairs(myArray)
do
  print(index)
  print(value)
end

// while
local int sum = 0
local int i = 0
while i <= 100
do
  sum = sum + i
  i = i + 1
end
print(sum) -- 5050

// if else
if myArray[i] == "jedis" then
  print(true)
else 
  -- do nothing
end

// 定义函数
funtion funcName ()
  ...
end

更多lua语法请访问 http://www.lua.org 进行学习

应用场景介绍

有这样一个场景,一个场活动只能有100个人报名,超过100个人就不能报名了,后面报名的人直接返回失败。有3台服务,nginx轮询负载到这三台服务。对于这个场景我们有2种通用的解决方案:

① Redis限流

利用redis进行限流操作,每次进入代码块先incr一次报名人数缓存,然后判断报名的人数是否超过了100次,如果超过了就直接返回报名人数已满。如果没有超过100次则进行相应的业务处理。这样做事非阻塞的,而且不会出现超出报名次数的问题。

存在的问题:

  • 如果是集群的话,多个命令可能会落到多个节点上,这个时候lua脚本就不能保证是原子性的。这个时候可以利用hashtag来让所有的报名用到的缓存落到同一台服务上即可。

  • 但这样是强依赖redis的,如果说redis挂掉了(单节点),报名服务就不可用了。如果是redis集群部署的话,从节点选举为主节点的时候丢失了数据,本来报名人数是100,现在从节点只同步到99。当然这种情况的发生概率可以忽略不计了。

@Test
public void testSetCacheCount () throws InterruptedException {

    long start = System.currentTimeMillis();

    ExecutorService executorService = Executors.newFixedThreadPool(50);

    redisTemplate.delete(COUNT_KEY);

    Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(COUNT_KEY, 0);
    if (!aBoolean) {
        throw new RuntimeException("Redis 不可用...");
    } else {
        CountDownLatch countDownLatch = new CountDownLatch(10000);
        for (int i = 0; i < 10000; i++) {
            executorService.execute(() -> {
                try {
                    Long increment = redisTemplate.opsForValue().increment(COUNT_KEY);
                    if (increment <= LIMIT_COUNT) {
                        // lua 脚本操作(伪代码)
                        atomicLong.incrementAndGet();
                    }
                    countDownLatch.countDown();
                } catch (Exception e) {
                    // 这里请求失败大多都是redis获取不到连接超时了
                    System.out.println("错误发生了:" + e.getMessage());
                }
            });
        }
        countDownLatch.await();
    }

    int currentCount = Integer.parseInt(String.valueOf(redisTemplate.opsForValue().get(COUNT_KEY)));
    System.out.println(currentCount);
    System.out.println("执行了业务代码:" + atomicLong + "次");

    long end = System.currentTimeMillis();

    System.out.println("Cost time is:" + (start - end));

    executorService.shutdown();

    // 不加锁 357
    // 加锁 747
}

public static void inc (RedisTemplate redisTemplate, int LIMIT_COUNT, AtomicLong atomicLong, String COUNT_KEY) {
    Long increment = redisTemplate.opsForValue().increment(COUNT_KEY);
    if (increment <= LIMIT_COUNT) {
        // 这段可能要修改多个缓存
        atomicLong.incrementAndGet(); // 代码块 
    }
}

模拟10000 个请求,50个线程进行处理,大概357秒左右。不管我请求数有多少、失败的请求有多少(这里请求失败主要是因为获取不到连接,失败额的话直接熔断)最终请求进入到atomicLong.incrementAndGet() 只会有100个人。如果我们人数增加了之后执行下面代码失败了,但这个时候报名限制的人数COUNT_KEY已经增加上去了。这个时候我们就可以使用lua脚本来处理整段redis操作让其变成事务处理。

RedisLimit.lua

local num = redis.call('incr', KEYS[1])
if tonumber(num) == 1 then
    redis.call('expire', KEYS[1], ARGV[1])
    return 1
elseif tonumber(num) > tonumber(ARGV[2]) then
    return 0
else
    return 1
end

业务代码

Long increment = redisTemplate.opsForValue().increment(COUNT_KEY);
if (increment <= LIMIT_COUNT) {
    // lua 脚本操作(伪代码)
    atomicLong.incrementAndGet();
}

// 上面代码改为
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
//设置返回值类型
redisScript.setResultType(Long.class);
// redis
// redisScript.setScriptText("xxxx");
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("RedisLimit.lua")));

//设置lua脚本文件路径
List<Object> keys = new ArrayList<>();
keys.add(COUNT_KEY);
keys.add(LIMIT_KEY);

Long execute = redisTemplate.opsForValue()
        .getOperations()
        .execute(redisScript, keys, "60", "1");

if (execute == 1) {
    // 成功
} else {
    // 失败
}

② 分布式锁

用分布式锁解决的话也就是要每次同一时间只能有一个请求进入到报名处理的代码中对报名人数进行加减。这样虽然没有什么问题但性能势必会很低。对于每个服务来说扣除库存都是阻塞的,而且获取分布式锁之后可能伴随着大量的业务处理逻辑进一步阻塞减慢集群的处理效率,这种场景不推荐使用此种方式。

Springboot 中的Redis 事务使用_第3张图片

你可能感兴趣的:(redis,redis,spring,boot,lua)