前言:
关于Lua脚本介绍:Lua脚本的作用是可以执行一系列Redis命令,将它们封装在一个原子操作中,确保这些命令的执行是连续的且不会被其他客户端的操作中断。这样可以保证一系列操作的原子性,避免出现数据不一致的情况。
场景介绍:当我们下单操作后,需要对Redis中的库存信息进行删减,虽然Redis是线程安全的,但在JAVA中下单操作是分为两个步骤,首先是先要判断存入Redis中的库存是否充足,充足后再进行扣减,此处查询库存和扣减库存是两步操作,所以在这两步操作之间,就有可能在并发情况下导致线程安全问题而导致“超卖等现象”。
以下图为例,假设此时商品只剩最后一个,这时有两个并发线程进来,首先进行Redis查询后判断库存,这时候线程切换到线程二,线程二也执行查询操作(此时线程一还没有扣减数据),判断后扣减,然后回到线程一,扣减数据。这时候就发生了“超卖”问题。
Lua脚本解决方案
在上述环境中会发生超卖问题是因为查询和写数据分成了两步操作,因为线程轮巡而导致数据不一致问题,而Lua脚本则可以将查询Redis操作和扣减数据库操作的命令合并成一个原子操作,这样就不需要在JAVA中再进行处理而导致线程安全问题了。
Redis+Lua脚本
Redis中后面版本自带了Lua脚本的解释器,所以无需再进行下载。
进入Redis容器使用SCRIPT EXISTS查看是否支持Lua脚本(返回一个空数组)
Lua脚本基础语法
Lua中定义变量语法是local 变量名
local userid=0
判断语法
💡注意事项:
在Lua脚本中数组下标从[1]开始,即相当于JAVA的[0], 字符串拼接是使用 .. str1 .. str2
秒杀业务Lua脚本示例:
local resultFlag = "0"
local n = tonumber(ARGV[1])
local key = KEYS[1]
local goodsInfo = redis.call("HMGET",key,"totalCount","seckillCount")
local total = tonumber(goodsInfo[1])
local alloc = tonumber(goodsInfo[2])
if not total then
return resultFlag
end
if total >= alloc + n then
local ret = redis.call("HINCRBY",key,"seckillCount",n)
return tostring(ret)
end
return resultFlag
首先Lua脚本调用Redis命令使用redis.call()进行调用,方法中第一个参数为redis的命令,如set、get等,上面示例的HMGET和HINCRBY表示是对哈希数据结构的命令,
HMGET命令用于获取哈希(Hash)数据结构中一个或多个字段的值。语法为:HMGET key field1 (field2 ... fieldN)
对应上述示例: redis.call("HMGET",key,"totalCount","seckillCount")
HINCRBY命令用于将哈希数据结构中指定字段的值增加指定的增量。语法为:HINCRBY key field increment
对应上述示例: redis.call("HINCRBY",key,"seckillCount",n)
KEYS[1] 和ARGV[1]表示传入参数的第一个key和对应的value值
在Redis中执行语法:EVAL script numkeys key [key …] arg [arg …],而我们是通过JAVA语句调用,需要注意的就是传入的key和对应的arg(value)。就相当于一个K-V结构,传入一个Key就会对应一个Value,传两个Key,就会对应两个Value。
RedisTemplate操作Lua脚本
Java代码示例:
public int secKill(String id, int number) {
String key = getCacheKey(id);
Object seckillCount = redisTemplate.execute(script, Arrays.asList(key), String.valueOf(number));
return Integer.valueOf(seckillCount.toString());
}
可以使用redisTemplate.execute()方法来执行Lua脚本,其中第一个参数为script即lua脚本,第二个参数根据脚本中的KEYS[]和ARGV[]来定,前提是需要将key转为List结构,ARGV需要转为String类型。
编写好Lua脚本后用java代码拼接(也可以直接创建Lua脚本文件)
先要创建一个Script对象,然后拼接好的Lua脚本转为字符串传入第一个参数,第二个参数是指返回结果类型。
RedisScript<String> redisScript=new DefaultRedisScript<>(sb.toString(),String.class);
然后执行 redisTemplate.execute(redisScript, Arrays.asList(key),num);
@Test
public void testRedisLua(){
StringBuilder sb=new StringBuilder();
//传入的参数应该是商品的哈希key
//哈希结构key-商品id
//totalCount 100 总库存
//seckillCount 0 已经售出数量
sb.append("local resultFlag=\"0\"\n");
//传入的参数key --对应商品id key
sb.append("local key=KEYS[1]\n");
//传入的参数arg --对应订购的数量
sb.append("local addCap=tonumber(ARGV[1])\n");
//先将key进行查询判断库存是否充裕
sb.append("local phoneInfo=redis.call(\"HMGET\",key,\"totalCount\",\"seckillCount\")\n");
sb.append("local total=tonumber(phoneInfo[1])\n");//得到totalCount 总库存数
sb.append("local alsell=tonumber(phoneInfo[2])\n");//得到seckillCount 已经售出的数量
//如果没有值则返回 “0”
sb.append("if not total then\n");
sb.append("return resultFlag\n");
sb.append("end\n");
//如果库存数量大于已售出数量加需要订购的数量(代表还有货)则将已售出数量增加订购数量并返回
sb.append("if total >=alsell + addCap then\n");
//将售出数量进行增加(订购数量)
sb.append("local ret=redis.call(\"HINCRBY\",key,\"seckillCount\",addCap)\n");
sb.append("return tostring(ret)\n");
sb.append("end\n");
//如果没货返回 "0"
sb.append("return resultFlag\n");
RedisScript<String> redisScript=new DefaultRedisScript<>(sb.toString(),String.class);
redisTemplate.execute(redisScript, Arrays.asList(key),num);
}
采用Lua文件进行执行(推荐)
在resources目录下创建好lua脚本文件
private static final DefaultRedisScript<String> SECKILL_KEY;
static {
SECKILL_KEY=new DefaultRedisScript<>();
SECKILL_KEY.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_KEY.setResultType(String.class);//返回结果类型
}
可以直接通过文件读取,而不是使用StringBuffer进行字符串拼接。
到此整个流程的基本内容已经介绍完毕,大家可以根据自己项目不同的业务去进行实现或者测试。此方案适用于单节点的Redis,在读写分离以及集群状况下需要进一步设计。