SpringBoot在Redis中執(zhí)行Lua腳本

SpringBoot執(zhí)行Lua腳本

之前基本上完成了Lua腳本在Redis中使用的常用方式以及常用指令颠悬,在項目使用Lua腳本時矮燎,可以直接使用原始的指令,只是過程較為復(fù)雜赔癌。每種語言在實現(xiàn)Redis客戶端時诞外,基本上都會再次封裝,盡量為用戶提供更方便的調(diào)用灾票,目前團隊使用的SpringCloud架構(gòu)峡谊,因此這里主要介紹一下SpringBoot執(zhí)行Lua腳本的方法。

對Redis的一些封裝

對Redis的操作,主要使用了SpringBoot的RedisTemplate模版,該模板是對各種客戶端的一個抽象,無論使用Jedis、Lettuce等都無需關(guān)注底層的一些細節(jié)。另外為了更為簡單的操作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序列化请毛,支持多種方式仙蚜,團隊統(tǒng)一采用了FastJson進行序列化操作面徽,這種方式目前也比較普遍霎匈,序列化對象的實現(xiàn)也比較簡單

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);
    }
}

在系統(tǒng)生成RedisTemplate對象時,只需要將FastJsonRedisSerializer對象設(shè)置為value和HashValue的序列化屬性即可

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

以上這些操作,基本上完成RedisTemplate應(yīng)用的前置配置,后續(xù)的示例都是依賴于上述的基礎(chǔ)。

示例需求及構(gòu)造數(shù)據(jù)

在這個示例中蹄葱,不再使用上一節(jié)的訂單例子竣况。模擬一個新的需求摹恨,主要實現(xiàn)Lua腳本日志記錄、多項數(shù)據(jù)的統(tǒng)計工作较木,這些知識點在前一節(jié)都有描述泳赋,這里主要是組裝整合應(yīng)用千诬,日志記錄也比較重要,可以用于跟蹤BUG毅访。

  • 公司銷售團隊會形成一個列表,能夠展示在頁面上玛迄,同時顯示用戶姓名,年齡以及當(dāng)前的銷售額
  • 管理團隊需要能夠查知銷售團隊的平均年齡钧栖,以及當(dāng)年的銷售總額,以便對比年任務(wù)差值

在這個示例中,將對返回值進行測試淀弹,因此構(gòu)建了幾個對象:銷售人員忿峻、統(tǒng)計結(jié)果等兩個對象

@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;
}

創(chuàng)建幾個測試數(shù)據(jù)

    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中將包含一組測試數(shù)據(jù)

key=mylua:id 用戶ID列表叔遂,目前采用列表實現(xiàn)嚼吞∑酰可以使用有序集合實現(xiàn)补憾,按用戶銷售額進行排序岩瘦,實現(xiàn)銷售人員列表展示

key=mylua:user 散列表實現(xiàn),item=用戶ID苏遥,存儲用戶詳情田炭,{id: "", name: "", age: 1, sales: 1}

執(zhí)行Lua腳本

根據(jù)以上的需求瞬矩,實現(xiàn)步驟為:

  • 讀取銷售人員ID列表
  • 循環(huán)列表讀取每個銷售信息盾碗,累加年齡及銷售額
  • 計算年齡平均值,返回人均年齡和總銷售

在實現(xiàn)這個腳本時,只需要傳入兩個鍵值唬格,銷售人員ID列表鍵家破,散列表得鍵,用戶ID由中間過程產(chǎn)生购岗;不需要傳遞參數(shù)汰聋。因此其腳本參見下述代碼

-- 調(diào)用方式:2 idlistKey userKey, 返回{avgAge, sumSale}
-- 讀取參數(shù)鍵,分別為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

-- 結(jié)果 銷售總額,人均年齡
local sumSale = 0
local avgAge = 0

if type(ids) == "table" and #ids > 0 then
    for _, v in pairs(ids) do
        -- 散列表中存儲的是字符串乾吻,因為序列化為JSON髓梅,因此使用cjson將json字符串轉(zhuǎn)為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返回時轉(zhuǎn)換為JSON
local ret = cjson.encode(result)
redis.log(redis.LOG_NOTICE, "calc result:" .. ret)

return ret

上面的腳本中使用到了CJSON類庫,該類庫由C開發(fā)绎签,在LUA中提供高效的JSON操作枯饿,常用方法有

  • cjson.encode({}) 將Lua的table數(shù)據(jù)類型轉(zhuǎn)換為JSON格式字符串
  • cjson.decode(jsonString) 將json格式字符串轉(zhuǎn)為Lua的table數(shù)據(jù)

腳本完成之后,添加到SpringBoot項目的resources目錄下诡必,即目錄結(jié)構(gòu)為:src/resources/person.lua奢方。RedisTemplate提供兩種方法可以執(zhí)行Lua腳本

/**
    script為腳本資源,keys為一個數(shù)組形式的Key集合爸舒,最后為可選參數(shù)
*/
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)

