03.01 子彈短信內部技術分享:Redis

原理

Redis 是一個內存型「數據庫」,除存儲之外,它還有許多強大的命令,使之遠遠超出了數據庫的定義,所以官方稱之為「data structure store」,數據結構存儲系統。 通過 Redis 提供的指令,我們可以實現緩存、消息隊列、事件通知、排行榜、庫存管理、分佈式鎖等功能。

基礎結構

Redis 核心是單進程單線程服務,通過 epoll、select 等實現了 IO 多路複用,可以併發處理網絡事件。

數據結構

Redis 提供了以下幾種典型的數據結構

strings

Redis 實現了名為 SDS(Simple Dynamic String) 的字符串類型,與 C 字符串區別:

  1. 實現字符串拼接,減少內存重分配
  2. 維護了字符串的長度,以便快速獲取及避免緩衝區溢出
  3. 二進制安全,即支持存儲空格(\\0)

linkedlist

Redis 實現了雙向無環鏈表,並使用此數據結構實現了 list。

Hashtable

Redis 實現了符合自身使用場景的 HashMap,即數組加鏈表的實現。此數據結構實現了 Redis 中的 Hash、Set 數據類型。特點如下:

  1. 使用 MurmurHash3 Hash 算法,針對規律性強的字符串有更好分佈性。
  2. 新節點插入到表頭而非表尾,因為緩存一定程度上會存在,「後加入的緩存會比先前加入的緩存更容易被訪問」的特點。
  3. 漸進式 rehash。Redis 數據庫本身是個巨大的 Hash 表,每次 rehash 要操作幾百上千萬的 key,漸進式 rehash 則是其中必不可少的保障。 rehash 的方式是維護兩張表和索引,需要 rehash 時將 rehashIndex 置為 0,然後每次除 insert 操作外,都會將 oldTable 的 rehashIndex 中數據轉移到 newTable 中,直到 rehashIndex == oldTable.length() - 1,再將 rehashIndex 置為 -1,rehash 完成。

skiplist

跳躍表通過給鏈表分層,實現了平均 O(logN),最壞 O(N) 的時間複雜度。Redis 使用該數據結構實現了 Sorted Set 數據類型。另外 Sorted Set 中還需要使用 HashTable 來實現 O(1) 的查詢。

intset

整數集合,即只保存整數的集合。Redis 使用該數據結構實現了 Set。

ziplist

壓縮列表。壓縮列表是一種犧牲性能節約空間的數據結構,相比鏈表,它節約了指針的空間,Redis 將它作為 List、Hash、Sorted Set 的實現,並使用 hash-max-ziplist-entries(512)、hash-max-ziplist-value(64)、list-max-ziplist-size(8 Kb)、zset-max-ziplist-entries(128)、zset-max-ziplist-value(64) 配置來決定是否使用 ziplist。

持久化

不論是內存型的數據庫還是關係型數據庫,宕機、停電後數據無法恢復都是不可接受的。Redis 有兩種備份數據的方式:

AOF

即 Append-Only-File,當開啟備份時,Redis 會創建出一個默認名稱為 appendonly.aof 的文件。並將內存中所有數據以命令的形式寫入文件中,後續執行新的操作數據的命令時,會放入緩衝區中定時寫入文件(appendfsync 不為 always 時)。 在 redis.conf 中用以下參數配置 AOF 策略:

appendonly yes/no 是否開啟 AOF 模式
appendfilename appendonly.aof
appendfsync always/everysec/no #寫入磁盤時機,always 表示每次都會同步到磁盤,由於是同步操作,性能下降嚴重。everysec 表示每秒刷盤。no 表示只放入緩存區中,由操作系統指定刷盤時機(Linux 一般是 30 秒)

當我執行了以下命令時:

set liuzhiguo 123
set liuzhiguo abc
set liuzhiguo 456
set liuzhiguo 1231 ex 30

