後端程序員必會:併發狀況下redis-lua保證原子操做

前言

本文主要是分享在實際工做中同事遇到的問題案例;活動組在作活動時,開發人員未考慮到接口併發場景,致使由於一些用戶在實際抽獎(土豪通常都是狂抽)過程當中對餘額產生了增長/減小的操做,致使緩存的餘額出現異常;經過我review代碼發現,開發者在更新緩存時:先get後set或者incrby,致使併發場景下get的值是一致的,因此緩存異常。php

那麼針對這種我進行了改進使用:redis+lua腳本實現原子性保證餘額數據正常。本文將跟你們一塊兒學習Redis使用lua腳本的應用:html

    1. 爲何引入Lua
    1. 什麼是Lua
    1. 主要優點
    1. 基本用法
    1. 實戰講解
    1. 腳本的安全性

Redis中爲何引入Lua腳本?

Redis是高性能的key-value內存數據庫,它幫助咱們解決了大部分業務問題;提供豐富的指令集合,據官網上統計有200多個命令。這些命令顯然已經知足了咱們的常規的業務場景需求。可是在某些特殊的場景下,業務須要原子性操做,redis原有的命令是沒法完成,因此須要額外開發實現原子操做。redis

由於這樣的問題,Redis爲開發者提供了lua腳本的支持,用戶能夠向服務器發送lua腳原本執行自定義動做,以此獲取腳本的響應數據。Redis自己又是單線程執行lua腳本,保證了lua腳本在處理邏輯過程當中不會被任意其它請求打斷數據庫

什麼是Lua

Lua是一種輕量小巧腳本語言,用標準C語言編寫並以源代碼形式開放。json

其設計目的就是爲了嵌入應用程序中,從而爲應用程序提供靈活的擴展和定製功能。由於普遍的應用於:遊戲開發、獨立應用腳本、Web 應用腳本、擴展和數據庫插件等。數組

好比:Lua腳本用在不少遊戲上,主要是Lua腳本能夠嵌入到其餘程序中運行,遊戲升級的時候,能夠直接升級腳本,而不用從新安裝遊戲。緩存

小夥們能夠查看我lua從入門到實戰專欄安全

專欄已經在計劃中出教程了,雖然看似寫的內容比較簡單,可是須要注重細節地方;lua語言每每在項目中出問題基本上細節較多。

主要優點

可以使用版本:從 Redis 2.6.0 版本開始起;可經過內置的 Lua 解釋器,可使用 EVAL 命令對 Lua 腳本進行執行。

時間複雜度:根據腳本的複雜度而定(腳本儘可能簡潔)。

使用Lua腳本的好處

### 共有三條優點

① 支持原子性操做 - Redis會將整個腳本做爲一個總體執行,中間不會被其餘請求插入。所以在腳本運行過程當中無需擔憂會出現競態條件,無需使用事務

② 下降網絡開銷 - 將多個請求經過腳本的形式一次發送到服務器,減小了網絡的時延

③ 腳本複用    - 客戶端發送的腳本可支持永久存在redis中,這樣其餘客戶端能夠複用這一腳本,而不須要使用代碼完成相同的邏輯。

複製代碼

基本用法

Eval命令的基本語法以下:

## 命令格式
redis 127.0.0.1:6379> EVAL script numkeys key [key ...] arg [arg ...]

### 參數說明

① script Lua 5.1版本以上腳本程序,它會被運行在Redis服務器上下文中,這段腳本沒必要(也不該該)定義爲一個 Lua函數。

② numkeys 指用於指定鍵名參數的個數

③ key [key ...] 指要操做的鍵名,能夠指定多個,在lua腳本中經過KEYS[1], KEYS[2]獲取

④ arg [arg ...] 指附加參數,在lua腳本中經過全局變量 ARGV 數組訪問;例如:ARGV[1], ARGV[2]

複製代碼

① 實例實現方式之一:

### 既有key鍵也有附加參數
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 value1 value2
1) "key1"
2) "key2"
3) "value1"
4) "value2"