/**
    和上述中方式一致袱巨,多了兩個參數(shù),分別為參數(shù)序列化方法碳抄、結(jié)果序列化方法愉老,更方便擴展
*/
<T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)

RedisTemplate使用Lua腳本時,有一個非常重要的參數(shù)RedisScript剖效,該參數(shù)一方面決定了Lua腳本資源嫉入,另一方面也決定了返回值類型。在實際開發(fā)過程中璧尸,使用的是默認DefaultRedisScript實現(xiàn)對象咒林,對這個類進行一些查閱,了解以下使用方式

// 這里代碼不全爷光,可以查看org.springframework.data.redis.core.script.DefaultRedisScript
// 三個屬性垫竞,分別表示了腳本資源,腳本sha1,以及結(jié)果類型
private @Nullable ScriptSource scriptSource;
private @Nullable String sha1;
private @Nullable Class<T> resultType;
// 腳本字符串和返回類型構(gòu)造函數(shù),一般不常用
public DefaultRedisScript(String script, @Nullable Class<T> resultType) {
        ...
}
// 獲取腳本Sha1欢瞪,緩存時使用
public String getSha1() {
    ...
}
// 設(shè)置返回類型, 
public void setResultType(Class<T> resultType) {
    ...
}
// 設(shè)置腳本資源
public void setLocation(Resource scriptLocation) {
    this.scriptSource = new ResourceScriptSource(scriptLocation);
}
// 設(shè)置腳本資源
public void setScriptSource(ScriptSource scriptSource) {
    this.scriptSource = scriptSource;
}

上面setResultType方法中活烙,有一個要求,只能設(shè)置類型為:Long遣鼓、布爾啸盏、List或者反序列化類型,設(shè)置為什么類型骑祟,最后就得到什么類型回懦。

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

在SpringBoot項目中,腳本一般放在resources中次企,因此很容易獲取到Resource資源(ClassPathResource)怯晕,因此后面兩個設(shè)置腳本資源的方法是比較方便的。對于兩個執(zhí)行Lua腳本的方法缸棵,分別進行調(diào)用舟茶,看一下實現(xiàn)的方式。

  • 直接調(diào)用蛉谜,不提供序列化方法

<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崇堵,但不需要參數(shù)型诚,因此在調(diào)用時傳入了一個List,存儲兩個key鸳劳。由于腳本之后返回了一個JSON格式字符串狰贯,并且RedisTemplate采用了FastJson序列化,因此返回一個JSONObject赏廓,調(diào)用時設(shè)置的ResultType必須和腳本返回類型一致涵紊,由于沒有指定序列化,會使用默認的序列化工具幔摸,而設(shè)置的默認序列化方法泛型為Object摸柄,因此上述無法直接使用Sum類型,參見第一節(jié)RedisTemplate<String, Object> redisTemplate既忆。如果有序列化特定類型驱负,還是需要采用更明確第二種方法。執(zhí)行后患雇,其返回的結(jié)果如下

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

由于團隊采用了RedisTemplate<String, Object>的泛型方案跃脊,雖然能夠處理任意類型,但是對于類型轉(zhuǎn)換確實存在一些不方便之處苛吱,本例中只能只能轉(zhuǎn)換為JSONObject也是基于此酪术,無法直接序列化為Sum對象,只能再次提供一個新的序列化再次進行翠储』嫜悖可以采用RedisTemplate不指定泛型的方式去解決類型轉(zhuǎn)換的問題橡疼,但使用起來也會有一些不變之處。

  • 提供序列化方法

<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類型衰齐,將更方便在程序中的應(yīng)用。主要就是返回結(jié)果的序列化方法继阻,也使用了同一個序列化操作對象耻涛。其執(zhí)行結(jié)果

Sum(avgAge=32.5, sumSale=300000)

執(zhí)行結(jié)果符合預(yù)期。上面兩種方法都可以使用瘟檩,相對來說抹缕,第二種更適合在業(yè)務(wù)開發(fā)中使用。由于業(yè)務(wù)的不同墨辛,因此使用的腳本不同卓研,返回數(shù)據(jù)不同,鍵與參數(shù)都不同睹簇。在使用過程中可以稍微封裝一下奏赘。

SpringBoot執(zhí)行腳本流程分析

在之前介紹執(zhí)行腳本指令時,提到過兩種指令EVAL太惠、EVALSHA磨淌,并提供了檢查腳本緩存是否存在的指令SCRIPT EXISTS,EVAL指令或者SCRIPT LOAD指令都可以將腳本緩存凿渊,EVAL立即執(zhí)行梁只,但不會返回SHA1,SCRIPT LOAD緩存腳本埃脏,但不立即執(zhí)行搪锣,并且返回SHA1值。EVALSHA指令可以直接利用Redis緩存的腳本執(zhí)行彩掐,而不需要每次都傳遞腳本构舟,當(dāng)腳本比較大時,可以節(jié)約網(wǎng)絡(luò)傳輸數(shù)據(jù)量堵幽。