AOF 文件長這樣:

*2 \t\t消息行數
$6 \t\t第一條消息長度
SELECT 消息內容
$1 \t\t第二條消息長度
0\t\t消息內容
*3\t\t
$3
set
$9
liuzhiguo

$3
123
*3
$3
set
$9
liuzhiguo
$3
abc
*3
$3
set
$9
liuzhiguo
$3
456
*3
$3
set
$9
liuzhiguo
$4
1231
*3
$9
PEXPIREAT
$9
liuzhiguo
$13
1544420872751

可以看出 AOF 模式是直接將命令寫入文件中,所以在恢復數據時,Redis 會逐條執行命令來恢復數據。所以 AOF 模式恢復數據的效率並不高,而且當重複對一個 key 進行操作時,也需要執行所有操作命令。 針對同一數據重複操作的問題,Redis 提供了 AOF 重寫的功能,即丟棄原有的 appendonly.aof 文件,重新將內存中的數據作為命令寫入文件中。

RDB

即 Redis DataBase,此持久化模式默認開啟。 開始備份時,Redis 會 fork 出一個子進程(bgsave),創建默認名為 dump.rdb 的二進制文件,逐個對內存中的數據進行備份。每次備份時都會拋棄原有的 RDB 文件,重新將數據全量備份。 對於備份的時機,在 redis.conf 有以下選項來觸發備份:

save 900 1\t\t900 秒內有 1 次變動
save 300 10\t\t300 秒內有 10 次變動
save 60 10000\t60 秒內有 10000 次變動

RDB 由於體積和天然的指令壓縮能力,恢復數據速度要大大快於 AOF。但是因為每次只能全量備份,資源消耗比 AOF 大,不如 AOF 靈活。並且因為備份時機的不確定性,數據完整不如 AOF。

RDB-AOF

Redis 在 4.0 之後提出了 RDB-AOF 混合模式持久化,可以在 redis.conf 中通過 aof-use-rdb-preamble 選項開啟。 此模式下,全量備份、重寫 AOF 時會使用 RDB 格式,隨後執行命令還是以 AOF 的格式追加到文件中。

這樣一來,恢復數據時性能比單純 AOF 強,全量備份比 AOF 快,備份體積比 AOF 小,部分備份性能比 RDB 高。

高可用

Redis 通過哨兵(Sentinel)與複製的方式實現了高可用

複製

通過在 redis.conf 文件中配置「slaveof ip port」或給運行中的 redis 節點執行命令「slaveof ip port」,即可使得該節點成為某個 redis 實例的從節點。

從節點(slave)啟動時會向主節點(master)發送 sync 指令,主節點使用 bgsave 方法生成 RDB 文件,並建立緩衝區記錄寫命令。RDB 文件生成會即發送給從節點,從節點開始載入 RDB 文件,此動作同步執行。 從節點完成載入後,主服務器會將緩衝區的記錄發送給從服務器,此後主節點每當有執行命令時,都會傳播給從節點一份。

斷線重連後,從節點再次上線時會向主節點發送 psync 命令執行部分重同步,主節點會將此期間的命令發送給從節點執行。為實現此功能,主從節點維護了「複製偏移量」。

使用 info 可以查看複製的狀態:

# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6380,state=online,offset=280,lag=0\t\t// 從節點信息
master_replid:6088224db78515c7c2cbef387fb90cefd459f0d5
master_repl_offset:280\t\t\t\t\t\t\t\t\t\t\t// 主節點偏移量
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:280
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:1\t\t\t\t\t\t\t\t\t// 與主節點 1 秒前同步
master_sync_in_progress:0\t\t\t\t\t\t\t\t\t\t// 是否在進行 sync 同步
slave_repl_offset:280\t\t\t\t\t\t\t\t\t\t\t// 從節點偏移量
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:6088224db78515c7c2cbef387fb90cefd459f0d5
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:280
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:280

Sentinel

