什么是Lua脚本?
Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
在Redis2.6之前,如果用户想添加一些Redis不具备的功能,则需要通过编写客户端代码或者修改Redis的C源代码来实现自己想要的功能,前后者的解决方案都存在一些弊端,于是Redis2.6版本引入了eval命令,使得可以在Redis服务器内部执行Lua代码。
Lua脚本跟单个Redis命令以及MULTI/EXEC事务一样,都是原子操作。在某个Lua脚本的执行过程中不会受到其它命令的干扰,Redis将EVAL和EVALSHA看成是单个命令进行处理。
Lua脚本可以保证原子性吗?
Lua脚本只是作为原子操作执行,但它并不能保证ACID中的原子性,当redis.call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,错误的输出信息会说明错误造成的原因。和redis.call() 不同, redis.pcall() 出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误。
如果在SpringBoot环境下使用StringRedisTemplate执行lua脚本,如果某个redis.call()执行出错,那么脚本会停止执行并返回错误信息,脚本中剩余的语句不会执行,而且已经执行的操作不会回滚。redis.pcall()在遇到错误时不会停止脚本的执行,而是返回一个包含错误信息的表。这意味着你可以在脚本中处理错误,而不是立即停止脚本的执行。就算不处理这个错误,脚本中剩余的语句也会执行完。
// 演示lua脚本中redis.pcall()出错的情况
// 在SpringBoot环境下运行下面的代码后,控制台无任何报错
// 而且redis.call('SET','123451','543211'),redis.call('SET','TEST','TEST')都执行成功
public static void testRedis(StringRedisTemplate stringRedisTemplate){
String script = "redis.call('SET','123451','543211')" +
"redis.pcall('INCRBY','123451','3.14159')" +
"redis.call('SET','TEST','TEST')";
DefaultRedisScript<String> defaultRedisScript = new DefaultRedisScript<>();
defaultRedisScript.setScriptText(script);
List<String> keys = new ArrayList<>();
String result = stringRedisTemplate.execute(defaultRedisScript, keys);
}
// 演示lua脚本中redis.call()出错的情况
// 在SpringBoot环境下运行下面代码
// 控制台报错RedisCommandExecutionException: ERR Error running script
// redis在执行到redis.call('INCRBY','123451','3.14159')时报错,脚本立即结束并返回
// 但是redis.call('SET','123451','543211')依然成功执行,且没有回滚
public static void testRedis(StringRedisTemplate stringRedisTemplate){
String script = "redis.call('SET','123451','543211')" +
"redis.call('INCRBY','123451','3.14159')" +
"redis.call('SET','TEST','TEST')";
DefaultRedisScript<String> defaultRedisScript = new DefaultRedisScript<>();
defaultRedisScript.setScriptText(script);
List<String> keys = new ArrayList<>();
String result = stringRedisTemplate.execute(defaultRedisScript, keys);
}
使用Lua脚本解决超卖问题
超卖问题来源于查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况。
例如:当商品A一共有库存15件,用户甲先下单10件,用户乙下单8件,这时候库存只能满足一个人下单成功,如果两个人同时提交,就出现了超卖的问题。
由于Lua脚本具有原子操作的特性,可以利用这一点来解决超卖问题。
private static String script = null;
public static void loadScript(){
StringBuilder scriptBuilder = new StringBuilder();
// 如果给定的商品不存在,则返回-1表示失败
scriptBuilder.append("if redis.call('EXISTS', KEYS[1]) == 0 then ");
scriptBuilder.append("return -1;");
scriptBuilder.append("end;");
scriptBuilder.append("local stock = tonumber(redis.call('GET', KEYS[1]));");
// 如果库存量为-1表示无限量供应,返回0表示成功
scriptBuilder.append("if stock == -1 then ");
scriptBuilder.append("return 0;");
scriptBuilder.append("end;");
// 如果商品库存量不足,则返回-1表示失败
scriptBuilder.append("if stock < tonumber(ARGV[1]) then ");
scriptBuilder.append("return -1;");
scriptBuilder.append("end;");
// 扣减库存,返回原库存量表示成功
scriptBuilder.append("redis.call('DECRBY', KEYS[1], tonumber(ARGV[1]));");
scriptBuilder.append("return stock;");
script = scriptBuilder.toString();
}
/**
* 使用lua脚本扣减库存
* @param stuff 商品名称
* @param amount 想要扣减多少库存
* @return 是否扣减成功
*/
public static boolean decrementStockByLua(String stuff,Integer amount){
if (script == null){
loadScript();
}
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
// 注意,这里必须显式地设置返回值类型,不然lettuce会报错。
defaultRedisScript.setResultType(Long.class);
defaultRedisScript.setScriptText(script);
List<String> keys = new ArrayList<>();
keys.add(stuff);
Long result = stringRedisTemplate.execute(defaultRedisScript, keys, String.valueOf(amount));
return result > 0;
}