一、redis事务
redis事务可以一连着执行多个命令,每个命令都跟redis server通信一次,然后这些命令先进入到server端的事务队列里QUEUED,直到最后收到exec命令后,一起执行。一起执行的时候不会被其他命令插入。如果这时候如果命令集合中有命令报错则不会回滚已经执行掉了的命令、且后面的命令也会执行。但是可以通过watch机制在事务之前指定事务执行的条件,一旦watch的key发生了改变则exec返回执行失败。
我们姑且把redis事务称为“弱原子性”,因为其没有类似关系型数据库的回滚机制。
1、redis事务与lua脚本、pipeline区别
pipeline目的是一次网络通信执行一组命令,但是不具备redis事务的弱原子性。
将一组命令用lua脚本组合成一个“命令”发给redis server,执行的时候不会被其他命令插入,具备与事务相同的弱原子性。除此之外,相比redis事务通信只一次,且可以多个命令之间的业务逻辑判定等操作。
2、redis事务的相关命令
multi , exec, discard的用法
127.0.0.1:7001> multi
OK
127.0.0.1:7001> set me-account 32000
QUEUED
127.0.0.1:7001> get me-account
QUEUED
127.0.0.1:7001> exec
1) OK
2) "32000"
127.0.0.1:7001> multi
OK
127.0.0.1:7001> set me-account 32
QUEUED
127.0.0.1:7001> discard
OK
127.0.0.1:7001>
127.0.0.1:7001>
127.0.0.1:7001> get me-account
"32000"
watch key 有条件执行exec的用法:
127.0.0.1:7001> watch me-account
OK
127.0.0.1:7001> multi
OK
127.0.0.1:7001> set me-account 50000
QUEUED
127.0.0.1:7001> exec
(nil)
在上面最后执行exec之前,在另一个redis-cli窗口执行set me-account 45000
回过来执行exec,发现返回的不是ok是nil,进一步get值也是45000而不是50000,说明事务没有执行。
二、Redis script解决复杂的业务逻辑的情况
上面介绍了redis事务,其原子性的执行一组命令,但是每条命令实际上是不是马上执行的:先发到server端队列里攒着一起exec,这样的话如果命令之间有依赖就没法搞定了。
考虑如下场景:一个key如果大于0,则减去1,否则返回失败。
这个用redis事务难以实现。就算是使用watch的方式:先watch key, 然后get key,判定value>0 ,再set key执行exec。这个过程中如果key没有被修改则最后会执行成功,如果key被修改了则exec失败,就算允许自旋重试个几次,在并发竞争大的时候失败率也是很高的,虽然逻辑上不会出什么错误,但这显然不是一个理想的方案。
这时候就要祭出大杀器lua脚本了。redis能够单线程串行且不允许插入别的命令的方式执行一个lua脚本,这为解决上面的问题提供了可行的方案。"This is an ideal use case for a Redis script, as it requires that running a set of commands atomically, and the behavior of one command is influenced by the result of another."
先确认一下redis版本,因为redis2.6+才支持Lua脚本的。
[root@VM_0_11_centos ~]# redis-server -v
Redis server v=6.0.3 sha=00000000:0 malloc=jemalloc-5.1.0 bits=64 build=35d5f849c3480964
下面介绍如何使用Java开发,这里使用spring data redis来调用redis lua script,业务场景还是扣库存:先查库存,如果大于0则库存减1然后返回成功,如果库存等于0了则返回失败。
官方文档:https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#scripting
1、编写和调试redis lua脚本
--[[
扣减redis库存lua script
KEYS[1] 库存key名称,例如my-stock
ARGV[1] 参数,json字符串,属性buyNum表示一次扣多少库存
]]
local stock_key = KEYS[1]
local args = ARGV[1]
redis.log(redis.LOG_NOTICE, stock_key)
redis.log(redis.LOG_NOTICE, args)
local args_json = cjson.decode(args)
local buy_num = args_json.buyNum
local current_stock = redis.call("get", stock_key)
if tonumber(current_stock) > 0 then
redis.call("set", stock_key, tonumber(current_stock) - buy_num)
return true
end
return false
然后编写完lua脚本以后,给java调之前先去redis上调试一下,3.2版本之后redis官方提供了一个工具,Redis Lua scripts debugger,简称ldb: https://redis.io/topics/ldb
Starting with version 3.2 Redis includes a complete Lua debugger, that can be used in order to make the task of writing complex Redis scripts much simpler.
下面是调试的详细过程:
[root@VM_0_11_centos redis-script]# redis-cli -p 7001 -a me@210 --ldb --eval /usr/redis-script/deduct_stock.lua my-stock , {\"buyNum\":5}
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
Lua debugging session started, please use:
quit -- End the session.
restart -- Restart the script in debug mode again.
help -- Show Lua script debugging commands.
* Stopped at 1, stop reason = step over
-> 1 local stock_key = KEYS[1]
lua debugger> s
* Stopped at 2, stop reason = step over
-> 2 local args = ARGV[1]
lua debugger> s
* Stopped at 3, stop reason = step over
-> 3 local args_json = cjson.decode(ARGV[1])
lua debugger> s
* Stopped at 5, stop reason = step over
-> 5 local buy_num = args_json.buyNum
lua debugger> p
stock_key = "my-stock"
args = "{\"buyNum\":5}"
args_json = {["buyNum"]=5}
lua debugger> s
* Stopped at 7, stop reason = step over
-> 7 local current_stock = redis.call("get", stock_key)
lua debugger> p
stock_key = "my-stock"
args = "{\"buyNum\":5}"
args_json = {["buyNum"]=5}
buy_num = 5
lua debugger> s
get my-stock
"3000"
* Stopped at 9, stop reason = step over
-> 9 if tonumber(current_stock) > 0 then
lua debugger> p
stock_key = "my-stock"
args = "{\"buyNum\":5}"
args_json = {["buyNum"]=5}
buy_num = 5
current_stock = "3000"
lua debugger> s
* Stopped at 10, stop reason = step over
-> 10 redis.call("set", stock_key, tonumber(current_stock) - buy_num)
lua debugger> p
stock_key = "my-stock"
args = "{\"buyNum\":5}"
args_json = {["buyNum"]=5}
buy_num = 5
current_stock = "3000"
lua debugger> s
set my-stock 2995
"+OK"
* Stopped at 11, stop reason = step over
-> 11 return true
lua debugger> s
(integer) 1
(Lua debugging session ended -- dataset changes rolled back)
用起来还算简便,print可以看到当前的local变量,step是单步执行。然后get my-stock一看发现还是3000,这是因为ldb不影响实际数据(最后也是可以看到rolled back)。至此可以相信我们的Lua脚本是正确的了。
准备工作都搞好了,看一下完整的java代码。
2、使用spring data redis调用redis script lua脚本
(1)、配置RedisTemplate,写个redis小工具类RedisDao
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
RedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
return redisTemplate;
}
}
RedisDao
@Slf4j
@Service
public class RedisDao {
@Autowired
RedisTemplate redisTemplate;
public String getStringValue(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
public void setStringValue(String key, String value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
public void setStringValue(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
public T executeScript(DefaultRedisScript script, List keys, Map args) {
log.info("执行redis lua脚本");
log.info("脚本输入keys:" + JSON.toJSONString(keys));
log.info("脚本输入参数args:" + JSON.toJSONString(args));
return redisTemplate.execute(script, keys, JSON.toJSONString(args));
}
}
(2)、配置redis script,其中lua脚本返回值是要与这里的Boolean对应的。
The script resultType
should be one of Long
, Boolean
, List
, or a deserialized value type. It can also be null
if the script returns a throw-away status (specifically, OK
).
@Configuration
public class RedisScriptConfig {
@Bean
public DefaultRedisScript deductMyStock() {
DefaultRedisScript script = new DefaultRedisScript<>();
script.setResultType(Boolean.class);
script.setScriptSource( new ResourceScriptSource(new ClassPathResource("redis-script/deduct_stock.lua")) );
return script;
}
}
另外,官网上有个说明:It is ideal to configure a single instance of DefaultRedisScript in your application context to avoid re-calculation of the script’s SHA1 on every script run.
redis上有script缓存,如果客户端发送的脚本文件名+SHA1校验和命中缓存,那么就说明要执行的脚本之前server端执行过且没有发生变化,就直接执行了。如果没命中才需要客户端把脚本文件重新发一遍的。这样就提高了传输效率。官网建议每个需要执行的脚本都在应用中配置一个单例DefaultRedisScript
,避免每次执行都重新计算SHA1校验和。
反例:
/**
每次重新实例化ScriptSource,都要重新计算SHA1校验和,性能不佳
*/
@Bean
public RedisScript script() {
ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/checkandset.lua"));
return RedisScript.of(scriptSource, Boolean.class);
}
(3)、Controller使用
@Slf4j
@RestController
@RequestMapping("redis")
public class RedisTestConntroller {
@Autowired
private RedisDao redisDao;
@Autowired
private DefaultRedisScript deductMyStock;
/**
* 用户下单接口
* */
@RequestMapping(value = "neworder", method = RequestMethod.POST)
public String newOrder(@RequestBody Map order) {
String mobile = (String)order.get("mobile");
int buyNum = 5;
log.info("客户{}预约订购{}件商品..." , mobile, buyNum);
//预扣库存
List keys = new ArrayList<>();
keys.add("my-stock");
Map args = new HashMap<>();
args.put("buyNum", buyNum);
Boolean result = redisDao.executeScript(deductMyStock, keys, args);
if(!result.booleanValue()) {
return "已售完,扣减库存失败";
}
log.info("扣减库存成功, 客户{}, 库存扣减{}件商品", mobile, buyNum);
//生成订单,这里可以分两种情况:
//1.如果是预约性质的,那这边可以直接把订单请求写到mq,由消费端的订单系统去生成订单
//2.如果是要返回给调用端订单号的,比如前端要拿这个订单号去支付,那接下来可以先生成个订单号返回给前台,
// 同时异步将订单请求给到mq、消费端订单系统生成订单。
// 后续前端根据订单号发起支付请求时、前端跳转到支付页面之前要去查库里边的订单记录,考验mq和消费端订单系统的处理速度。
log.info("继续执行生成订单逻辑");
return "ok";
}
}
postman调用一下controller的接口,post requestBody { "mobile":"137xxxx8612"}
,返回ok,去redis上查看库存key已经正确扣除
127.0.0.1:7001> get my-stock
"2995"
tips:写好lua脚本之后可以直接传到redis服务器上,然后使用ldb调试。此外在脚本里也可以使用redis.log(redis.LOG_NOTICE, args)
来向redis打日志来进一步进行调试,这在java与lua脚本进行联调的时候很有用。redis.conf里边可以设置日志的级别和日志文件的位置。