《玩轉Redis》系列文章主要講述Redis的基礎及中高級應用,文章基於Redis 5.0.4+。本文是《玩轉Redis》系列第【8】篇,最新系列文章請前往公衆號「zxiaofan」查看,或百度搜索「玩轉Redis zxiaofan」便可。python
本文關鍵字:玩轉Redis、簽到記錄、簽到日曆、簽到領京豆、用戶簽到表設計、位圖Bitmaps;git
大綱程序員
新建一張「用戶簽到記錄表(user_sign)」,核心字段以下:github
字段英文名 | 字段中文名 |
---|---|
keyid | 數據表主鍵(AUTO_INCREMENT) |
user_key | 京東用戶ID(全局惟一) |
sign_date | 簽到日期(如20200618) |
sign_count | 連續簽到天數(如2) |
# 查詢用戶小東(user_key="20200618-xxxx-xxxx-xxxx-xxxxxxxxxxxx")的連續簽到天數;
# 注意:sign_date BETWEEN '2020-06-17' AND '2020-06-18' 關聯的時間點是0時0分0秒,
# 因此此處SQL的時間點必須帶上時分秒;
SELECT
sign_count
FROM
user_sign
WHERE
user_key = '20200618-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
AND sign_date BETWEEN '2020-06-17 00:00:00'
AND '2020-06-18 23:59:59'
ORDER BY
sign_date DESC
LIMIT 1;
複製代碼
簽到用戶量較小時這麼設計或許勉強能行,但京東這個體量的用戶(估算3KW簽到用戶,一天一條數據,一個月就是9億數據),即便數據表按照月份分表,同時按照「用戶ID」進行hash分表(數據表示例爲「user_sign_202006_0」),數據存儲也是巨大的挑戰。關鍵是投入產出比過低,這種方式仍是say goodbye吧。redis
初級玩法一條簽到數據一條記錄,佔用了大量的存儲空間,咱們能夠從這裏優化一下。數據庫
# 用戶簽到記錄表user_sign_{h};
# 按照用戶ID hash分表,h是hash值;
CREATE TABLE `user_sign_h` (
`keyid` char(42) NOT NULL DEFAULT '' COMMENT '主鍵(簽到月份+用戶ID)',
`user_key` char(36) NOT NULL DEFAULT '' COMMENT '用戶ID',
`sign_month` char(6) NOT NULL DEFAULT '190001' COMMENT '簽到月份',
`sign_record` int unsigned NOT NULL DEFAULT '0' COMMENT '簽到記錄',
`sign_count` int unsigned NOT NULL DEFAULT '0' COMMENT '連續簽到天數',
`last_sign_date` char(8) NOT NULL DEFAULT '' COMMENT '上次簽到日期',
PRIMARY KEY (`keyid`),
KEY `index_user_id` (`user_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
複製代碼
表設計或許你有如下疑問,先思考一下吧,解析見文末Tips?數組
如下技術實現基於「表設計進階玩法(按位存儲)」;
keyid:由簽到月份+用戶ID生成。如用戶小東(user_key="19980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx"),其2020年6月份的簽到記錄keyid值是"20200619980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx",前6位是年月YYYYMM,後面36位是用戶ID;bash
# 查詢用戶當月簽到數據 @zxiaofan
SELECT
sign_record
FROM
user_sign_h
WHERE
keyid = 'xxxx';
複製代碼
# 查詢用戶連續簽到天數(服務器時間不是當月第一天) @zxiaofan
SELECT
sign_count
FROM user_sign_h
WHERE keyid = '當月xxxx';
複製代碼
# 查詢用戶連續簽到天數(服務器時間是當月第一天)@zxiaofan
SELECT
sign_month, sign_count
FROM user_sign_h
WHERE keyid in ('當月keyid','上月keyid');
複製代碼
# 本月第一天簽到SQL @zxiaofan;
# 新增一條簽到數據,連續簽到天數需判斷上月簽到記錄的最後一次簽到日期;
# 若上月最後一次簽到是月末,則連續簽到天數在上月基礎上加1;
# 若上月最後一次簽到不是月末,則將連續簽到天數直接置爲1;
INSERT INTO user_sign_h ( `keyid`, `user_key`, `sign_month`, `sign_record`, `sign_count`, `last_sign_date` )
VALUES
( '本月keyid', '用戶id', '202006', 1 << 1, 業務方計算好的連續簽到天數 , '20200601' );
複製代碼
# 本月非第一次簽到SQL @zxiaofan
# 本月第x天簽到,則 sign_record = sign_record | (1 << x);
# 1 << x 表示:1向左移動x位;
# 假設今日是 20200602,則昨天是 20200601;
UPDATE user_sign_h
SET sign_record = sign_record | ( 1 << 2 ),
sign_count = ( CASE last_sign_date WHEN '20200602' THEN sign_count WHEN '20200601' THEN ( sign_count + 1 ) ELSE 1 END ),
last_sign_date = '20200602'
WHERE
keyid = '10'
AND sign_month = '202006';
複製代碼
補籤須要更新對應日期的簽到記錄,計算並更新連續簽到天數;不是本文重點具體的技術邏輯就不贅述了。服務器
上述基於MySQL的進階解決方案,已能知足海量用戶的簽到業務。但咱們想再節省點存儲空間,再提高響應效率呢。
Bitmaps 閃亮登場。微信
Bit arrays (or simply bitmaps,咱們能夠稱之爲 位圖 ),Bitmaps並非一種實際的數據類型(好比Strings、Lists、Sets、Hashes這類實際的數據類型),而是基於String數據類型的按位操做。Bitmaps支持的最大位數是2^32位。
位圖本質是數組,數組由多個二進制位組成,每一個二進制位都對應一個偏移量(咱們能夠稱之爲 索引 )。
Bitmaps能夠極大地節省存儲空間,使用512M內存就能夠存儲多達42.9億的字節信息(2^32 = 4,294,967,296);
Bitmaps常見應用場景:
① 各類實時分析;
② 存儲大量與ID關聯的布爾值,且但願極致節省空間;
好比你想統計哪一個用戶訪問網站的天數最多;則能夠在用戶天天登陸時將對應天數的 bit位 設置爲1,使用BITCOUNT統計此用戶對應的字符串中爲1的位的數量,從而計算出其登陸天數。
一般咱們避免在Redis中使用大key,建議將大key拆分紅多個小key。常規建議是單key僅存儲1M信息,則可經過bit-number/M計算出key的名字,經過bit-number MOD M(MOD表示取餘)計算出第幾個bit位。假設 bit-number/M = 2,bit-number MOD M = 666,則對此位的操做實際是操做key名字爲「xxx:2」的key,位數是第666的位。
關於Bitmaps的使用其實在先前的文章中已經說起過了,能夠查看玩轉Redis系列文章之《玩轉Redis-Redis基礎數據結構及核心命令》,其中在「String位操做」這一節已經講過。此處咱們來複習一下:
【Bitmaps核心命令】:SETBIT、BITOP、GETBIT、BITCOUNT、BITFIELD、BITPOS;
命令 | 功能 | 參數 |
---|---|---|
SETBIT | 指定偏移量bit位置設置值 | key offset value【0=< offset< 2^32】 |
BITOP | 對一個或多個key執行邏輯操做,並將結果保存到destkey | operation destkey key [key ...]【AND, OR, XOR, NOT】 |
GETBIT | 查詢指定偏移位置的bit值 | key offset |
BITCOUNT | 統計指定字節區間bit爲1的數量 | key [start end]【@LBN】 |
BITFIELD | 操做多字節位域 | key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP/SAT/FAIL] |
BITPOS | 查詢指定字節區間第一個被設置成1的bit位的位置 | key bit [start] [end]【@LBN】 |
# 位圖Bitmaps位操做命令示例 @zxiaofan
# SETBIT、GETBIT、BITCOUNT、BITPOS 命令示例
// SETBIT 命令示例
127.0.0.1:6379> setbit bitkey 2 1
(integer) 0
127.0.0.1:6379> setbit bitkey 22 1
(integer) 0
// GETBIT 命令示例
127.0.0.1:6379> getbit bitkey 0
(integer) 0
127.0.0.1:6379> getbit bitkey 2
(integer) 1
// BITCOUNT 命令示例
// BITCOUNT、BITPOS的參數start、end指的是字節偏移量;
127.0.0.1:6379> bitcount bitkey 3 22
(integer) 0
127.0.0.1:6379> bitcount bitkey 0 0
(integer) 1
127.0.0.1:6379> bitcount bitkey 2 2
(integer) 1
127.0.0.1:6379> bitcount bitkey 0 2
(integer) 2
// BITPOS 命令示例
127.0.0.1:6379> bitpos bitkey 1 0 0
(integer) 2
// BITPOS 返回的是相對於第0 bit位的偏移量
127.0.0.1:6379> bitpos bitkey 1 2 2
(integer) 22
127.0.0.1:6379> bitpos bitkey 1 20 22
(integer) -1
複製代碼
# 位圖Bitmaps位操做命令示例 @zxiaofan
# bitop 命令示例
127.0.0.1:6379> setbit bkey1 0 1
(integer) 0
127.0.0.1:6379> setbit bkey1 1 1
(integer) 0
127.0.0.1:6379> setbit bkey1 5 1
(integer) 0
127.0.0.1:6379> setbit bkey2 0 1
(integer) 0
127.0.0.1:6379> setbit bkey2 3 1
(integer) 0
127.0.0.1:6379> setbit bkey3 1 1
(integer) 0
// bitop AND
127.0.0.1:6379> bitop AND dkey1 bkey1 bkey2 bkey3
(integer) 1
127.0.0.1:6379> getbit dkey1 0
(integer) 0
127.0.0.1:6379> getbit dkey1 1
(integer) 0
127.0.0.1:6379> get dkey1
"\x00"
127.0.0.1:6379> bitop AND dkey1 bkey1 bkey2
(integer) 1
127.0.0.1:6379> getbit dkey1 0
(integer) 1
127.0.0.1:6379> getbit dkey1 3
(integer) 0
// bitop XOR
127.0.0.1:6379> bitop XOR dkey1 bkey1 bkey2
(integer) 1
127.0.0.1:6379> getbit dkey1 0
(integer) 0
127.0.0.1:6379> getbit dkey1 5
複製代碼
1M數據能夠存儲1,048,576位(1 * 1024 * 1024 = 1,048,576),能夠存儲2870年的數據(1,048,57 / 365.25 = 2870.84)。
因此咱們使用1個key便可徹底儲存一個用戶的簽到數據。redis的key設計爲 sign:user_key,value存儲簽到記錄的位數組。
首先明確第0位表示哪一天的數據(數據基點),好比簽到產品是2000年1月1日上線的(數據基點就能夠是2000年1月1日),那麼第0位就表示2000年1月1日的簽到記錄。想要記錄2000年1月3日的簽到記錄,則先計算此時間點和數據基點的差值(差值爲2),則2000年1月3日的簽到記錄將存儲在第2位。其餘日期以此類推。
# 指定日期簽到,時間複雜度O(1) @zxiaofan
127.0.0.1:6379> setbit sign:user_key 2 1
複製代碼
經過get命令查詢指定用戶的全部簽到記錄,而後在內存中計數便可。指定時間段的簽到狀況或者連續簽到天數都可計算。
# 查詢指定key的全部簽到數據,時間複雜度O(1) @zxiaofan
127.0.0.1:6379> get sign:user_key
// 查詢指定日期是否簽到
// 先計算第二天期與數據基點的差值x
127.0.0.1:6379> get sign:user_key x
複製代碼
從上述來看,使用位圖Bitmaps實現簽到業務場景相對簡單很大,沒必要考慮跨月等問題,並且佔用的存儲空間也極小。那麼咱們還有優化空間嗎?
目前是1個用戶僅1條記錄,若是產品設計上不會存在跨年數據的操做,是否可考慮將簽到數據按年存儲呢,歷年數據在持久化後從Redis中清除從而節省Redis內存空間。固然不要爲了節省而拆分,若是致使業務邏輯變複雜,就得不償失了。
技術上11位的確足夠了,但技術都是爲業務服務的。若是使用簡單的數字,則競爭對手就能夠知道你的真實用戶數量、用戶增量狀況,這在商業上是確定不容許的。
業務邏輯仍是業務方作,在保證數據準確性的前提下,數據庫邏輯儘可能簡單。
auto_increment自增的確簡單省事,但keyid自行設計爲月份+用戶ID,直接根據keyid查詢指定用戶指定月份的簽到數據,這樣不香嗎。
只能說能夠實現以上產品邏輯,京東領京豆的實際產品邏輯更加複雜,好比,京東簽到領京豆有個頁面能夠看到「京豆領取明細」,包含精確到秒級別的領取時間,這點以上文章並未涉及,固然這也不是本文的重點。
每一個產品的背後都有着產品經理充分調研用戶需求、業務需求,架構、技術、運營等人員的通力合做。
心存敬畏。
京東的簽到日曆僅展現了當前月份的數據,支付寶會員簽到的最大連續簽到天數是7天,CSDN的簽到次數僅保留3個月。
爲什麼不展現一年的數據呢?在商言商,不展現的重要緣由固然是商業價值不足,投入產出比不高。技術上能夠實現,但技術須要爲業務服務、爲產品服務。
因爲String數據類型的最大長度是512M,因此String支持的位數是2^32位。512M表示字節長度,換算成位須要乘以8,即512 * 2^10 * 2^10 * 8=2^32;
Strings的最大長度是512M,還能存更大的數據?固然不能,可是咱們能夠換種實現思路,文中其實已說起,咱們回顧下:將大key拆分紅多個小key。常規建議是單key僅存儲1M信息,則可經過bit-number/M計算出key的名字,經過bit-number MOD M(MOD表示取餘)計算出第幾個bit位。假設 bit-number/M = 2,bit-number MOD M = 666,則對此位的操做實際是操做key名字爲「xxx:2」的key,位數是第666的位。
按照這種思路,存儲的大小徹底不受限啦。
玩轉Redis系列文章:
《玩轉Redis-老闆帶你深刻理解分佈式鎖》
《玩轉Redis-研發也應該知道的Connection命令》
《玩轉Redis-Redis高級數據結構及核心命令-ZSet》
祝君好運!
Life is all about choices!
未來的你必定會感激如今拼命的本身!
【CSDN】【GitHub】【OSCHINA】【掘金】【語雀】【微信公衆號】