前言
在现代应用程序中,用户签到是一个常见的功能。我们通常使用 MySQL
数据库来存储用户的签到记录。然而,随着用户数量的增加,数据库中的记录将会随时间和用户量线性增长,这不仅增加了存储的负担,而且可能影响查询效率。在追求更高存储效率和查询性能的场景下,MySQL
可能不再是最佳选择。
这时,Redis
的 Bitmap
数据结构就显得尤为重要。利用 Redis Bitmap
,我们不仅可以大幅度降低存储空间的占用,还可以高效实现复杂的用户行为统计,如连续签到天数、月签到统计等。接下来,本文将详细介绍如何利用 Redis Bitmap
实现高效的用户签到统计功能。
准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。
Redis Bitmap
Redis
的 Bitmap
,也称为位图,是一种用于存储和处理二进制位(bit
)的数据结构。在 Redis
中,Bitmap
不是一种独立的数据类型,而是字符串类型的一种特殊使用方式。你可以通过特定的命令在字符串数据中处理二进制位。由于 Redis
中字符串的最大长度为 512
MB,每个字节有 8
位,因此一个字符串最多可以存储 512 * 1024 * 1024 * 8 = 2^32
个位。
Bitmap
的主要应用场景如下:
- 用户签到统计:每个用户对应一张位图,位图中的每一位代表某一天的签到情况。
0
表示未签到,1
表示已签到。通过位图可以快速统计用户的连续签到天数、总签到天数等。 - 布隆过滤器:基于
bitmap
可以实现一个布隆过滤器,bitmap
可以用于高效地判断某个元素是否存在于一个集合中。通过多个哈希函数将元素映射到bitmap
的不同位上,快速判断元素的存在性。 - 活跃用户统计:可以用
Bitmap
记录用户是否在某一天活跃。例如,用户访问网站或者使用应用时,将相应的位设为1
,通过统计位的数量可以快速计算活跃用户数。
签到统计功能实现
用户与位图的映射关系
签到记录以年为单位,一个用户,对应一张位图(Bitmap
),表示用户在一年内的签到情况。
key
的设计:user:sign:%d:%d
,第一个占位符表示年份,第二个占位符表示用户的编号。bitmap
值的设计:由于一年只有 365 或 366 天,因此我们只需要bitmap
里面的前 366 位,即 0-365 位。
功能概览
接下来将会结合 Go
语言和 Redis
中间件实现以下功能:
- 用户签到
- 查询用户签到状态
- 统计今年累计签到天数
- 统计当月的签到情况
在 Go 程序里安装 Redis 依赖
接下来的功能实现将会使用 Go
语言代码进行演示,因此我们需要先安装 Go Redis
依赖。
go get github.com/redis/go-redis/v9
用户签到
要实现用户签到的功能,我们需要用到 Redis
的 SETBIT
命令。
SETBIT
命令用于设置或清除字符串值中的某个位(bit
)值,用法如下所示:
SETBIT key offset value
- key: 键名。
- offset: 位偏移量,表示要设置或清除的位(
bit
)的位置。位的位置从 0 开始计数。 - value: 要设置的位值,可以是 0 或 1。
示例代码:
代码语言:go复制// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/redis-bitmap-sign/sign/main.go
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
func RedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
}
func main() {
rdb := RedisClient()
if rdb == nil {
panic("redis client is nil")
}
oldValue, err := rdb.SetBit(context.Background(), "user:2024:1", 0, 1).Result()
if err != nil {
panic(err)
}
if oldValue == 1 {
fmt.Println("重复签到")
} else {
fmt.Println(oldValue) // 0,表示这个位(`bit`)被设置新值之前的值。
}
}
在上述代码示例中,我们通过调用 Redis
客户端实例的 SetBit
方法,将 key
为 user:2024:1
对应的 bitmap
中第 0 位设为 1。这代表 ID
为 1 的用户在 2024-01-01 进行了签到。SetBit
方法的返回值为该位(bit
)被设置新值之前的值。
查询用户签到状态
要实现查询用户签到的状态,我们需要用到 Redis
的 GETBIT
命令。
GETBIT
命令用于获取字符串值中的某个位(bit
)的值,用法如下所示:
GETBIT key offset
- key: 键名。
- offset: 位偏移量,表示要设置或清除的位(
bit
)的位置。位的位置从 0 开始计数。
示例代码:
代码语言:go复制// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/redis-bitmap-sign/sign-in-record/main.go
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
func RedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
}
func main() {
rdb := RedisClient()
if rdb == nil {
panic("redis client is nil")
}
value, err := rdb.GetBit(context.Background(), "user:2024:1", 0).Result()
if err != nil {
panic(err)
}
fmt.Println(value) // 1
}
在上述代码示例中,我们通过调用 Redis
客户端实例的 GetBit
方法,获取到 key
为 user:2024:1
对应的 bitmap
中的第 0 位的值为 1,这代表 ID
为 1 的用户在 2024-01-01 已经签到过了。
统计今年累计签到天数
要实现统计一年里的签到次数,我们需要用到 Redis
的 BITFIELD
命令。
Redis
的 BITFIELD
命令是一个非常强大的命令,它允许你执行多种位级操作,包括读取、设置、增加位字段。这个命令能够操作存储在字符串中的位数组,并可以看作是直接在字符串上执行复杂的位操作。用法如下所示:
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment]
详情请参考:Redis BITFIRLED Command
示例代码:
代码语言:go复制// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/redis-bitmap-sign/cumulative-sign/main.go
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// RedisClient 初始化 Redis 客户端
func RedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
}
// GetCumulativeDays 获取指定年份的累计签到天数
func GetCumulativeDays(ctx context.Context, rdb *redis.Client, userID int, year int, dayOfYear int) (int, error) {
key := fmt.Sprintf("user:%d:%d", year, userID)
segmentSize := 63
cumulativeDays := 0
bitOps := make([]any, 0)
for i := 0; i < dayOfYear; i = segmentSize {
size := segmentSize
if i segmentSize > dayOfYear {
size = dayOfYear - i
}
bitOps = append(bitOps, "GET", fmt.Sprintf("u%d", size), fmt.Sprintf("#%d", i))
}
values, err := rdb.BitField(ctx, key, bitOps...).Result()
if err != nil {
return 0, fmt.Errorf("failed to get bitfield: %w", err)
}
for idx, value := range values {
if value != 0 {
size := segmentSize
if (idx 1)*segmentSize > dayOfYear {
size = dayOfYear % segmentSize
}
for j := 0; j < size; j {
if (value & (1 << (size - 1 - j))) != 0 {
cumulativeDays
}
}
}
}
return cumulativeDays, nil
}
func main() {
rdb := RedisClient()
if rdb == nil {
log.Fatal("redis client is nil")
}
now := time.Now()
// 获取当前的年份
year := now.Year()
// 获取当前日期是今年的第几天
dayOfYear := now.YearDay()
// 假设用户 ID 为 1
userID := 1
cumulativeDays, err := GetCumulativeDays(context.Background(), rdb, userID, year, dayOfYear)
if err != nil {
log.Fatalf("failed to get cumulative days: %v", err)
}
fmt.Printf("%d 年累计签到的天数: %dn", year, cumulativeDays)
}
上述代码实现了统计今年累计签到天数的功能,流程如下:
- 获取
Redis
客户端实例: 使用redis.NewClient()
方法连接Redis
至服务器,并获取一个客户端实例。 - 获取时间因子:
- 当前年份: 通过
year := now.Year()
获取。 - 今天是今年的第几天: 通过
dayOfYear := now.YearDay()
获取。
- 当前年份: 通过
- 设定用户 ID: 示例中假设用户
ID
为 1。 - 构建 Redis Key:使用年份和用户
ID
构建一个唯一的Redis Key
,格式为user:年份:用户ID
。 - 定义位操作的区间大小: 由于位域命令
BitField
的每个操作可以处理的最大长度是 63 位,定义segmentSize := 63
来批量处理签到数据。一个区间表示 63 天的签到情况。 - 封装
BitField
命令的参数: 通过循环将从年初到当前日期的天数(dayOfYear
)分割为每段最多包含 63 天的多个区间,动态构建BitField
命令的参数。 - 执行 BitField 命令: 使用
rdb.BitField()
方法执行构建好的BitField
命令,返回一个包含位二进制对应的十进制表示的int64
类型切片。 - 统计累计签到天数: 遍历结果数组,针对每个非零的结果使用位运算(
&
操作和位移操作)来检测签到情况,每发现一个 1 就将cumulativeDays
增加 1。
统计当月的签到情况
要实现统计某月的签到情况,同样我们也需要用到 Redis
的 BITFIELD
命令。
示例代码:
代码语言:go复制// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/redis-bitmap-sign/monthly-sign/main.go
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
func RedisClient() *redis.Client {
return redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
}
func main() {
rdb := RedisClient()
if rdb == nil {
panic("redis client is nil")
}
now := time.Now()
// 获取当前的年份
year := now.Year()
// 假设用户 ID 为 1
userID := 1
// 获取当前月的天数
days := time.Date(now.Year(), now.Month() 1, 1, 0, 0, 0, 0, now.Location()).Add(-24 * time.Hour).Day()
// 获取本月初是今年的第几天
offset := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).YearDay()
signOfMonth, err := GetSignOfMonth(context.Background(), rdb, userID, year, days, offset)
if err != nil {
log.Fatal(err)
}
fmt.Println(signOfMonth)
}
func GetSignOfMonth(ctx context.Context, rdb *redis.Client, userID, year, days, offset int) ([]bool, error) {
typ := fmt.Sprintf("u%d", days)
key := fmt.Sprintf("user:%d:%d", year, userID)
s, err := rdb.BitField(ctx, key, "GET", typ, offset).Result()
if err != nil {
return nil, fmt.Errorf("failed to get bitfield: %w", err)
}
if len(s) != 0 {
signInBits := s[0]
signInSlice := make([]bool, days)
for i := 0; i < days; i {
signInSlice[i] = (signInBits & (1 << (days - 1 - i))) != 0
}
return signInSlice, nil
} else {
return nil, errors.New("no result returned from BITFIELD command")
}
}
上述代码实现了统计当月的签到情况的功能,流程如下:
- 获取 Redis 客户端实例:使用
redis.NewClient()
方法连接至Redis
服务器,并获取一个客户端实例。 - 获取时间因子:
- 当前年份:通过
year := now.Year()
获取。 - 当前月的天数:通过
time.Date(now.Year(), now.Month() 1, 1, 0, 0, 0, 0, now.Location()).Add(-24 * time.Hour).Day()
计算。 - 本月初是今年的第几天:通过
time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).YearDay()
获取。
- 当前年份:通过
- 设定用户
ID
:示例中假设用户 ID 为 1。 - 构建
Redis key
和BitField
命令的参数:- 使用年份和用户
ID
构建一个唯一的Redis Key
,格式为user:年份:用户ID
。 - 使用当月天数
days
构建type
参数fmt.Sprintf("u%d", days)
,表示操作的位字段宽度。
- 使用年份和用户
- 执行
BitField
命令:通过rdb.BitField()
方法执行BitField
命令,返回一个包含位二进制对应的十进制表示的int64
类型切片。 - 统计当月的签到情况:通过位运算(与操作和位移操作)检测每天的签到状态,将结果以布尔切片形式返回,其中
true
表示签到,false
表示未签到。
我们可以根据布尔切片的元素在用户端展示当月的签到情况,例如 签到日历。
小结
本文详细介绍了如何利用 Redis Bitmap
类型实现高效的用户签到统计功能。内容包括 Redis Bitmap
数据类型的简单介绍及其应用场景,并通过 Go
语言程序简单实现了 用户签到、查询用户签到状态 和 统计今年累计签到天数 以及 统计当月的签到情况 的功能。
虽然 Redis bitmap
数据类型在统计用户签到情况方面具有显著优势,主要体现在以下两点:
- 高效存储:每个用户的签到信息仅占用一个位,从而极大地节省了存储空间。
- 快速查询:可以通过位操作快速查询用户的签到状态和统计签到天数。
然而,Redis Bitmap
数据类型也有其局限性。例如,使用 Bitmap
存储数据时,只能存储单一状态。如果需要存储额外的具体签到时间或其他相关信息,Bitmap
并不适用。
总的来说,Redis Bitmap
非常适合实现高效的签到统计功能,但在设计系统时需要根据具体需求权衡其优缺点。