為實現高可用,只有複製是不夠的,還需要主節點服務不可用後,從節點能自動補位。 Redis 通過 Sentinel 來實現節點監控與協調,Sentinel 是一個特殊的 Redis 節點,需要啟動時指定參數 --sentinel 和 sentinel.conf 配置文件,並在配置文件中指定主節點的 ip、host。 Sentinel 啟動後會向主節點發送 info 命令,獲取到相應的從節點信息,並與從節點建立連接。 當主節點不響應時,Sentinel 會等待至配置中指定的 timeout 時間,隨後將從節點提升為主節點。主節點再次啟動時,Sentinel 會向主節點發送 slaveof 命令,要求其成為從節點。

Sentinel 本身同樣支持高可用,多個 Sentinel 會向每個主從節點 publish 自己的信息,以此來得知其他 Sentinel 的存在並建立連接。多個 Sentinel 共存時,對主從節點狀態、身份的共識會有更復雜的協調過程,這就是另外一個漫長的故事了。

Redis 因為是內存型數據庫,在存儲空間上容易捉襟見肘,於是產生了許多擴容方案。

客戶端分片

如 ShardedJedis,通過在客戶端對 key 進行 hash,再分給指定的節點。 優點:無需改動 Redis 即可擴容 缺點:只能擴容一次,無法平滑升級

代理層分片

如 Twemproxy。代理層接收客戶端的請求,代理到對應的 Redis 節點上,通常也是使用一致性 hash 來分片。並由於代理層可以統一配置或讀取同一數據源,做到可拓展代理層。 優點:客戶端無需關心 Redis 服務狀態,也無需分片。 缺點:難以擴容。

Redis Cluster

Redis 自己實現的集群,可實現無痛擴容,平滑遷移。啟動集群模式需要在配置文件中配置:

cluster-enabled yes
cluster-config-file nodes.conf

cluster-node-timeout 1500

集群模式下,會創建出 16384 個槽,並給集群中每個節點分配自己的槽數,槽必須被全部指定才能工作,一個節點最低指定一個槽。所以 Redis 集群理論上最大是 16384 個節點。

當需要添加/獲取某個 key 時,通過 crc16(key) & 16384 得到這個 key 應在的槽,隨後找出這個槽所在的節點,如果節點是自己直接執行,否則會返回給客戶端對應的節點的 ip + port。

Redis 集群是去中心化的,彼此之間狀態同步靠 gossip 協議通信,集群的消息有以下幾種類型:

  1. Meet。通過「cluster meet ip port」命令,已有集群的節點會向新的節點發送邀請,加入現有集群。
  2. Ping。節點每秒會向集群中其他節點發送 ping 消息,消息中帶有自己已知的兩個節點的地址、槽、狀態信息、最後一次通信時間等。
  3. Pong。節點收到 ping 消息後會回覆 pong 消息,消息中同樣帶有自己已知的兩個節點信息。
  4. Fail。節點 ping 不通某節點後,會向集群所有節點廣播該節點掛掉的消息。其他節點收到消息後標記已下線。

由於去中心化和通信機制,Redis Cluster 選擇了最終一致性和基本可用。例如當加入新節點時(meet),只有邀請節點和被邀請節點知道這件事,其餘節點要等待 ping 消息一層一層擴散。除了 Fail 是立即全網通知的,其他諸如新節點、節點重上線、從節點選舉成為主節點、槽變化等,都需要等待被通知到。

因此,由於 gossip 協議,Redis Cluster 對服務器時間的要求較高,否則時間戳不準確會影響節點判斷消息的有效性。另外節點數量增多後的網絡開銷也會對服務器產生壓力。因此官方推薦最大節點數為 1000。

優點:

  1. 真正的彈性擴容縮容。
  2. 擴容期間不影響使用。

缺點:

  1. 缺乏管理平臺。
  2. 客戶端要另做兼容。
  3. 部分命令不支持

