Redis事务与Redis script lua脚本

一、redis事务

redis事务可以一连着执行多个命令,每个命令都跟redis server通信一次,然后这些命令先进入到server端的事务队列里QUEUED,直到最后收到exec命令后,一起执行。一起执行的时候不会被其他命令插入。如果这时候如果命令集合中有命令报错则不会回滚已经执行掉了的命令、且后面的命令也会执行。但是可以通过watch机制在事务之前指定事务执行的条件,一旦watch的key发生了改变则exec返回执行失败。

我们姑且把redis事务称为“弱原子性”,因为其没有类似关系型数据库的回滚机制。

1、redis事务与lua脚本、pipeline区别
  1. pipeline目的是一次网络通信执行一组命令,但是不具备redis事务的弱原子性。

  2. 将一组命令用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
<value> stock_key = "my-stock"
<value> args = "{\"buyNum\":5}"
<value> 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
<value> stock_key = "my-stock"
<value> args = "{\"buyNum\":5}"
<value> args_json = {["buyNum"]=5}
<value> buy_num = 5
lua debugger> s
<redis> get my-stock
<reply> "3000"
* Stopped at 9, stop reason = step over
-> 9   if tonumber(current_stock) > 0 then 
lua debugger> p
<value> stock_key = "my-stock"
<value> args = "{\"buyNum\":5}"
<value> args_json = {["buyNum"]=5}
<value> buy_num = 5
<value> 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
<value> stock_key = "my-stock"
<value> args = "{\"buyNum\":5}"
<value> args_json = {["buyNum"]=5}
<value> buy_num = 5
<value> current_stock = "3000"
lua debugger> s
<redis> set my-stock 2995
<reply> "+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<String, Object> redisTemplate(RedisConnectionFactory factory) {

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        RedisSerializer<String> stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(stringRedisSerializer);
        return redisTemplate;
    }
}

RedisDao

@Slf4j
@Service
public class RedisDao {
    
    @Autowired
    RedisTemplate<String, Object> 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> T executeScript(DefaultRedisScript<T> script, List<String> keys, Map<String,Object> 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<Boolean> deductMyStock() {
        DefaultRedisScript<Boolean> 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<Boolean> 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<Boolean> deductMyStock;
    
    /**
     * 用户下单接口
     * */
    @RequestMapping(value = "neworder", method = RequestMethod.POST)
    public String newOrder(@RequestBody Map<String,Object> order) {
        String mobile = (String)order.get("mobile");
        int buyNum = 5;
        log.info("客户{}预约订购{}件商品..." , mobile, buyNum);
        
        //预扣库存
        List<String> keys = new ArrayList<>();
        keys.add("my-stock");
        Map<String,Object> 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里边可以设置日志的级别和日志文件的位置。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,816评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,729评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,300评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,780评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,890评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,084评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,151评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,912评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,355评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,666评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,809评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,504评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,150评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,121评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,628评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,724评论 2 351

推荐阅读更多精彩内容