SpringBoot在Redis中执行Lua脚本

SpringBoot执行Lua脚本

之前基本上完成了Lua脚本在Redis中使用的常用方式以及常用指令,在项目使用Lua脚本时,可以直接使用原始的指令,只是过程较为复杂。每种语言在实现Redis客户端时,基本上都会再次封装,尽量为用户提供更方便的调用,目前团队使用的SpringCloud架构,因此这里主要介绍一下SpringBoot执行Lua脚本的方法。

对Redis的一些封装

对Redis的操作,主要使用了SpringBoot的RedisTemplate模版,该模板是对各种客户端的一个抽象,无论使用Jedis、Lettuce等都无需关注底层的一些细节。另外为了更为简单的操作Redis,又对RedisTemplate进行了一层工具封装,如下示例

@Slf4j
public class RedisUtils {
    RedisTemplate<String, Object> redisTemplate;
    
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return false;
        }
    }
    ...
}

RedisTemplate序列化,支持多种方式,团队统一采用了FastJson进行序列化操作,这种方式目前也比较普遍,序列化对象的实现也比较简单

public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
    /**
     * 默认编码
     */
    public static final Charset DEFAULT_CHARSET = Charset.forName("utf-8");

    private Class<T> clazz;

    public FastJsonRedisSerializer(Class<T> clazz) {
        super();
        this.clazz = clazz;
    }

    @Nullable
    @Override
    public byte[] serialize(@Nullable T t) throws SerializationException {
        return Optional.ofNullable(t)
                .map(p -> JSON.toJSONString(p, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET))
                .orElseGet(() -> new byte[0]);
    }

    @Nullable
    @Override
    public T deserialize(@Nullable byte[] bytes) throws SerializationException {
        return Optional.ofNullable(bytes)
                .map(p -> JSON.parseObject(new String(p, DEFAULT_CHARSET), clazz))
                .orElse(null);
    }
}

在系统生成RedisTemplate对象时,只需要将FastJsonRedisSerializer对象设置为value和HashValue的序列化属性即可

redisTemplate.setValueSerializer(fastJsonRedisSerializer);
redisTemplate.setHashValueSerializer(fastJsonRedisSerializer);

以上这些操作,基本上完成RedisTemplate应用的前置配置,后续的示例都是依赖于上述的基础。

示例需求及构造数据

在这个示例中,不再使用上一节的订单例子。模拟一个新的需求,主要实现Lua脚本日志记录、多项数据的统计工作,这些知识点在前一节都有描述,这里主要是组装整合应用,日志记录也比较重要,可以用于跟踪BUG。

  • 公司销售团队会形成一个列表,能够展示在页面上,同时显示用户姓名,年龄以及当前的销售额
  • 管理团队需要能够查知销售团队的平均年龄,以及当年的销售总额,以便对比年任务差值

在这个示例中,将对返回值进行测试,因此构建了几个对象:销售人员、统计结果等两个对象

@Getter
@Setter
@ToString
public class Person {
    /**人员ID*/
    private Integer id;
    private String name;
    private Integer age;
     /**销售额*/
    private BigDecimal sales;
}

@Getter
@Setter
@ToString
public class Sum {
    /**平均年龄*/
    private Double avgAge;
    /**总销售额*/
    private BigDecimal sumSale;
}

创建几个测试数据

    public void inintPersons() {
        // 人员列表
        String idKeys = "mylua:id";
        // 销售人员详情
        String userkey = "mylua:user";
        List<Object> ids = ImmutableList.of(1, 2, 3, 4);
        ids.forEach(p -> {
            Integer i = (Integer) p;
            Person person = new Person();
            person.setId(i);
            person.setName("user" + i);
            person.setAge(30 + i);
            person.setSales(new BigDecimal(i * 30000));

            redisUtils.hset(userkey, String.valueOf(p), person);
        });

        redisUtils.lsetlist(idKeys, ids);
    }

初始化脚本后,Redis中将包含一组测试数据

key=mylua:id 用户ID列表,目前采用列表实现。可以使用有序集合实现,按用户销售额进行排序,实现销售人员列表展示

key=mylua:user 散列表实现,item=用户ID,存储用户详情,{id: "", name: "", age: 1, sales: 1}

执行Lua脚本

根据以上的需求,实现步骤为:

  • 读取销售人员ID列表
  • 循环列表读取每个销售信息,累加年龄及销售额
  • 计算年龄平均值,返回人均年龄和总销售

在实现这个脚本时,只需要传入两个键值,销售人员ID列表键,散列表得键,用户ID由中间过程产生;不需要传递参数。因此其脚本参见下述代码