Redis 通過 Cluster 解決了擴容之後,客戶端該怎麼使用呢? 如 JedisCluster,每次請求前會拉取節點的 cluster info 來計算應該到哪個節點請求,並需要對錯誤節點返回的 ASK 消息做相應的處理。由此產生的問題是

  1. 每次操作最少請求兩次。
  2. 每次如果只請求某一個節點,也會形成單點壓力。

對問題 1,解決辦法是客戶端緩存集群狀態。對問題 2,JedisCluster 支持配置多個節點,拉取節點信息時會隨機選擇某節點以分攤壓力。對問題 2 的處理方式,需要將 Redis 節點信息同步到客戶端配置中,產生了耦合。

另外的問題是,集群狀態下是不支持 mget、mset 等需要跨節點執行的命令。該問題的解決方案是加一層 Proxy ,其使用 Nginx + Redis Cluster 的思路令人讚歎,並用請求聚合的方式實現了跨節點執行命令的問題。

阿里雲提供的 Redis 服務同樣實現了集群模式下的跨節點命令,採用代理 + 分片服務器 + 分片配置服務器(很可能是 zookeeper),但是沒有使用 Redis Cluster 機制,而是自己實現的「分片」,保留了 slot。阿里雲的 Redis 好處是集群版無需客戶端做兼容,可以當成單機 Redis 使用,出了問題方便甩鍋。

第三方魔改 Redis

在等待 Redis 出官方集群方案之前,人們迫不及待想要集群版的 Redis,一些不滿於現狀以及不滿於 Redis Cluster 實現的人們開始對 Redis 進行改造。前面提到的阿里雲 Redis 也屬於魔改後的 Redis。

Codis

Codis 幾乎是最知名的第三方 Redis,對 Redis 進行了大量改造。 其架構為 zookeeper + proxy + server-group(master + slave),並提供了控制檯以便可視化運維。

通過 zookeeper 記錄可用的 proxy 節點,再使用 Codis 開發組基於 Jedis 修改的 Jodis 客戶端到 zookeeper 中尋找可用的 proxy 節點進行調用。如果使用的是 jedis 或其他客戶端,則只能到連接一個 proxy,或者想辦法連接到 zookeeper 獲取節點,再進行輪詢調用。

Codis 支持彈性擴容,分片方式與 Redis Cluster 類似,通過 crc32(key) % 1024 分成 1024 個槽,每臺實例保存對應槽的數據。

LedisDB 和 SSDB

LedisDB 和 SSDB 非常相似,都是用 LevelDB 底層,重新實現了 Redis,或者說只實現了 Redis 協議。通過多線程 + 硬盤的方式,實現了和單機 Redis 相似的 QPS 性能,並可以很大程度上對容量進行擴容。 LedisDB/SSDB 與 Redis 的關係,相當於 TiDB 與 MySQL 的關係。

缺點是出了容量上的成本優勢,其他沒有任何優勢。

事務

Redis 提供 watch、multi、exec 等方法實現樂觀鎖事務。使用事務的流程如下:

  1. watch key1 key2
  2. multi 開啟事務
  3. set key1 value1、set key2 value2,將指令入隊。
  4. exec,執行指令。

如果 multi ~ exec 之間 key1/key2 被其他客戶端修改過,exec 時會返回 nil, set key1 value1、set key2 value2 均不會執行。 Redis 會保存一個 watch_keys 字典,結構為: client -> keys、is_dirty。Redis 在處理每一個會修改數據的命令時,會檢查 watch_keys 是否存在該 key,如果有,則修改 is_dirty 為 true。

執行事務的客戶端在執行 exec 時,會檢查 is_dirty 字段,如果發現為 false,所有的積累的指令會直接丟棄不執行。

事務在 Redis 中的使用場景不多,併發量大的情況下需要反覆重試,大部分情況下有更好的使用方式:

Lua

