Redis系列二 位图

居上位而不骄,在下位而不忧。(《周易·䷀乾·文言》)
直接上代码----实战在最后
  1. 如果要统计一篇文章的阅读量,可以直接使用 Redis 的 incr 指令来完成。
  2. 如果要求阅读量必须按用户去重,那就可以使用 set 来记录阅读了这篇文章的所有用户 id,获取 set 集合的长度就是去重阅读量。
  3. 但是如果爆款文章阅读量太大,set 会浪费太多存储空间。这时候我们就要使用 Redis 提供的 HyperLogLog 数据结构 来代替 set,它只会占用最多 12k 的存储空间就可以完成海量的去重统计。但是它牺牲了准确度,它是模糊计数,误差率约为 0.81%。

按照官网的说法,Redis位图Bitmaps不是实际的数据类型,而是在字符串类型上定义的一组面向位的操作。在Redis中字符串限制最大为512MB, 所以位图中最大可以设置2^32个不同的位(42.9亿个)。图位的最小单位是比特(bit),每个bit的值只能是0或1。
位图适合存bool数据,当某个业务只有两种结果的时候,位图是不二之选
位图的存储大小计算: (maxOffset / 8 / 1024 / 1024)MB。其中maxOffset为位图的最大位数

基本操作

// bit 基本操作
async function baseBitFun() {

// hello => 二进制 【01101000 01100101 01101100 01101100 01101111】
const [bitKey, bitValue] = ['bitKey', 'hello']
client.set(bitKey, bitValue, redis.print);
client.get(bitKey, redis.print);
// 1. 根据偏移量获取bit上的值 0=》0;1-》1
client.getbit(bitKey, 1, redis.print);
// 2. bitcount 获取全部的 1 的总数
client.bitcount(bitKey, redis.print);
// 3. setbit 设置指定偏移量的值,0 || 1
// !offset 参数必须0到 2^32 (bit 映射被限制在 512 MB 之内)。
// !注意,这里的star和end不是指bit的下标,而是字节(byte)的下标。比如start为1,则实际对应的bit下标为8(1byte = 8 bit)
client.setbit(bitKey, 0, '1');
client.bitcount(bitKey, redis.print);
await sleep(0.2);
console.log('----获取位置----');
// 4. 获取第一次出现0或1的位置,获取某个偏移量之后第一次出现0或1的位置
client.bitpos(bitKey, 0, redis.print)
client.bitpos(bitKey, 1, redis.print)
// 1=>8 2=>16 == [8, 16]
client.bitpos(bitKey, 1, 2, redis.print)
await sleep(0.2);
console.log('----BITFIELD----');
// 设置value=hello
client.setbit(bitKey, 0, '0');
client.get(bitKey, redis.print);
// get i4 0 从0开始取4位即0110,有符号/无符号转十进制为6, 1*2^2+1*2^1 = 6, 结果一致
const get1Arg = ['get', 'i4', 0];
// !get i4 4 从4开始取4位即 1000 , 有符号转十进制为 -8, 1000 =>
const get2Arg = ['get', 'i4', 4];
// 5. bitfield
client.bitfield(bitKey, get1Arg, redis.print);
client.bitfield(bitKey, get2Arg, redis.print);
// 6. incrby
// 2位开始连续4位无符号自增
const incrby1Arg = ['incrby', 'u4', 2, 1];
client.bitfield(bitKey, incrby1Arg, redis.print);
/**
* 它用来对指定范围的位进行自增操作。既然提到自增,就有可能出现溢出。如果增加了正数,会出现上溢,如果增加的是负数,就会出现下溢出。
* 【Redis 默认的处理是折返】。如果出现了溢出,就将溢出的符号位丢掉。如果是 8 位无符号数 255,加 1 后就会溢出,会全部变零。如果是 8 位有符号数 127,加 1 后就会溢出变成 -128。
*/
await sleep(0.2);
console.log('----BITOP----');

};

返回值

/** 
Reply: OK
Reply: hello
Reply: 1
Reply: 21
Reply: 22
----获取位置----
Reply: 3
Reply: 0
Reply: 17
----BITFIELD----
Reply: hello
Reply: 6
Reply: -8
Reply: 11
----BITOP----

*/

BITFIELD

字母 数值 二进制(高位<-低位)
h 104 0110 1000
e 101 0110 0101
l 108 0110 1100
l 108 0110 1100
o 111 0110 1111
127.0.0.1:6379> set w hello
OK

127.0.0.1:6379> bitfield w get u4 0 #从w的第0个位开始取4个位(0110),结果为无符号数(u)
1) (integer) 6
127.0.0.1:6379> bitfield w get u3 2 #从w的第2个位开始取3个位(101),结果为无符号数(u)
1) (integer) 5
127.0.0.1:6379> bitfield w get i4 0 #从w的第0个位开始取4个位(0110),结果为有符号数(i)
1) (integer) 6
127.0.0.1:6379> bitfield w get i3 2 #从w的第2个位开始取3个位(101),结果为有符号数(i)
1) (integer) -3

