前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >利用 Redis bitmap 实现高效的用户签到统计功能

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

原创
作者头像
陈明勇
修改2024-07-18 21:49:40
1722
修改2024-07-18 21:49:40
举报
文章被收录于专栏:RedisGo 实战Go 技术

前言

在现代应用程序中,用户签到是一个常见的功能。我们通常使用 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/consecutive-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
    })
}

// 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
        }

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • Redis Bitmap
  • 签到统计功能实现
    • 用户与位图的映射关系
      • 功能概览
        • 在 Go 程序里安装 Redis 依赖
          • 用户签到
            • 查询用户签到状态
              • 统计今年累计签到天数
                • 统计当月的签到情况
                • 小结
                相关产品与服务
                云数据库 Redis
                腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档