-- 调用方式:2 idlistKey userKey, 返回{avgAge, sumSale}
-- 读取参数键,分别为id集合key,用户详情key
local idsKey = KEYS[1]
local userKey = KEYS[2]
-- 记录日志,安装默认的log级别为NOTICE
redis.log(redis.LOG_NOTICE, "Receie the param:" .. idsKey)

-- 获取所有的id集合, table
local ids = redis.call('lrange', idsKey, 0, -1)
-- 记录用户长度
redis.log(redis.LOG_NOTICE, "the user count:" .. #ids)

local sumAge = 0

-- 结果 销售总额,人均年龄
local sumSale = 0
local avgAge = 0

if type(ids) == "table" and #ids > 0 then
    for _, v in pairs(ids) do
        -- 散列表中存储的是字符串,因为序列化为JSON,因此使用cjson将json字符串转为Lua table
        local user = cjson.decode(redis.call("hget", userKey, v))
        sumAge = sumAge + user.age
        sumSale = sumSale + user.sales
    end
    avgAge = sumAge / #ids
end

local result = {}
result.avgAge = avgAge
result.sumSale = sumSale

-- table返回时转换为JSON
local ret = cjson.encode(result)
redis.log(redis.LOG_NOTICE, "calc result:" .. ret)

return ret

上面的脚本中使用到了CJSON类库,该类库由C开发,在LUA中提供高效的JSON操作,常用方法有

  • cjson.encode({}) 将Lua的table数据类型转换为JSON格式字符串
  • cjson.decode(jsonString) 将json格式字符串转为Lua的table数据

脚本完成之后,添加到SpringBoot项目的resources目录下,即目录结构为:src/resources/person.lua。RedisTemplate提供两种方法可以执行Lua脚本

/**
    script为脚本资源,keys为一个数组形式的Key集合,最后为可选参数
*/
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)

/**
    和上述中方式一致,多了两个参数,分别为参数序列化方法、结果序列化方法,更方便扩展
*/
<T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)

RedisTemplate使用Lua脚本时,有一个非常重要的参数RedisScript,该参数一方面决定了Lua脚本资源,另一方面也决定了返回值类型。在实际开发过程中,使用的是默认DefaultRedisScript实现对象,对这个类进行一些查阅,了解以下使用方式

// 这里代码不全,可以查看org.springframework.data.redis.core.script.DefaultRedisScript
// 三个属性,分别表示了脚本资源,脚本sha1,以及结果类型
private @Nullable ScriptSource scriptSource;
private @Nullable String sha1;
private @Nullable Class<T> resultType;
// 脚本字符串和返回类型构造函数,一般不常用
public DefaultRedisScript(String script, @Nullable Class<T> resultType) {
        ...
}
// 获取脚本Sha1,缓存时使用
public String getSha1() {
    ...
}
// 设置返回类型, 
public void setResultType(Class<T> resultType) {
    ...
}
// 设置脚本资源
public void setLocation(Resource scriptLocation) {
    this.scriptSource = new ResourceScriptSource(scriptLocation);
}
// 设置脚本资源
public void setScriptSource(ScriptSource scriptSource) {
    this.scriptSource = scriptSource;
}

上面setResultType方法中,有一个要求,只能设置类型为:Long、布尔、List或者反序列化类型,设置为什么类型,最后就得到什么类型。

The script result type. Should be one of Long, Boolean, List, or deserialized value type

在SpringBoot项目中,脚本一般放在resources中,因此很容易获取到Resource资源(ClassPathResource),因此后面两个设置脚本资源的方法是比较方便的。对于两个执行Lua脚本的方法,分别进行调用,看一下实现的方式。

  • 直接调用,不提供序列化方法

<T> T execute(RedisScript<T> script, List<K> keys, Object... args)

public void execLuaWithoutSerializer() {
    DefaultRedisScript<JSONObject> script = new DefaultRedisScript<>();
    script.setResultType(JSONObject.class);
    script.setLocation(new ClassPathResource("/person.lua"));
    List<String> keys = ImmutableList.of("mylua:id", "mylua:user");
    JSONObject sum = redisTemplate.execute(script, keys);
    System.out.println(sum.toJSONString());
}

在之前的脚本中,定义了必须传递两个key,但不需要参数,因此在调用时传入了一个List,存储两个key。由于脚本之后返回了一个JSON格式字符串,并且RedisTemplate采用了FastJson序列化,因此返回一个JSONObject,调用时设置的ResultType必须和脚本返回类型一致,由于没有指定序列化,会使用默认的序列化工具,而设置的默认序列化方法泛型为Object,因此上述无法直接使用Sum类型,参见第一节RedisTemplate<String, Object> redisTemplate。如果有序列化特定类型,还是需要采用更明确第二种方法。执行后,其返回的结果如下

{"sumSale":300000,"avgAge":32.5}