Redis 提供了對 Lua 腳本的支持,原子性執行一系列指令,並可以寫代碼做邏輯判斷。 例如需要大量插入數據的場景:

for i=1,10000000,1 do
local num = math.random(1000000,999999999);
redis.call("set",num,i)
end

執行一千萬條命令在本機大概用了 12 秒,QPS 83w。 Redis 在執行 Lua 腳本時是單線程,無法處理其他請求,這也是 Redis 原子性的原因。下面是搶紅包時利用該特性實現的 Lua 腳本:

// 該腳本傳入 4 個參數
// KEYS[1] = 未領取的紅包列表 key
// KEYS[2] = 已領取的紅包列表 key
// KEYS[3] = 紅包已領取人ID列表 key
// KEYS[4] = 領取人ID
// 檢查領取人是否在已領取列表內
if redis.call('hexists', KEYS[3], KEYS[4]) ~= 0 then
\treturn nil
else
\t// 取出一個未領取的紅包
\tlocal redEnvelop = redis.call('rpop', KEYS[1]);
\tif redEnvelop then
\t\t// 紅包中的 receiver 填入領取人 ID
\t\tlocal x = cjson.decode(redEnvelop);
\t\tx['receiver'] = KEYS[4];
\t\tlocal re = cjson.encode(x);
\t\t// 領取人放入已領取人ID列表,將紅包放入已領取紅包列表
\t\tredis.call('hset', KEYS[3], KEYS[4], KEYS[4]);
\t\tredis.call('lpush', KEYS[2], re);
\t\t// 給相應的 key 續期
\t\tif redis.call('llen', KEYS[2]) == 1 then
\t\t\tredis.call('expire', KEYS[2], 172800);
\t\tend
\t\tif redis.call('hlen', KEYS[3]) == 1 then
\t\t\tredis.call('expire', KEYS[3], 172800);
\t\tend
\t\treturn re;
\tend
end
return nil

需要注意的是,由於 Lua 腳本只能在單個 Redis 實例執行,所以在集群狀態下執行 Lua 時,Redis 會對要執行的 key 進行檢查。為了保證所有 key 一定在某一臺機器上,Redis 限制了所有 key 都必須在同一個 slot 內才行。

所以針對紅包的場景,對 Lua 中傳入的 key 做了xxx{redpacketId}的處理,以保證所有 key 落在一個 slot 上。

管道(pipeline)

Redis 支持使用管道批量執行命令,再統一返回,減少往返次數,通常用於批量插入數據,批量獲取數據。

實戰

緩存

緩存方式

緩存是 Redis 最常見的場景。通常緩存的過程為:

  1. 未命中:從數據源中取得數據,放入緩存中。
  2. 命中:返回數據。
  3. 更新:先把數據存入數據庫,再使緩存失效。

不推薦更新數據時同時更新到緩存,因為可能併發更新導致髒數據。但刪除緩存並不是完全不會導致髒數據,只是概率會相對小很多。

批量查詢

查詢時可能會需要類似 where id in (xx,yy,zz) 的情況,這時查詢緩存可以使用 mget 同時查詢多個 key,可以大大提高效率。下面是 benchmark 數據:

get 81833.06 requests per second\t
mget 10 73475.39 requests per second\t\t 734,753
mget 20 64226.07 requests per second\t\t 642,260\t\t\t
mget 30 59559.26 requests per second\t\t1,786,770\t\t\t99% < 1 milliseconds
mget 50 48995.59 requests per second\t\t2,449,750\t\t\t99% < 1.5 milliseconds
mget 100 29214.14 requests per second\t\t2,921,414\t\t\t99% < 2.5 milliseconds
mget 200 16730.80 requests per second\t\t3,346,000\t\t\t99% < 3 milliseconds
mget 500 7222.30 requests per second\t\t3,611,150\t\t\t99% < 9 milliseconds

