在现代应用程序中,用户签到是一个常见的功能。我们通常使用 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 程序里安装 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
。
示例代码:
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")
}
// 返回值为这个位(`bit`)被设置新值之前的值。
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
开始计数。
示例代码:
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]
- type:表示操作的位字段宽度。
- offset:表示从该偏移量开始
详情请参考:Redis BITFIRLED Command
示例代码:
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
})
}
// GetConsecutiveDays 计算连续签到天数
func GetConsecutiveDays(ctx context.Context, rdb *redis.Client, userID int, year int, dayOfYear int) (int, error) {
key := fmt.Sprintf("user:%d:%d", year, userID)
segmentSize := 63
consecutiveDays := 0
bitOps := make([]any, 0)
for i := 0; i < dayOfYear; i += segmentSize {
size := segmentSize
if i+segmentSize > dayOfYear {
size = dayOfYear - i
}
// 表示从offset开始,获取指定位字段宽度的值
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 {
consecutiveDays++
}
}
}
}
return consecutiveDays, 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
consecutiveDays, err := GetConsecutiveDays(context.Background(), rdb, userID, year, dayOfYear)
if err != nil {
log.Fatalf("failed to get consecutive days: %v", err)
}
fmt.Printf("%d 年累计签到的天数: %d\n", year, consecutiveDays)
}
上述代码实现了统计今年累计签到天数的功能,流程如下:
- 获取
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
就将consecutiveDays
增加 1。
统计当月的签到情况
要实现统计某月的签到情况,同样我们也需要用到 Redis
的 BITFIELD
命令。
示例代码:
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
非常适合实现高效的签到统计功能,但在设计系统时需要根据具体需求权衡其优缺点。