127.0.0.1:6379> bitfield w set u8 8 97 #从第9个位开始,将接下来8个位用无符号数97 ( 字母a) 替换
1) (integer) 101
127.0.0.1:6379> get w

BITOP operation destkey key [key …]

基本操作其实还是用终端比较好,直接贴命令

127.0.0.1:6379> set a a                  # 二进制  01100001    
OK    
127.0.0.1:6379> set c c                  # 二进制  01100011    
OK    
127.0.0.1:6379> bitop and destkey a c    # 与操作  01100001 -> a    
(integer) 1    
127.0.0.1:6379> get destkey    "a"
127.0.0.1:6379> set a a                 # 二进制  01100001    
OK    127.0.0.1:6379> set b b           # 二进制  01100010    
OK    127.0.0.1:6379> bitop or destkey a b    # 或操作  01100011 -> c    
(integer) 1
127.0.0.1:6379> get destkey    
"c"
127.0.0.1:6379> set a a                 # 二进制  01100001    
OK    
127.0.0.1:6379> set z Z           # 二进制  01011010 (大写的Z)    
OK    
127.0.0.1:6379> bitop xor destkey a z   # 异或    00111011 -> ; 分号    
(integer) 1    
127.0.0.1:6379> get destkey
";"

实战前奏

如果对位运算不熟悉的同学,可以先复习一下。

链接放这里了:

理解有符号数和无符号数
位运算世界畅游指南

位图实战

如果要统计一篇文章的阅读量,可以直接使用 Redis 的 incr 指令来完成
如果要求阅读量必须按用户去重,那就可以使用 set 来记录阅读了这篇文章的所有用户 id,获取 set 集合的长度就是去重阅读量。
但是如果爆款文章阅读量太大,set 会浪费太多存储空间。这时候我们就要使用 Redis 提供的 HyperLogLog 数据结构 来代替 set,它只会占用最多 12k 的存储空间就可以完成海量的去重统计。
但是它牺牲了准确度,它是模糊计数,误差率约为 0.81%

按照官网的说法,Redis位图Bitmaps不是实际的数据类型,而是在字符串类型上定义的一组面向位的操作。在Redis中字符串限制最大为512MB
所以位图中最大可以设置2^32个不同的位(42.9亿个)。图位的最小单位是比特(bit),每个bit的值只能是0或1。

位图适合存bool数据,当某个业务只有两种结果的时候,位图是不二之选
位图的存储大小计算: (maxOffset / 8 / 1024 / 1024)MB。其中maxOffset为位图的最大位数

目标

  1. 打卡
  2. 判断某天是否打卡
  3. 统计某月打卡总次数
  4. 获取某用户在某月的打卡信息
    1. 连续打卡的起止时间
    2. 最长连续天数
  5. 统计指定区间的打卡次数
总的调用

(async function () {
const bitmap = new BitMap();
// 初始化数据
await bitmap.initData(['2020-10']);
// 展示10月份全部签到数据
await bitmap.getAllData(['2020-10']);

const [uid1, uid2] = [1, 2]

// 用户X 签到
await bitmap.userSign(uid2, '2020-11-18');
// 用户X在XX日期 是否签到
await bitmap.judgeUserSign(uid2, '2020-11-18');
// 用户X在XX月 总的签到次数
await bitmap.getUserSignCount(uid2, '2020-10');
// 用户X在XX月 第一次签到的日期
await bitmap.getFirstSignDate(uid2, '2020-11');
// 用户XX在XX月 签到的情况
await bitmap.getSignInfo(uid2, '2020-10');
// 某个区间内,连续签到的人数总和
await bitmap.signAllWeek();

await common.sleep(1);
process.exit(1);
})()

返回值

➜  2位图 git:(main) ✗ ts-node sign.ts

用户1在 2020-10月 签到数据: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
用户2在 2020-10月 签到数据: 0,0,1,1,1,1,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,1,1,0,1,1,0,1,1,1
用户3在 2020-10月 签到数据: 1,0,1,0,0,1,0,0,1,0,0,1,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,1
用户4在 2020-10月 签到数据: 1,0,0,1,0,1,1,0,1,0,0,0,0,1,0,1,0,0,1,1,1,1,1,1,0,1,1,0,1,1,0
用户5在 2020-10月 签到数据: 1,0,1,1,0,1,1,1,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,1,1,0,1
用户6在 2020-10月 签到数据: 0,0,0,1,0,0,1,0,0,0,1,1,1,1,0,1,1,0,1,1,0,0,0,1,1,0,0,1,1,1,0
用户2在 2020-11-18签到为1
用户1在 2020-11月 签到数据: 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
用户2在 2020-11月 签到数据: 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0
用户3在 2020-11月 签到数据: 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
用户4在 2020-11月 签到数据: 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
用户5在 2020-11月 签到数据: 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
用户6在 2020-11月 签到数据: 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
用户2在 2020-11-18签到状态为1
用户2在 2020-10 月份 签到总次数为15
用户2在 2020-11 月份 首次签到 日期为2020-11-18
------用户2在2020-10签到情况-------
当月签到连续情况为:
[ { signCount: 4, days: [ 3, 4, 5, 6 ] },
{ signCount: 3, days: [ 29, 30, 31 ] },
{ signCount: 2, days: [ 26, 27 ] },
{ signCount: 2, days: [ 23, 24 ] },
{ signCount: 1, days: [ 19 ] },
{ signCount: 1, days: [ 17 ] },
{ signCount: 1, days: [ 13 ] },
{ signCount: 1, days: [ 11 ] } ]
最长的连续签到次数:4
最长的连续签到次数日期为:3,4,5,6
----------本月某七天-----------
本月第4到10天 中所有的签到次数: 1
具体实现