根據總獲取數據個數、平均響應時間,通常認為 mget 數量控制在 100 以下是比較均衡的。

按每次 mget 100 與 get 相比,性能相當於提高了 35 倍。再加上跨機器調用往返的時間消耗,實際情況性能提升很可能 100 倍以上。

分佈式鎖

Redis 可以通過 SET key randomValue NX EX 30 給某個 key 賦值,並同時判斷 key 是否存在,以及給定過期時間。過期時間要根據業務變化。

釋放鎖可以直接 del 掉這個 key。但是 del 是有風險的:

例如 A 獲取到鎖,過期時間 30 秒。因為某些原因 30 秒沒能處理完請求,B 過來也獲取到了鎖。此時 A 處理完執行釋放鎖的操作,就會釋放掉 B 所持有的鎖。

為了避免這個問題,需要判斷 value 是不是 set 時的 value,如果是才執行 del 操作。為了讓這兩條命令原子性執行,需要使用到 lua 腳本:

- KEYS[1] 為 鎖名稱,ARGV[1] 為鎖內容, 即 set 時的 randomValue
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

另外在 spring-data-redis 的實現中,是沒有 set nx ex 的,所以需要找到 Jedis 或者 Lettuce 調用原生方法。

計數器

Redis 另一個值得稱道的命令就是自增了,其提供了 incr/incrby/incrbyfloat(string)、hincrby/hincr(hash)、zincrby(zset)方法供不同數據類型使用。

通過這些命令可以實現對庫存的扣減,記錄接口訪問頻次,記錄一篇文章的點贊數、評論數、轉發數,搶紅包扣減數量等。

排行榜

利用 zset 有序列表,比如要計算用戶積分排行榜:

  • zadd/zincrby 保存或自增用戶的積分
  • zrevrank 獲取用戶的排名
  • zscore 獲取用戶的積分
  • zrevrange 獲取排行

消息隊列

利用 list 的 lpush(Left Push) 和 brpop(Blocked Right Pop) 接口可以實現消息隊列功能:

  1. 將消息 lpush 到隊列中。
  2. 所有實例通過 brpop 監聽隊列並取出數據進行消費。

在消費的過程中可以通過配置線程池,根據業務情況決定消費速率。

異步延遲合併隊列

比如秒殺、搶紅包時,庫存數據需要異步入庫。但僅僅異步入庫也是不夠的,並不會減少對數據庫操作的次數。這時候可能需要將 100 次請求壓縮成一次請求,只取最後的數據落庫。

此類需求則可以用 zset + list 實現,我們需要幾個東西:

  1. 需要延遲執行的任務放入 zset 列表中,score 為需要執行的時間戳。
  2. 後臺起一個線程每秒鐘拉取 zset ,執行 zrangeByScore, score 範圍為 0 ~ 當前時間戳,如果取到數據則放入執行隊列 list 中,最後 zrem 查出來的數據。
  3. 監聽 list 隊列的執行器,此時開始執行任務。

這也是紅包中的異步更新的實現方式。在搶群紅包時,如果每次都更新數據庫中的數據,勢必會增加響應時間。使用這種更新方式的話,只在最後一次搶紅包的 30s 後更新,30s 之內發生的數據更新,都只會合併為 1 條。

事件通知

Redis 提供了publish、subscribe 等命令實現了廣播功能,publish 時可以將消息通知到某個頻道(channel),此時 subscribe 了這個頻道的節點均能收到消息。

通過這個機制我們能做到對全節點的事件通知。

比如在積分系統中會將所有活動、抽獎、簽到、搖錢樹等數據庫配置數據放入 JVM 緩存中,以便獲得最高的性能。 為了更新數據,一開始是每分鐘到數據庫更新一次。但問題是每臺實例更新的時機都不同,導致請求到 A 實例的數據,與 B 實例上的不同。隨後將定時任務的配置改成了每分鐘的第 0 秒執行,則很大程度上改善了問題。

