玩轉Redis-京東簽到領京豆如何實現

《玩轉Redis》系列文章主要講述Redis的基礎及中高級應用,文章基於Redis 5.0.4+。本文是《玩轉Redis》系列第【8】篇,最新系列文章請前往公衆號「zxiaofan」查看,或百度搜索「玩轉Redis zxiaofan」便可。python

本文關鍵字:玩轉Redis、簽到記錄、簽到日曆、簽到領京豆、用戶簽到表設計、位圖Bitmaps;git

大綱程序員

  • 京東簽到日曆的產品邏輯是怎樣的?
  • 傳統關係型數據庫該如何實現?
    • 表設計初級玩法(80%的人只會這麼玩
    • 表設計進階玩法(高級程序員纔會的玩法
    • 查詢簽到狀況及簽到的技術實現
  • 基於Redis的Bitmaps實現簽到日曆(瞬間提高檔次
    • 什麼是Bitmaps
    • Bitmaps如何使用(含詳細命令對比分析及示例)
  • BitMap實戰簽到日曆
  • 業務總結/技術總結

1. 京東簽到日曆的產品邏輯

京東簽到領京豆

  • 簽到日曆僅展現當月簽到數據;
  • 簽到日曆需展現最近連續簽到天數;
    • 假設當前日期是20200618,且20200616未簽到;
    • 若20200617已簽到且0618未簽到,則連續簽到天數爲1;
    • 若20200617已簽到且0618已簽到,則連續簽到天數爲2;
  • 連續簽到天數越多,獎勵越大;
  • 全部用戶都可簽到;
    • 截至2020年3月31日的12個月,京東年度活躍用戶數3.87億,同比增加24.8%,環比增加超2500萬,此外,2020年3月移動端日均活躍用戶數同比增加46%。
    • 假設10%左右的用戶參與簽到,簽到用戶也高達3千萬;

2. 傳統關係型數據庫下的實現方案

2.1. MySQL表設計

2.1.1 表設計初級玩法(80%的人只會這麼玩)

  新建一張「用戶簽到記錄表(user_sign)」,核心字段以下:github

字段英文名 字段中文名
keyid 數據表主鍵(AUTO_INCREMENT)
user_key 京東用戶ID(全局惟一)
sign_date 簽到日期(如20200618)
sign_count 連續簽到天數(如2)
  • 用戶簽到:往此表插入一條數據,並更新連續簽到天數;
  • 當日重複簽到:數據不新增;
  • 查詢當月簽到狀況:查詢1號至今天的簽到數據;
  • 查詢連續簽到天數:查詢「sign_date=今天」的數據,今天無數據則查詢「sign_date=昨天」的數據;
# 查詢用戶小東(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

2.1.2 表設計進階玩法(按位存儲,高級程序員纔會的玩法)

  初級玩法一條簽到數據一條記錄,佔用了大量的存儲空間,咱們能夠從這裏優化一下。數據庫

  • int類型佔32位,足夠存儲一個月的簽到記錄;
  • 已簽到則對應位存1,未簽到存0;
    • (此處省略26個0)000101:表示1號和3號已簽到;
  • 一條數據直接存儲一個月的簽到記錄,再也不是存儲一天的簽到記錄;
  • 表設計按用戶ID hash分表,無需按照月份分表;
  • 優化後的表設計核心字段以下:
# 用戶簽到記錄表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?數組

  • 用戶ID爲何是36位的UUID,即便百億用戶也僅須要11位就夠了?
  • keyid爲何不使用auto_increment自增?

2.2. 查詢簽到狀況及簽到的技術實現

  如下技術實現基於「表設計進階玩法(按位存儲)」;
  keyid:由簽到月份+用戶ID生成。如用戶小東(user_key="19980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx"),其2020年6月份的簽到記錄keyid值是"20200619980618-xxxx-xxxx-xxxx-xxxxxxxxxxxx",前6位是年月YYYYMM,後面36位是用戶ID;bash

2.2.1. 查詢用戶當月簽到數據

  • 因爲表設計時keyid爲月份+用戶ID,故可直接根據keyid查詢指定用戶指定月份的簽到數據;
  • keyid不使用auto_increment自增的緣由你GET到了嗎。
# 查詢用戶當月簽到數據 @zxiaofan
SELECT
	sign_record
FROM
	user_sign_h 
WHERE
	keyid = 'xxxx';
複製代碼

2.2.2. 查詢用戶連續簽到天數

  • 因爲表設計有專門存儲連續簽到數量的字段,故直接查詢該用戶當月的「連續簽到天數」便可;
  • 注意:若是服務器時間是當月第一天,則須要查詢當月以及上個月的「連續簽到天數」。若當月的「連續簽到天數」爲0 ,則取上個月的「連續簽到天數」;
# 查詢用戶連續簽到天數(服務器時間不是當月第一天) @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');
複製代碼

2.2.3. 簽到

  • 簽到先確認本月是否已簽到:
    • 本月未簽到,則新增簽到數據;
    • 本月已簽到,則更新簽到記錄;
  • 簽到:將當前日期簽到狀態置爲「已簽到」(對應位置爲1);
  • 簽到時需更新連續簽到天數;
    • 若昨天已簽到,則「連續簽到天數」爲「當前連續簽到天數」+1;
    • 若昨天未簽到,則「連續簽到天數」置爲1便可;
# 本月第一天簽到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';
複製代碼
  • 關於補籤

  補籤須要更新對應日期的簽到記錄,計算並更新連續簽到天數;不是本文重點具體的技術邏輯就不贅述了。服務器

2.3. MySQL簽到解決方案的注意事項

2.3.1. 併發簽到如何處理?

  • 從以上SQL能夠看出,若本月已簽到,即便重複簽到,也不會影響最終的數據;
  • 注意:SQL中的「last_sign_date = xxx」必須在「sign_count = xxx」以後,由於sign_count的值取決「CASE last_sign_date」的計算結果;
  • 若是是本月第一次簽到,則新增數據,因爲新增數據的keyid是按規則生成的,因此即便非法或異常操做致使併發簽到,也絲絕不會影響最終的數據;

2.3.2. MySQL簽到記錄解決方案的想象空間

  • 從上述實現方案來看,業務邏輯、技術實現及SQL足夠簡單,從而單次查詢/簽到性能能夠知足產品訴求;
  • 1個用戶1年最多12條記錄,3KW用戶一年約3.6億條記錄,假設按用戶ID hash100分表,單表約360W條記錄,MySQL徹底能承受;

3. 基於Redis的Bitmaps實現簽到日曆

3.1. 爲何要使用Bitmaps

  上述基於MySQL的進階解決方案,已能知足海量用戶的簽到業務。但咱們想再節省點存儲空間,再提高響應效率呢。
  Bitmaps 閃亮登場。微信

3.2. 什麼是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的位。

3.3. Bitmaps如何使用

  關於Bitmaps的使用其實在先前的文章中已經說起過了,能夠查看玩轉Redis系列文章之《玩轉Redis-Redis基礎數據結構及核心命令》,其中在「String位操做」這一節已經講過。此處咱們來複習一下:

【Bitmaps核心命令】:SETBIT、BITOP、GETBIT、BITCOUNT、BITFIELD、BITPOS;

3.3.1. 【Redis-Bitmaps位操做】命令簡述

命令 功能 參數
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】

3.3.2. Bitmaps位操做命令注意事項

  • 【BITOP】支持邏輯操做,且AND、或OR、異或XOR、非NOT;
    • 且AND(&):同1爲1,其他爲0;
    • 或OR(|):有1爲1,同0爲0;
    • 異或XOR(^):不一樣爲1,相同爲0;
    • 非NOT(~):1變0,0變1;
  • GETBIT、SETBIT操做的是指定位,參數offset指的是二進制位偏移量;
  • BITCOUNT、BITPOS操做的是字節,參數start、end指的是字節偏移量;
  • BITPOS 返回的是相對於第0 bit位的偏移量,而不是相對於 參數中start的偏移量;

3.3.3. 【Redis-String位操做】命令詳細對比分析以下

Redis-String位操做

3.3.4. Bitmaps位操做命令示例

  • SETBIT、GETBIT、BITCOUNT、BITPOS 命令示例
# 位圖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
複製代碼
  • bitop 命令示例
# 位圖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
複製代碼

3.4. Bitmaps實戰簽到日曆

3.4.1. 簽到場景下的Bitmaps設計

  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存儲簽到記錄的位數組。

3.4.2. Bitmaps實現用戶簽到

  首先明確第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 
複製代碼

3.4.3. Bitmaps查詢簽到狀況

  經過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
複製代碼

3.4.4. Bitmaps實現簽到業務總結

  從上述來看,使用位圖Bitmaps實現簽到業務場景相對簡單很大,沒必要考慮跨月等問題,並且佔用的存儲空間也極小。那麼咱們還有優化空間嗎?
  目前是1個用戶僅1條記錄,若是產品設計上不會存在跨年數據的操做,是否可考慮將簽到數據按年存儲呢,歷年數據在持久化後從Redis中清除從而節省Redis內存空間。固然不要爲了節省而拆分,若是致使業務邏輯變複雜,就得不償失了。

4. 總結

4.1. 業務分析

  • 百億用戶也僅須要11位就夠存儲了,用戶ID爲何是36位的UUID呢?

  技術上11位的確足夠了,但技術都是爲業務服務的。若是使用簡單的數字,則競爭對手就能夠知道你的真實用戶數量、用戶增量狀況,這在商業上是確定不容許的。

  • 基於MySQL實現簽到業務,若是是本月第一天簽到,「連續簽到天數」爲何由業務方計算好,而不是SQL直接實現?

  業務邏輯仍是業務方作,在保證數據準確性的前提下,數據庫邏輯儘可能簡單。

  • keyid爲何不使用auto_increment自增?

   auto_increment自增的確簡單省事,但keyid自行設計爲月份+用戶ID,直接根據keyid查詢指定用戶指定月份的簽到數據,這樣不香嗎。

  • 按照這種思路就能本身作個「京東簽到領京豆」嗎?

   只能說能夠實現以上產品邏輯,京東領京豆的實際產品邏輯更加複雜,好比,京東簽到領京豆有個頁面能夠看到「京豆領取明細」,包含精確到秒級別的領取時間,這點以上文章並未涉及,固然這也不是本文的重點。
   每一個產品的背後都有着產品經理充分調研用戶需求、業務需求,架構、技術、運營等人員的通力合做。
   心存敬畏。

  • 爲什麼鮮有APP展現用戶一年的簽到記錄?

  京東的簽到日曆僅展現了當前月份的數據,支付寶會員簽到的最大連續簽到天數是7天,CSDN的簽到次數僅保留3個月。
  爲什麼不展現一年的數據呢?在商言商,不展現的重要緣由固然是商業價值不足,投入產出比不高。技術上能夠實現,但技術須要爲業務服務、爲產品服務。

4.2. 技術分析

  • Bitmaps最大長度位數是多少?

   因爲String數據類型的最大長度是512M,因此String支持的位數是2^32位。512M表示字節長度,換算成位須要乘以8,即512 * 2^10 * 2^10 * 8=2^32;

  • Bitmaps能夠支持超過512M的數據嗎?

  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-如何高效訪問Redis中的海量數據》

《玩轉Redis-高級程序員必知的Key命令》

《玩轉Redis-研發也應該知道的Connection命令》

《玩轉Redis-Redis高級數據結構及核心命令-ZSet》

《玩轉Redis-Redis基礎數據結構及核心命令》

《玩轉Redis-Redis安裝、後臺啓動、卸載》


祝君好運!
Life is all about choices!
未來的你必定會感激如今拼命的本身!
CSDN】【GitHub】【OSCHINA】【掘金】【語雀】【微信公衆號
歡迎訂閱zxiaofan的微信公衆號,掃碼或直接搜索zxiaofan

相關文章
相關標籤/搜索