對投票數據統計的時候發現了Redis Hash類型的一個大坑,單個key中field過多,導致取不出來。特記錄下嘗試解決和探索的過程。
第一階段:問題描述
一個投票類的產品,對單個選項mid投票成功后,記錄了總票數,還記錄了用戶投票日志(可以理解成投票明細),用的都是Redis Hash類型來存儲。投票日志的存儲格式如下:
1
|
$redis
->hset(
'vote_log'
,
'uid|mid|timestamp'
, 1);
|
最近,運營需要統計固定時間段每個選項投票的用戶數,要的比較急,就很快寫了一個腳本
算法:
0、全部取不來
1、第一次遍歷,篩選出mid
2、第二次遍歷,篩選出每個mid對應的uid
3、去重
4、統計數量
運行就報錯:
腳本的使用內存已經設定為1024M了,查看Redis.php中163行使用的是hGetAll獲取全量數據,估計是數據太大,登陸上Redis 使用hlens顯示2700W,確實太大了,改為hKeys獲取還是一樣報錯。Goolge搜索了一番,發現唯一的解決方案就是使用hscan, 測試發現線上redis server版本2.4不支持hscan(hscan命令版本要求>=2.8), 頓時都絕望了,跟運營反饋數據存儲集中,並且數量比較大,傳統的方案統計不出來,需要時間用其他方式來處理。好在,運營了解情況后,表示可以不用統計用戶數。
第二階段:探索解決方案
但是技術上這個問題並沒有解決,如何解決這個問題呢
目前分析思考結果如下:
0、使用內存足夠的機器跑腳本
1、使用hscan[自己的阿里雲上redis server version 3.2.11測試了hscan確實可以分頁取數據]
第一種:升級redis server version到2.8以上
第二種:導出redis key,導入高版本redis server中
具體實施的解決方案,還在探索中…(redis hash key導出在嘗試中,如果大家有好的建議,歡迎留言)
針對hscan這里有一個地方需要格外注意(scan不存在這個問題)
觀察下面幾條命令,我們看到vote_info中現在有4個鍵值對,但是我們設置hscan的count為2,還是返回了全部內容,並不是預期的2條
我們知道Redis Hashes是由ziplist(壓縮列表)和字典(Dict)兩種編碼方式實現,當我們創建一個空的Hashes的時候使用的ziplist編碼, 當某個鍵或某個值的長度大於hash_max_ziplist_value設定的值,會切換的Dict編碼,還有一種情況也會切換就是ziplist的entries(節點數)大於hash_max_ziplist_entries。hash_max_ziplist_value和 hash_max_ziplist_entries在redis.conf中設置,默認值分別是512和64。
hash-max-zipmap-entries 512 (hash-max-ziplist-entries for Redis >= 2.6)
hash-max-zipmap-value 64 (hash-max-ziplist-value for Redis >= 2.6)
查看redis scan 文檔,Hashes使用ziplist編碼的時候,通常忽略count參數,直接返回全部元素。
打開redis.conf, 把hash_max_ziplist_entries修改為10,hset多個元素,直到hlen為11的時候,count才生效,觀察下面一組命令
按照上面的實驗,ziplist中一對key-value算一個entries,沒有找到理論說明, 參考《Redis設計與實現第一版》中的結構
測試遺留問題:當hdel一條記錄后,hscan的count選項還是生效,返回的數量也有異常,暫未找到原因
第三階段:優化存儲結構,避免問題
去年PHP開發者大會上,記得鳥哥說,避免問題也是一種解決問題的好辦法。
如何存儲來避免這種問題呢
方案一:存儲key的優化, 按照mid來拆分key
1
|
$redis
->hset(
'vote_log_mid'
,
'uid|timestamp'
, 1);
|
方案二:存儲key的優化,按天來存
1
2
3
4
5
|
// 需要區分投票選項
$redis
->hIncrBy(
'vote_log_mid_20180718'
,
'uid'
, 1);
// 不需要區分投票選項
$redis
->hIncrBy(
'vote_log_20180718'
,
'uid'
, 1);
|
方案三:redis2mysql, 直接存到mysql中
–表結構
1
2
3
4
5
6
7
8
9
10
11
12
|
CREATE
TABLE
`vote_log` (
`id`
int
(11) unsigned
NOT
NULL
AUTO_INCREMENT COMMENT
'自增ID'
,
`aid`
int
(11) unsigned
NOT
NULL
DEFAULT
'0'
COMMENT
'活動ID'
,
`feed_id`
int
(11) unsigned
NOT
NULL
DEFAULT
'0'
COMMENT
'feed id'
,
`mid`
bigint
(11) unsigned
NOT
NULL
DEFAULT
'0'
COMMENT
'mid'
,
`uid`
bigint
(11) unsigned
NOT
NULL
DEFAULT
'0'
COMMENT
'uid'
,
`create_time`
bigint
(11) unsigned
NOT
NULL
DEFAULT
'0'
COMMENT
'投票時間'
,
`ext`
varchar
(64)
NOT
NULL
DEFAULT
''
COMMENT
'擴展字段'
,
PRIMARY
KEY
(`id`),
KEY
`key_a_f_c` (`aid`,`feed_id`,`create_time`),
KEY
`key_a_f_m` (`aid`,`feed_id`,`mid`)
) ENGINE=InnoDB
DEFAULT
CHARSET=utf8 COMMENT=
'投票日志'
;
|
結合實際情況,雖然方案一和方案二改造起來很方便,這個日志數據並不需要實時讀取,放在Redis中有對Redis誤用亂用的嫌疑,也並不方便運營未知的統計需求。存到mysql中開始考慮到按照日期或者活動id來分表,查看發現不是每個月都有這樣的投票活動,也不是每個活動都有投票,完全可以存一張表,等到時候數據太大了,可以寫腳本清歷史數據,或者手動清。
以上,選中了方案三。
其他已知的方案:
拆分key, 參考《Redis單key值過大,優化方式》
單獨存一份field,參考《Redis Hash結構遍歷某一個key下所有field value的方法》
第四階段:思考總結
在解決問題的過程中,發現很多朋友遇到了類似的問題,確實值得我們深思,在當初設計存儲的時候,必須要考慮到這種情況,最好的解決辦法還是設計階段提前預判和規避,世界杯決賽上解說說的,追不上姆巴佩,提前預判他的路線,打斷他的進攻,不給發揮速度的機會,這也是架構設計的意義吧。
最后補充一個有趣的發現,刪除hash總最后一個field后,hash key也會被刪除
1
2
3
4
5
6
7
8
9
10
|
redis 10.235.25.242:6379> hmset salmonl_20190514
id
100
type
1
OK
redis 10.235.25.242:6379> hdel salmonl_20190514
id
(integer) 1
redis 10.235.25.242:6379> exists salmonl_20190514
(integer) 1
redis 10.235.25.242:6379> hdel salmonl_20190514
type
(integer) 1
redis 10.235.25.242:6379> exists salmonl_20190514
(integer) 0
|
(全文完)
參考資料:
https://redis.io/topics/memory-optimization
https://redis.io/commands/scan#the-count-option
http://origin.redisbook.com/compress-datastruct/ziplist.html#id2
https://stackoverflow.com/questions/34503876/redis-hscan-command-cannot-limit-the-counts