但是輪詢的方式仍然不夠優雅,絕大部分時候取得的配置並沒有變化,是無用的請求。更新配置的時機應該是配置發生了變更才對。

這時就可以使用 Redis 廣播,每當數據庫數據發生變化時,通過廣播通知所有節點更新數據,或者乾脆將要更新的數據放入廣播中。

優化

Redis 雖然性能強悍,但是由於單線程的特性,一旦產生慢查詢,會將所有操作都阻塞住。所以使用上仍需要注意會踩哪些坑。Redis 提供了 slowlog get 查看慢查詢。

常見雷區

  • keys * keys 命令的時間複雜度是 O(n),n 是 Redis 中所有鍵的數量,這個是最常見的性能最差的命令。一般線上都把這個命令 block 掉(在配置中加 rename-command KEYS "")
  • 大 key。一個 key 裡存儲的數據越多,通常性能越差,比如對超大的 List 進行 lindex 和 lrange。另外大 value 在集群數據遷移時會阻塞可能導致 fail over。甚至在刪除時也會阻塞,例如刪除一個 1kw 數據量的 set,需要耗時 5s。或者在集群中大 key 會導致集群內存分配不均勻。所以在使用時需要避免在一個 key 中放入過多數據。
  • bgrewriteaof、bgsave,重寫 aof 文件及備份 RDB 文件時,會 fork 出子進程和內存,此期間是阻塞的,取決於 Redis 內存大小和機器性能。所以許多企業的做法是主節點上關閉 aof 和 rdb,只在從節點上備份。

大 key 的拆分

積分系統中存在一個進貢的任務,邀請人可獲得被邀請人做任務的獎勵,並在每天凌晨入賬。

對於這個任務,我們做的第一步優化就是每天將獲得了進貢獎勵的用戶,保存在 set 裡,通過 sscan 遍歷需要進貢的用戶,執行任務。以此避免了掃庫,保證每次取得的 userId 都是確切有效的。問題在於萬一子彈短信火了,set 中的 userId 會原來越多,也就遇到了大 key 的問題,需要將 set 拆分為多個 set。

拆分的思路和 Redis 集群分片類似,通過 hash(userId) % count 的方式,得到 0 ~ count 之間的分片數,將其加到原本的 key 上,過程如下:

  1. 通過 hash(userID) % count,得到分片數,如 16
  2. 原本 key 為「TRIBUTE:USER:SET:20181225」,再加上分片數即得到「TRIBUTE:USER:SET:20181225:16」,再將 userId sadd 放入即可
  3. 取出所有 key 時 for 循環從 0-count 拼到 key 上,再針對每個 key sscan。

使用 Hash

例如保存一篇文章的點贊數、轉發數、評論數時,既可以保存為 3 個 value,即 article:like、article:repost、article:comment。也可以保存為一個 hash 對象,key 為 article,hashKey 為 like、repost、comment。

好處:

  1. 通過一條 hgetall 就能取得所需數據
  2. 節約內存。

使用 value :

# lua
for i=1,1000000,1 do
redis.call("set","article:like:"..i,1)
redis.call("set","article:repost:"..i,1)
redis.call("set","article:comment:"..i,1)
end
# memory
used_memory:226568704
used_memory_human:216.07M
used_memory_rss:282144768
used_memory_rss_human:269.07M

使用 hash :

# lua
for i=1,1000000,1 do
redis.call("HMSET","article:"..i, "like", 1, "repost", 1, "comment", 1)
end
# memory
used_memory:121402896
used_memory_human:115.78M
used_memory_rss:132640768
used_memory_rss_human:126.50M

value 幾乎多使用了一倍內存。原因是 hash 類型這時會選擇 ziplist 數據結構實現。

關注我:私信回覆“555”獲取往期Java高級架構資料、源碼、筆記、視頻Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式、高併發等架構技術往期架構視頻


分享到:


相關文章: