SpringBoot+Redis BitMap 實(shí)現(xiàn)簽到與統(tǒng)計(jì)功能
各個(gè)項(xiàng)目中,我們都可能需要用到簽到和 統(tǒng)計(jì)功能。簽到后會(huì)給用戶一些禮品以此來(lái)吸引用戶持續(xù)在該平臺(tái)進(jìn)行活躍。
簽到功能,我們可以通過(guò)Redis中的 BitMap功能來(lái)實(shí)現(xiàn)。
一、Redis BitMap 基本用法
BitMap 基本語(yǔ)法、指令
簽到功能我們可以使用MySQL來(lái)完成,比如下表:
圖片
用戶一次簽到,就是一條記錄,假如有1000萬(wàn)用戶,平均每人每年簽到次數(shù)為10次,則這張表一年的數(shù)據(jù)量為 1億條。
每簽到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字節(jié)的內(nèi)存,一個(gè)月則最多需要600多字節(jié)。
這樣的壞處,占用內(nèi)存太大了,極大的消耗內(nèi)存空間!
我們可以根據(jù) Redis中 提供的 BitMap 位圖功能來(lái)實(shí)現(xiàn),每次簽到與未簽到用0 或1 來(lái)標(biāo)識(shí) ,一次存31個(gè)數(shù)字,只用了2字節(jié) 這樣我們就用極小的空間實(shí)現(xiàn)了簽到功能。
BitMap 的操作指令:
- SETBIT:向指定位置(offset)存入一個(gè)0或1
- GETBIT:獲取指定位置(offset)的bit值
- BITCOUNT:統(tǒng)計(jì)BitMap中值為1的bit位的數(shù)量
- BITFIELD:操作(查詢、修改、自增)BitMap中bit數(shù)組中的指定位置(offset)的值
- BITFIELD_RO:獲取BitMap中bit數(shù)組,并以十進(jìn)制形式返回
- BITOP:將多個(gè)BitMap的結(jié)果做位運(yùn)算(與 、或、異或)
- BITPOS:查找bit數(shù)組中指定范圍內(nèi)第一個(gè)0或1出現(xiàn)的位置
使用 BitMap 完成功能實(shí)現(xiàn)
服務(wù)器Redis版本采用 6.2。
進(jìn)入redis查詢 SETBIT 命令
圖片
新增key 進(jìn)行存儲(chǔ)
圖片
查詢 GETBIT命令
圖片
查看指定坐標(biāo)的簽到狀態(tài)
圖片
查詢 BITFIELD
圖片
無(wú)符號(hào)查詢
圖片
BITPOS 查詢1 和 0 第一次出現(xiàn)的坐標(biāo)
圖片
二、SpringBoot 整合 Redis 實(shí)現(xiàn)簽到 功能
需求介紹
采用BitMap實(shí)現(xiàn)簽到功能
- 實(shí)現(xiàn)簽到接口,將當(dāng)前用戶當(dāng)天簽到信息保存到Redis中
思路分析:
我們可以把 年和月 作為BitMap的key,然后保存到一個(gè)BitMap中,每次簽到就到對(duì)應(yīng)的位上把數(shù)字從0 變?yōu)?,只要是1,就代表是這一天簽到了,反之咋沒(méi)有簽到。
實(shí)現(xiàn)簽到接口,將當(dāng)前用戶當(dāng)天簽到信息保存至Redis中
圖片
提示:因?yàn)锽itMap 底層是基于String數(shù)據(jù)結(jié)構(gòu),因此其操作都封裝在字符串操作中了。
圖片
核心源碼
UserController
@PostMapping("sign")
public Result sign() {
return userService.sign();
}
UserServiceImpl
public Result sign() {
//1. 獲取登錄用戶
Long userId = UserHolder.getUser().getId();
//2. 獲取日期
LocalDateTime now = LocalDateTime.now();
//3. 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
//4. 獲取今天是本月的第幾天
int dayOfMonth = now.getDayOfMonth();
//5. 寫入redis setbit key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth -1, true);
return Result.ok();
}
接口進(jìn)行測(cè)試
ApiFox進(jìn)行測(cè)試
圖片
查看Redis 數(shù)據(jù)
圖片
三、SpringBoot 整合Redis 實(shí)現(xiàn) 簽到統(tǒng)計(jì)功能
問(wèn)題一:什么叫做連續(xù)簽到天數(shù)?
從最后一次簽到開(kāi)始向前統(tǒng)計(jì),直到遇到第一次未簽到為止,計(jì)算總的簽到次數(shù),就是連續(xù)簽到天數(shù)。
圖片
邏輯分析:
獲得當(dāng)前這個(gè)月的最后一次簽到數(shù)據(jù),定義一個(gè)計(jì)數(shù)器,然后不停的向前統(tǒng)計(jì),直到獲得第一個(gè)非0的數(shù)字即可,每得到一個(gè)非0的數(shù)字計(jì)數(shù)器+1,直到遍歷完所有的數(shù)據(jù),就可以獲得當(dāng)前月的簽到總天數(shù)了
問(wèn)題二:如何得到本月到今天為止的所有簽到數(shù)據(jù)?
BITFIELD key GET u[dayOfMonth] 0
假設(shè)今天是7號(hào),那么我們就可以從當(dāng)前月的第一天開(kāi)始,獲得到當(dāng)前這一天的位數(shù),是7號(hào),那么就是7位,去拿這段時(shí)間的數(shù)據(jù),就能拿到所有的數(shù)據(jù)了,那么這7天里邊簽到了多少次呢?統(tǒng)計(jì)有多少個(gè)1即可。
問(wèn)題三:如何從后向前遍歷每個(gè)Bit位?
注意:bitMap返回的數(shù)據(jù)是10進(jìn)制,哪假如說(shuō)返回一個(gè)數(shù)字8,那么我哪兒知道到底哪些是0,哪些是1呢?
我們只需要讓得到的10進(jìn)制數(shù)字和1做與運(yùn)算就可以了,因?yàn)?只有遇見(jiàn)1 才是1,其他數(shù)字都是0 ,我們把簽到結(jié)果和1進(jìn)行與操作,每與一次,就把簽到結(jié)果向右移動(dòng)一位,依次內(nèi)推,我們就能完成逐個(gè)遍歷的效果了。
需求:
實(shí)現(xiàn)以下接口,統(tǒng)計(jì)當(dāng)前截至當(dāng)前時(shí)間在本月的連續(xù)天數(shù)。
圖片
有用戶有時(shí)間我們就可以組織出對(duì)應(yīng)的key,此時(shí)就能找到這個(gè)用戶截止這天的所有簽到記錄,再根據(jù)這套算法,就能統(tǒng)計(jì)出來(lái)他連續(xù)簽到的次數(shù)了。
核心源碼
UserController
@GetMapping("/signCount")
public Result signCount() {
return userService.signCount();
}
UserServiceImpl
public Result signCount() {
//1. 獲取登錄用戶
Long userId = UserHolder.getUser().getId();
//2. 獲取日期
LocalDateTime now = LocalDateTime.now();
//3. 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
//4. 獲取今天是本月的第幾天
int dayOfMonth = now.getDayOfMonth();
//5. 獲取本月截至今天為止的所有的簽到記錄,返回的是一個(gè)十進(jìn)制的數(shù)字 BITFIELD sign:5:202301 GET u3 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
//沒(méi)有任務(wù)簽到結(jié)果
if (result == null || result.isEmpty()) {
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
//6. 循環(huán)遍歷
int count = 0;
while (true) {
//6.1 讓這個(gè)數(shù)字與1 做與運(yùn)算,得到數(shù)字的最后一個(gè)bit位 判斷這個(gè)數(shù)字是否為0
if ((num & 1) == 0) {
//如果為0,簽到結(jié)束
break;
} else {
count ++;
}
num >>>= 1;
}
return Result.ok(count);
}
進(jìn)行測(cè)試
圖片
查看 Redis 變量
圖片
從今天開(kāi)始,往前查詢 連續(xù)簽到的天數(shù),結(jié)果為2 測(cè)試無(wú)誤!
四、關(guān)于使用bitmap來(lái)解決緩存穿透的方案
回顧緩存穿透:
發(fā)起了一個(gè)數(shù)據(jù)庫(kù)不存在的,redis里邊也不存在的數(shù)據(jù),通常你可以把他看成一個(gè)攻擊。
解決方案:
- 判斷id<0
- 數(shù)據(jù)庫(kù)為空的話,向redis里邊把這個(gè)空數(shù)據(jù)緩存起來(lái)
第一種解決方案:遇到的問(wèn)題是如果用戶訪問(wèn)的是id不存在的數(shù)據(jù),則此時(shí)就無(wú)法生效。
第二種解決方案:遇到的問(wèn)題是:如果是不同的id那就可以防止下次過(guò)來(lái)直擊數(shù)據(jù)。
所以我們?nèi)绾谓鉀Q呢?
我們可以將數(shù)據(jù)庫(kù)的數(shù)據(jù),所對(duì)應(yīng)的id寫入到一個(gè)list集合中,當(dāng)用戶過(guò)來(lái)訪問(wèn)的時(shí)候,我們直接去判斷l(xiāng)ist中是否包含當(dāng)前的要查詢的數(shù)據(jù),如果說(shuō)用戶要查詢的id數(shù)據(jù)并不在list集合中,則直接返回,如果list中包含對(duì)應(yīng)查詢的id數(shù)據(jù),則說(shuō)明不是一次緩存穿透數(shù)據(jù),則直接放行。
圖片
現(xiàn)在的問(wèn)題是這個(gè)主鍵其實(shí)并沒(méi)有那么短,而是很長(zhǎng)的一個(gè) 主鍵。
哪怕你單獨(dú)去提取這個(gè)主鍵,但是在 11年左右,淘寶的商品總量就已經(jīng)超過(guò)10億個(gè)。
所以如果采用以上方案,這個(gè)list也會(huì)很大,所以我們可以使用bitmap來(lái)減少list的存儲(chǔ)空間。
我們可以把list數(shù)據(jù)抽象成一個(gè)非常大的bitmap,我們不再使用list,而是將db中的id數(shù)據(jù)利用哈希思想,比如:
id 求余bitmap長(zhǎng)度 :id % bitmap.size = 算出當(dāng)前這個(gè)id對(duì)應(yīng)應(yīng)該落在bitmap的哪個(gè)索引上,然后將這個(gè)值從0變成1,然后當(dāng)用戶來(lái)查詢數(shù)據(jù)時(shí),此時(shí)已經(jīng)沒(méi)有了list,讓用戶用他查詢的id去用相同的哈希算法, 算出來(lái)當(dāng)前這個(gè)id應(yīng)當(dāng)落在bitmap的哪一位,然后判斷這一位是0,還是1,如果是0則表明這一位上的數(shù)據(jù)一定不存在,采用這種方式來(lái)處理,需要重點(diǎn)考慮一個(gè)事情,就是誤差率,所謂的誤差率就是指當(dāng)發(fā)生哈希沖突的時(shí)候,產(chǎn)生的誤差。
圖片
小結(jié)
以上就是對(duì) 微服務(wù) Spring Boot 整合 Redis BitMap 實(shí)現(xiàn) 簽到與統(tǒng)計(jì) 的簡(jiǎn)單介紹,簽到功能是很常用的,在項(xiàng)目中,是一個(gè)不錯(cuò)的亮點(diǎn),統(tǒng)計(jì)功能也是各大系統(tǒng)中比較重要的功能,簽到完成后,去統(tǒng)計(jì)本月的連續(xù) 簽到記錄,來(lái)給予獎(jiǎng)勵(lì),可大大增加用戶對(duì)系統(tǒng)的活躍度 技術(shù)改變世界?。?!