实现部分涉及:

  1. 位操作
  2. 多种实现方法
/**
* 获取某些月份总的签到数据
* @param totalMonth 如: ['2020-10']
*/
public async getAllData(totalMonth: string[]) {
for (let uid = 1; uid <= this.allUser; uid++) {
const total = [];
// 获取上月份的起止时间
for (const month of totalMonth) {
// month对应的天数
const { days } = UtilDate.daysInMonth(month);
// allUser 用户ID作为key中的标示
// 【偏移量+1】就是某月对应的几号
let offset = 0;
while (offset < days) {
const bit = await this.client.getbit(this.genKey({ date: month, uid }), offset);
total.push(bit);
offset++;
}
const result = `用户${uid}在 ${month}月 签到数据: ${total}`;
console.log(result);
}
}
}
/**
* 用户在某天 签到
* @param uid 用户ID
* @param date YYYY-MM—DD
*/
public async userSign(uid: number, date: string) {
const offset = UtilDate.dayOfNumInMonth(date);
const status = SIGN.YES;
await this.client.setbit(this.genKey({ date, uid }), offset - 1, status);
console.log(`用户${uid}在 ${date}签到为${status}`);
}
/**
* 判断用户在某天是否签到
* @param uid 用户ID
* @param date YYYY-MM—DD
*/
public async judgeUserSign(uid: number, date: string) {
const offset = UtilDate.dayOfNumInMonth(date);
const status = await this.client.getbit(this.genKey({ date, uid }), offset - 1);
await this.getAllData(['2020-11']);
console.log(`用户${uid}在 ${date}签到状态为${status}`);
}
/**
* 用户X在XX月总的签到次数
* @param uid 用户ID
* @param date YYYY-MM—DD
*/
public async getUserSignCount(uid: number, date: string) {
const count = await this.client.bitcount(this.genKey({ date, uid }));
console.log(`用户${uid}在 ${date} 月份 签到总次数为${count}`);
}
/**
* 获取当月签到情况
* 1. 当月最长的签到天数
* 2.
* @param uid
* @param date
*/
public async getSignInfo(uid: number, date: string) {
const { days, dayList } = UtilDate.daysInMonth(date);
const key = this.genKey({ date, uid });
// days 该月总天数
const bitValue = await this.genBitIntervalValue({ key, start: 0, length: days });
if (bitValue === -1) {
console.log('相关信息不存在')
return
}

let signCount = 0;
const signInfo = [];
let signValue = bitValue;
// 从后往前算
for (let index = dayList.length; index > 0; index--) {
// 位运算
// 先左后右,如果和原数据相等,则标示最低位是0,即,没有签到
// 从该月最后一天往前算。
if (signValue >> 1 << 1 === signValue) {
if (signCount > 0) {
// 记录连续的长度&位置
signInfo.push({ signCount, index });
// 重置连续次数
signCount = 0;
}
} else {
signCount++;
}
signValue >>= 1;
}

// 记录最后的一次连续【高位】
if (signCount > 0) {
signInfo.push({ signCount, index: 0 });
}

// 统计连续的天数、连续的日期
const result = [];


for (const item of signInfo) {
const { signCount, index } = item;
const days = [];
let i = 1;
let _index = index + 1;
while (i <= signCount) {
days.push(_index++);
i++;
}
const arg = {
signCount,
days,
}
result.push(arg);
}
// 排序函数 逆序排列
const compare = (p: any) => (m: any, n: any) => -(m[p] - n[p]);
result.sort(compare('signCount'));
console.log(`------用户${uid}在${date}签到情况-------`)
console.log("当月签到连续情况为:", '\n', result);
console.log(`最长的连续签到次数:${result[0].signCount}`);
console.log(`最长的连续签到次数日期为:${result[0].days}`);
}

具体的实例代码:https://github.com/simuty/Integration/blob/main/Redis/

参考链接

Redis -位图为何能存亿级数据
redis专题04 数据结构之redis位图系列问题
Redis中bitmap的妙用
使用redis位图bitMap 实现签到功能(PHP版本)
Redis修行 — 位图实战
基于Redis位图实现用户签到功能
跬步千里 —— 阿里云Redis bitfield命令加速记