利用 Redis bitmap 实现高效的用户签到统计功能

2024-07-19 16:25:14 浏览数 (1)

前言

在现代应用程序中,用户签到是一个常见的功能。我们通常使用 MySQL 数据库来存储用户的签到记录。然而,随着用户数量的增加,数据库中的记录将会随时间和用户量线性增长,这不仅增加了存储的负担,而且可能影响查询效率。在追求更高存储效率和查询性能的场景下,MySQL 可能不再是最佳选择。

这时,RedisBitmap 数据结构就显得尤为重要。利用 Redis Bitmap,我们不仅可以大幅度降低存储空间的占用,还可以高效实现复杂的用户行为统计,如连续签到天数、月签到统计等。接下来,本文将详细介绍如何利用 Redis Bitmap 实现高效的用户签到统计功能。

准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。

Redis Bitmap

RedisBitmap,也称为位图,是一种用于存储和处理二进制位(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 值的设计:由于一年只有 365366 天,因此我们只需要 bitmap 里面的前 366 位,即 0-365 位。

功能概览

接下来将会结合 Go 语言和 Redis 中间件实现以下功能:

  • 用户签到
  • 查询用户签到状态
  • 统计今年累计签到天数
  • 统计当月的签到情况

在 Go 程序里安装 Redis 依赖

接下来的功能实现将会使用 Go 语言代码进行演示,因此我们需要先安装 Go Redis 依赖。

代码语言:bash复制
go get github.com/redis/go-redis/v9

用户签到

要实现用户签到的功能,我们需要用到 RedisSETBIT 命令。

SETBIT 命令用于设置或清除字符串值中的某个位(bit)值,用法如下所示:

代码语言:bash复制
SETBIT key offset value
  • key: 键名。
  • offset: 位偏移量,表示要设置或清除的位(bit)的位置。位的位置从 0 开始计数。
  • value: 要设置的位值,可以是 01

示例代码:

代码语言: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 方法,将 keyuser:2024:1 对应的 bitmap 中第 0 位设为 1。这代表 ID1 的用户在 2024-01-01 进行了签到。SetBit 方法的返回值为该位(bit)被设置新值之前的值。

查询用户签到状态

要实现查询用户签到的状态,我们需要用到 RedisGETBIT 命令。

GETBIT 命令用于获取字符串值中的某个位(bit)的值,用法如下所示:

代码语言:bash复制
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 方法,获取到 keyuser:2024:1 对应的 bitmap 中的第 0 位的值为 1,这代表 ID1 的用户在 2024-01-01 已经签到过了。

统计今年累计签到天数

要实现统计一年里的签到次数,我们需要用到 RedisBITFIELD 命令。

RedisBITFIELD 命令是一个非常强大的命令,它允许你执行多种位级操作,包括读取、设置、增加位字段。这个命令能够操作存储在字符串中的位数组,并可以看作是直接在字符串上执行复杂的位操作。用法如下所示:

代码语言:bash复制
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: 示例中假设用户 ID1
  • 构建 Redis Key:使用年份和用户 ID 构建一个唯一的 Redis Key,格式为 user:年份:用户ID
  • 定义位操作的区间大小: 由于位域命令 BitField 的每个操作可以处理的最大长度是 63 位,定义 segmentSize := 63 来批量处理签到数据。一个区间表示 63 天的签到情况。
  • 封装 BitField 命令的参数: 通过循环将从年初到当前日期的天数(dayOfYear)分割为每段最多包含 63 天的多个区间,动态构建 BitField 命令的参数。
  • 执行 BitField 命令: 使用 rdb.BitField() 方法执行构建好的 BitField 命令,返回一个包含位二进制对应的十进制表示的 int64 类型切片。
  • 统计累计签到天数: 遍历结果数组,针对每个非零的结果使用位运算(& 操作和位移操作)来检测签到情况,每发现一个 1 就将 cumulativeDays 增加 1

统计当月的签到情况

要实现统计某月的签到情况,同样我们也需要用到 RedisBITFIELD 命令。

示例代码:

代码语言: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 非常适合实现高效的签到统计功能,但在设计系统时需要根据具体需求权衡其优缺点。

0 人点赞