### 只有附加參數
127.0.0.1:6379> eval "return {ARGV[1],ARGV[2]}" 0 'hello!' 'my name is amumu'
1) "hello!"
2) "my name is amumu"

### 注意

{} 在lua裏是指數據類型table,一樣相似常說的數組格式
複製代碼

② 實例實現方式之二:

### lua腳本中,可以使用兩個不一樣函數來執行redis命令

① redis.call()
    -- 正確的設置方式 設置amumu值爲1000 60s過時  
    127.0.0.1:6379> eval "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;" 1 amumu 1000 60
    (integer) 1
    127.0.0.1:6379> get amumu -- 設置成功
    "1000"
    127.0.0.1:6379> ttl amumu -- 剩餘存活時間
    (integer) 49
    127.0.0.1:6379> ttl amumu -- 已通過期
    (integer) -2
    
    -- 出現報錯的狀況
    127.0.0.1:6379> eval "redis.call('SET', KEYS[1], ARGV[1]);redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1;" 0 amumu 1000 60
    (error) ERR Error running script (call to f_6aeea4b3e96171ef835a78178fceadf1a5dbe345): @user_script:1: @user_script: 1: Lua redis() command arguments must be strings or integers

② redis.pcall()
    -- 正確的設置方式 獲取amumu緩存值
    127.0.0.1:6379> EVAL "return redis.pcall('GET', KEYS[1])" 1 amumu
    "1000"
    
    -- 出現報錯的狀況
    
    127.0.0.1:6379> EVAL "return redis.pcall('GET', KEYS[1])" 0 amumu
    (error) @user_script: 1: Lua redis() command arguments must be strings or integers
複製代碼

從上面的報錯狀況能夠看出來:redis.call() 和 redis.pcall() 的惟一區別在於它們對錯誤處理的不一樣

  • redis.call()在執行命令的過程當中發生錯誤時,腳本會直接中止執行,並返回一個腳本錯誤,會告訴你形成錯誤的緣由

  • redis.pcall()執行中出錯時並不引起致命錯誤,而是返回一個帶err域的Lua表,展現結果

127.0.0.1:6379>  eval 'local dt = redis.pcall("HGETALL", KEYS[1]); local res = {type(dt)}; for i, v in ipairs(dt) do res[#res+1] = i; res[#res+1] = v; end; return res' 0
1) "table"
複製代碼

③ 實例實現方式之三

## 在命令行裏使用

127.0.0.1:6379> redis-cli --eval lua_filenames key1 key2 , arg1 arg2 ...

### 各單位請注意eval命令的後面參數是lua腳本文件,須要完整的文件名;例如hello.lua

② 跟前兩種方式不同的地方,不須要指定numkeys個數,而是使用,(英文逗號)隔開;注意,先後有空格。

複製代碼

演示示例以下:

## test.lua文件

-- 獲取緩存key
local _key = KEYS[1]
-- 獲取設置的值
local _val = ARGV[1]

-- 獲取緩存已經存在的值
local result = redis.call('GET', _key);  
result = result and result or ""

-- 定義返回的結果變量
local text = ''
if result == '' then
    return text
else
    text = result .. _val
    redis.call('SET', _key, text)
end

return text
複製代碼

開始命令行運行lua腳本文件,以下圖:

-- 第一次設置緩存未有值 因此返回了null
➜  ~ redis-cli --raw --eval Desktop/test.lua lua:test , '歡迎關注個人lua專欄!'

-- 設置默認值
➜  ~ redis-cli set lua:test '你們好,我是阿沐!'
OK

➜  ~ redis-cli --raw --eval Desktop/test.lua lua:test , '歡迎關注個人lua專欄!'
你們好,我是阿沐!歡迎關注個人lua專欄!

➜  ~ redis-cli --raw --eval Desktop/test.lua lua:test , '熱衷於經過項目實戰經驗分享!'
你們好,我是阿沐!歡迎關注個人lua專欄!熱衷於經過項目實戰經驗分享!

➜  ~ redis-cli --raw --eval Desktop/test.lua lua:test , '請必定要仔細閱讀,注意點很重要!'
你們好,我是阿沐!歡迎關注個人lua專欄!熱衷於經過項目實戰經驗分享!請必定要仔細閱讀,注意點很重要!

### 注意

實際上咱們在正常開發過程,可能不會採用此方法,更多的仍是在,項目裏使用仍是以腳本方式寫入;

這裏只是告訴你們有多種執行方式
複製代碼

實戰實例:

<?php
$script = <<<EOF local _key = KEYS[1] local _val = ARGV[1] local result = redis.call('GET', _key); result = result and result or "" local text = '' if result == '' then return text else text = result .. _val redis.call('SET', _key, text) end return text EOF;

// 獲取傳過來的變量
$text = isset($argv[1]) ? $argv[1] : '';
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$result = $redis->eval($script, array("lua:test", $text), 1);
echo $result;

### 執行結果集
➜ ~ /usr/local/opt/php@7.2/bin/php index.php

➜ ~ redis-cli set lua:test '你們好,我是阿沐!'
OK
➜  Desktop /usr/local/opt/php@7.2/bin/php index.php '歡迎關注個人lua專欄!'
你們好,我是阿沐!歡迎關注個人lua專欄!
複製代碼

參數說明:

Redis::eval(string script, [array keys], int keys_nums)

### 解析參數

① ::eval 執行命令

② script 要執行的lua腳本 

③ keys 是指key值

④ keys_nums 參數爲KEYS的個數,用來區分KEYS和ARGV

複製代碼

④ lua腳本加載到腳本緩存-evalsha

緣由以下:

  • ① 生成環境下,若是使用evalsha會比eval發送更小的數據包,佔用更少的網絡資源;

  • ② eval每次都須要把腳本完整發送給redis,而evalsha只須要傳遞一個sha1值便可完成

檢測指定sha1是否已經存在:

## 基本命令
-- 指定一個或多個腳本的sha1校驗和,返回一個結果集含有0和1的列表(tab),表示校驗和所指定的腳本是否已經被保存在緩存當中
script exists sha1 [sha1 ...] 

## 說明:
① redis版本號:必須大於等於 2.6.0

② 時間複雜度: O(n),n爲給定的sha1校驗和的數量

③ 結果集: 一個列表返回;0-不存在緩存中;2-存在緩存中;列表值跟結果集一一對應
複製代碼

演示示例:

-- 檢測sha1是否存在
127.0.0.1:6379> script exists 'b3e2eb6aa7bdb29e60f32cd153612a2887164b70'
1) (integer) 0

-- 設置sha1值
127.0.0.1:6379> script load "return redis.call('get', 'lua:test')"
"b3e2eb6aa7bdb29e60f32cd153612a2887164b70"

-- 這時已經存在
127.0.0.1:6379> script exists 'b3e2eb6aa7bdb29e60f32cd153612a2887164b70'
1) (integer) 1

-- 獲取多個返回列表 
127.0.0.1:6379> script exists 'b3e2eb6aa7bdb29e60f32cd153612a2887164b70' 'd1cb717b6f16ad4e798430f98c31bc449222b946'
1) (integer) 1
2) (integer) 0

複製代碼

根據sha1值使用evalsha執行腳本:

## 基礎執行命令

-- 根據給定的 sha1 校驗碼,執行緩存在服務器中的腳本
evalsha sha1 numkeys key [key ...] arg [arg ...]

### 參數說明:

① sha1     經過上面 script load 生成的 sha1 校驗碼

② numkeys  指定鍵名參數的個數

③ key      redis的鍵名

④ arg      附加參數,就是附帶進入腳本的變量值

複製代碼

演示示例:(使用時要注意,並非全部的腳本都適合緩存,形成沒必要要的內存浪費)

➜ ~ redis-cli --raw evalsha b3e2eb6aa7bdb29e60f32cd153612a2887164b70 0
你們好,我是阿沐!歡迎關注個人lua專欄!
複製代碼

⑤ 腳本日誌

有的時候咱們腳本出問題了,可是並不知道究竟是由於那一行代碼或者變量不對致使腳本中斷;我想大部分開發都會急躁,更有甚至者調試了半天一直看不出問題,會口吐芬芳等等。其實,在實際開發過程當中,咱們找不到問題所在的時候,必定要多打日誌,咱們只有經過日誌才能更好地找到問題所在,而不是一味的抱怨,抱怨解決不了任何問題。

Lua腳本中,能夠經過調用 redis.log 函數來將錯誤信息寫入 Redis 日誌(log),命令以下:

redis.log(loglevel, message)

### 參數說明

① loglevel  錯誤等級,跟咱們日常開發同樣,bug、提示、警告等等

② message   錯誤信息,跟咱們日常開發異常拋出信息一致

複製代碼

其中 loglevel 參數能夠是如下任意一個值:

redis.LOG_DEBUG     -- 會打印生成大量信息,適用於開發/測試階段

redis.LOG_VERBOSE   -- 包含不少不太有用的信息,可是不像debug級別那麼混亂

redis.LOG_NOTICE    -- 適度冗長,適用於生產環境

redis.LOG_WARNING   -- 僅記錄很是重要、關鍵的警告消息
複製代碼

注意:只有設置的錯誤等級大於等於redis實例日誌等級纔會被記錄下來

演示示例:

27.0.0.1:6379>  eval 'redis.log(redis.LOG_WARNING, "Something is wrong with this script.")' 0
(nil)

-- 在redis的日誌文件中查看:
1174:M 30 May 18:09:20.347 # Something is wrong with this script.
複製代碼

PS:這個經過日誌來看腳本問題,仍是比較重要的,若是不能一眼看出你腳本問題,那麼請儘可能的保證你多打點日誌查問題。

實戰講解

###  lua語言中如何實現原子腳本

package.path = package.path..";~/redis-lua/src/?.lua"  --redis.lua所在目錄

local json_encode = require "cjson" .encode
local redis = require("redis")

local reds, err = redis.connect('127.0.0.1',6379)

--- lua腳本檢測當前緩存值是否已 溢出 未溢出累加 不然 計算應增長多少值
local _introduce_myself = [[ local _key = KEYS[1] local cnt = ARGV[1] local limit = ARGV[2] local currnt_cnt = redis.call('GET', _key) currnt_cnt = tonumber(currnt_cnt) or 0 limit = tonumber(limit) or 0 cnt = tonumber(cnt) or 0 local ret = {"num", 0 ,"score", 0} if currnt_cnt < limit then local res = currnt_cnt + cnt if res >= limit then local diff = limit - currnt_cnt redis.call('INCRBY', _key, diff) ret[2] = limit ret[4] = diff else redis.call('INCRBY', _key, cnt) ret[2] = cnt end end return ret ]]

-- 執行lua腳本 
function execute_script()
    local key   = 'lua:test' -- 緩存key
    local count = 500        -- 每次增長數量
    local limit = 1000       -- 限制總數溢出狀況
    
    -- 執行腳本 更改執行緩存值,保證不超過限制的最大值 溢出則丟棄 
    local res , err = reds:eval(_introduce_myself, 1, key, count, limit)

    print(json_encode(res))
end

execute_script() -- 調用execute_script腳本函數
複製代碼

lua腳本的安全問題

根據官方所說:lua腳本內部變量禁止產生隨機參數,若是在集羣環境下,存在多主多從節點;當master節點執行完腳本之後,slave節點會一樣執行該腳本。

一旦腳本內部含有隨機值這種,就可能致使主從數據不一致;因此lua腳本會嚴格限制全部的腳本都無反作用

Redis 對 Lua 環境作了一些列相應的措施:

① 不提供訪問系統狀態狀態的庫

② 禁止使用 loadfile 函數

③ 禁止出現隨機性質命令
複製代碼

參考文檔

redisbook.readthedocs.io/en/latest/f… - 主要看腳本的安全性

各單位請注意:《Lua語言從入門到實戰》已經悄悄地進行中了!

相關文章
相關標籤/搜索