但是在上面SpringBoot執(zhí)行過程中旁壮,并沒有發(fā)現(xiàn)其調(diào)用EVALSHA,也沒有執(zhí)行SCRIPT EXISTS的方法谐檀,這個過程中有沒有利用到SHA(在RedisScript中抡谐,有一個getSha1的方法),需要分析一下其執(zhí)行流程桐猬。

上面的兩種方法最終都調(diào)用到下述方法

    // 實現(xiàn)類org.springframework.data.redis.core.script.DefaultScriptExecutor
    // 第一個方法調(diào)用該方法麦撵,采用過了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);
    }

    // 最終兩個都調(diào)用了該方法
    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;
            }
            // 沒有使用管道,調(diào)用該方法
            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);
    }

從上面最后一個方法執(zhí)行中可以看到,SpringBoot先計算了當(dāng)前資源的Sha1免胃,并使用EVALSHA指令嘗試執(zhí)行了一次音五,如果成功,則返回結(jié)果羔沙,如果緩存沒有該腳本躺涝,則進入異常部分,并最終使用了EVAL指令進行執(zhí)行扼雏。

從這里可以看到坚嗜,SpringBoot客戶端已經(jīng)實現(xiàn)了腳本緩存的功能,只不過進行了封裝诗充,并且不對用戶暴露苍蔬。在使用時簡單,傻瓜蝴蜓,并且用起來很舒服碟绑。總結(jié)一下就是:SpringBoot每次都先按EVALSHA執(zhí)行茎匠,沒有緩存腳本格仲,再次執(zhí)行EVAL,得到結(jié)果并緩存腳本诵冒。

注:開發(fā)環(huán)境是Windows凯肋,Redis在Linux上部署,由于編碼以及文件的換行符配置導(dǎo)致Windows下計算的SHA1造烁,與Redis在Linux下緩存的文件SHA1不匹配否过,導(dǎo)致每次都無法命中緩存午笛,此時可以通過IDEA的文件換行設(shè)置惭蟋,調(diào)整腳本文件使用Unix換行符,可以解決不同系統(tǒng)匹配問題药磺。

IDEA設(shè)置Linux換行符

如上圖告组,最開始默認為Windows換行符,CRLF癌佩,調(diào)整為LF即可解決上述問題木缝。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者围辙。
  • 序言:七十年代末我碟,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子姚建,更是在濱河造成了極大的恐慌矫俺,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,542評論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異厘托,居然都是意外死亡友雳,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評論 3 394
  • 文/潘曉璐 我一進店門铅匹,熙熙樓的掌柜王于貴愁眉苦臉地迎上來押赊,“玉大人,你說我怎么就攤上這事包斑×鹘福” “怎么了?”我有些...
    開封第一講書人閱讀 163,912評論 0 354
  • 文/不壞的土叔 我叫張陵舰始,是天一觀的道長崇棠。 經(jīng)常有香客問我,道長丸卷,這世上最難降的妖魔是什么枕稀? 我笑而不...
    開封第一講書人閱讀 58,449評論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮谜嫉,結(jié)果婚禮上萎坷,老公的妹妹穿的比我還像新娘。我一直安慰自己沐兰,他們只是感情好哆档,可當(dāng)我...
    茶點故事閱讀 67,500評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著住闯,像睡著了一般瓜浸。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上比原,一...
    開封第一講書人閱讀 51,370評論 1 302
  • 那天插佛,我揣著相機與錄音,去河邊找鬼量窘。 笑死雇寇,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的蚌铜。 我是一名探鬼主播锨侯,決...
    沈念sama閱讀 40,193評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼冬殃!你這毒婦竟也來了囚痴?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,074評論 0 276
  • 序言:老撾萬榮一對情侶失蹤审葬,失蹤者是張志新(化名)和其女友劉穎深滚,沒想到半個月后骂束,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,505評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡成箫,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,722評論 3 335
  • 正文 我和宋清朗相戀三年展箱,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蹬昌。...
    茶點故事閱讀 39,841評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡混驰,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出皂贩,到底是詐尸還是另有隱情栖榨,我是刑警寧澤,帶...
    沈念sama閱讀 35,569評論 5 345
  • 正文 年R本政府宣布明刷,位于F島的核電站婴栽,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏辈末。R本人自食惡果不足惜愚争,卻給世界環(huán)境...
    茶點故事閱讀 41,168評論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望挤聘。 院中可真熱鬧轰枝,春花似錦、人聲如沸组去。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,783評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽从隆。三九已至诚撵,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間键闺,已是汗流浹背寿烟。 一陣腳步聲響...
    開封第一講書人閱讀 32,918評論 1 269
  • 我被黑心中介騙來泰國打工烟央, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人证鸥。 一個月前我還...
    沈念sama閱讀 47,962評論 2 370
  • 正文 我出身青樓剧蚣,卻偏偏與公主長得像,于是被迫代替她去往敵國和親膀斋。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,781評論 2 354