由于团队采用了RedisTemplate<String, Object>的泛型方案,虽然能够处理任意类型,但是对于类型转换确实存在一些不方便之处,本例中只能只能转换为JSONObject也是基于此,无法直接序列化为Sum对象,只能再次提供一个新的序列化再次进行。可以采用RedisTemplate不指定泛型的方式去解决类型转换的问题,但使用起来也会有一些不变之处。

  • 提供序列化方法

<T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)

public void execLuaWithSerializer() {
    DefaultRedisScript<Sum> script = new DefaultRedisScript<>();
    script.setResultType(Sum.class);
    script.setLocation(new ClassPathResource("/person.lua"));
    List<String> keys = ImmutableList.of("mylua:id", "mylua:user");
    Sum sum = redisTemplate.execute(script, new StringRedisSerializer(), new FastJsonRedisSerializer<>(Sum.class), keys);
    System.out.println(sum);
}

在这个示例中,期望返回自定义的Sum类型,将更方便在程序中的应用。主要就是返回结果的序列化方法,也使用了同一个序列化操作对象。其执行结果

Sum(avgAge=32.5, sumSale=300000)

执行结果符合预期。上面两种方法都可以使用,相对来说,第二种更适合在业务开发中使用。由于业务的不同,因此使用的脚本不同,返回数据不同,键与参数都不同。在使用过程中可以稍微封装一下。

SpringBoot执行脚本流程分析

在之前介绍执行脚本指令时,提到过两种指令EVALEVALSHA,并提供了检查脚本缓存是否存在的指令SCRIPT EXISTS,EVAL指令或者SCRIPT LOAD指令都可以将脚本缓存,EVAL立即执行,但不会返回SHA1,SCRIPT LOAD缓存脚本,但不立即执行,并且返回SHA1值。EVALSHA指令可以直接利用Redis缓存的脚本执行,而不需要每次都传递脚本,当脚本比较大时,可以节约网络传输数据量。

但是在上面SpringBoot执行过程中,并没有发现其调用EVALSHA,也没有执行SCRIPT EXISTS的方法,这个过程中有没有利用到SHA(在RedisScript中,有一个getSha1的方法),需要分析一下其执行流程。

上面的两种方法最终都调用到下述方法

    // 实现类org.springframework.data.redis.core.script.DefaultScriptExecutor
    // 第一个方法调用该方法,采用过了RedisTemplate默认提供的序列化对象
    public <T> T execute(final RedisScript<T> script, final List<K> keys, final Object... args) {
        // use the Template's value serializer for args and result
        return execute(script, template.getValueSerializer(), (RedisSerializer<T>) template.getValueSerializer(), keys,
                args);
    }

    // 最终两个都调用了该方法
    public <T> T execute(final RedisScript<T> script, final RedisSerializer<?> argsSerializer,
            final RedisSerializer<T> resultSerializer, final List<K> keys, final Object... args) {
        return template.execute((RedisCallback<T>) connection -> {
            final ReturnType returnType = ReturnType.fromJavaType(script.getResultType());
            final byte[][] keysAndArgs = keysAndArgs(argsSerializer, keys, args);
            final int keySize = keys != null ? keys.size() : 0;
            if (connection.isPipelined() || connection.isQueueing()) {
                connection.eval(scriptBytes(script), returnType, keySize, keysAndArgs);
                return null;
            }
            // 没有使用管道,调用该方法
            return eval(connection, script, returnType, keySize, keysAndArgs, resultSerializer);
        });
    }

    protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
            byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {

        Object result;
        try {
            result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
        } catch (Exception e) {

            if (!ScriptUtils.exceptionContainsNoScriptError(e)) {
                throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
            }

            result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
        }

        if (script.getResultType() == null) {
            return null;
        }

        return deserializeResult(resultSerializer, result);
    }

从上面最后一个方法执行中可以看到,SpringBoot先计算了当前资源的Sha1,并使用EVALSHA指令尝试执行了一次,如果成功,则返回结果,如果缓存没有该脚本,则进入异常部分,并最终使用了EVAL指令进行执行。

从这里可以看到,SpringBoot客户端已经实现了脚本缓存的功能,只不过进行了封装,并且不对用户暴露。在使用时简单,傻瓜,并且用起来很舒服。总结一下就是:SpringBoot每次都先按EVALSHA执行,没有缓存脚本,再次执行EVAL,得到结果并缓存脚本。

注:开发环境是Windows,Redis在Linux上部署,由于编码以及文件的换行符配置导致Windows下计算的SHA1,与Redis在Linux下缓存的文件SHA1不匹配,导致每次都无法命中缓存,此时可以通过IDEA的文件换行设置,调整脚本文件使用Unix换行符,可以解决不同系统匹配问题。

IDEA设置Linux换行符

如上图,最开始默认为Windows换行符,CRLF,调整为LF即可解决上述问题。

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