Redis应用---掘金签到&补卡
前置
面向用户
- 熟悉Go的基础语法
- 熟悉Redis的操作
笔者环境
- macos 10.15.7
- Redis 7.0.11
- Golang 1.18
- GoLand 2022.01
读完本文可以获得
- 使用Redis实现连续签到、签到统计与补卡操作
- 了解Redis的INCR和Bitmap的应用
- 熟悉二进制位运算,将byte组映射为int数组
- 熟悉将Bitmap转为JSON
- 熟悉go-redis执行 Redis 命令
项目代码
samuelZhang7/easy-signin: 基于redis的bitmap实现签到和补签 (github.com)
一、需求描述
用户每天可以进行签到,会记录用户累计签到天数和连续签到天数,并在下方以日历的形式显示用户每天签到的状态,同时允许用户对某一天进行补卡,补卡一次后累计天数将+1,连续天数需要重新计算。
因此,我们需要解决如下需求:
- 签到
- 累计签到天数
- 连续签到天数
- 显示当月签到表
- 补卡
提示:下文的代码实现基于Service层,一些参数的校验需要在Controller或其他层自行处理
二、签到
2.1 签到描述
用户每天通过点击签到按钮进行签到,后端将用户累计签到的次数和连续签到次数都+1,并且还需要记录用户签到日期。
- 用户必须要在每天23:59:59秒前签到
- 用户可以查看近12个月内的签到数据
累计签到次数
- Key:cc_uid_2612095360970839:sign_in_count
- value:34
用户签到时,使用String+INCR的方式进行自增+1(如果没有key,则会创建并赋值为0)。
连续签到次数
用户签到时,使用INCR对key进行自增+1(如果没有key,则会创建并赋值为0),并且给该key设置48小时的过期时间。
- Key:cc_uid_2612095360970839:sign_in_continuous
- value:33
- expireAt:后天0点
签到表
- Key:cc_uid_2612095360970839:sign_in_map
- value:bitmap
用户签到时,获取用户签到的时间是当月的第X天,使用redis的bitmap设置对应偏移量(X-1)位置的 bit 为 1表示签到,然后设置过期时间为下月的零点。
保证redis里只保存用户当月的数据。
如果用户获取当月数据,直接从redis返回。
如果要获取前N个月的数据时,需要走数据库,如InfluxDB获取。
本程序中的bitmap占用4字节,每个用户的key是cc_uid_2612095360970839_202308这样的格式,那么每个key的长度是30字节。如果有1000万用户,那么当月的签到一共需要的内存是:
1000万 * (30字节 + 4字节) = 1000万 * 34字节 =340MB
上面的计算只是粗略,保存所有用户的签到表要340MB,对于1000万用户量,这个内存消耗还是可以接受的。还有很大的优化空间,这里仅作为应用案例。
签到信息
- 累计签到次数
- 连续签到次数及过期时间
- 签到表
2.2 实现逻辑
- 从redis里获取签到表里指定偏移量的bit的值,检查用户是否已签到
- 执行签到操作,更新用户签到信息
- 设置签到位图
- 更新累计签到总数
- 更新连续签到总数以及过期时间
2.3 业务流程
2.4 代码实现
1. 流程控制
go
// SignIn 用户签到
func (s *Service) SignIn(userID int64) error {
// 1. 检查用户是否已签到
signed, err := s.CheckSignIn(userID)
if err != nil {
return err
}
if signed {
return fmt.Errorf("用户[%d]已签到,请勿重复签到\n", userID)
}
// 2. 执行签到操作
if err := s.updateSignInfo(userID); err != nil {
return err
}
// 签到成功
// 签到记录入influxDB
// 后续业务逻辑...
// 3.打印用户签到信息
if err := s.PrintUserSignInData(userID); err != nil {
return err
}
return nil
}
该函数为Service里的成员函数,用来控制签到处理的流程
- 检查用户是否签到
- 执行签到操作,即更新签到信息
- 签到成功后,打印签到信息
2. 检查是否签到
go
// CheckSignIn 检查是否已经签到
func (s *Service) CheckSignIn(userID int64) (bool, error) {
// 通过GET BIT判断对应日期是否已签到
signed, err := redisClient.GetBit(ctx, tools.GenBitmapKey(userID, timeStatus.YearMonth), timeStatus.Day-1).Result()
if err != nil {
return false, err
}
// 已签到
if signed != 0 {
return true, nil
}
// 未签到
return false, nil
}
tools.GenBitmapKey
生成指定的keytimeStatus
变量是自定义的时间管理对象,用于处理签到时的时间问题,并通过corn
包实现了每日零点更新日期。包含年、月、日、是否是闰年以及年月的字符串组合,timeStatus.Day-1
,这里-1,是因为bitmap是从0开始写,第1天,bit的位置是0。
3. 执行签到操作
go
// updateSignInfo 执行签到更新
func (s *Service) updateSignInfo(userID int64) error {
// 初始化pipeline
pipe := redisClient.Pipeline()
// 设置签到位图
pipe.SetBit(ctx, tools.GenBitmapKey(userID, timeStatus.YearMonth), timeStatus.Day-1, 1)
// 更新签到总数
pipe.Incr(ctx, tools.GenSignCountKey(userID))
// 获取连续签到key
continuousKey := tools.GenContinuousKey(userID)
// 更新连续签到总数
pipe.Incr(ctx, continuousKey)
// 更新连续签到的过期时间
pipe.ExpireAt(ctx, continuousKey, tools.NextExpireTime())
// 执行
if _, err := pipe.Exec(ctx); err != nil {
return handleRedisError(err)
}
return nil
}
-
使用
pipeline
将一次性更新多个操作,可以减少网络开销,提高效率,而且保证数据的原子性。 -
tools.NextExpireTime()
用于获取过期时间,返回的是后天零点时间。 -
当出现错误时,通过
handleRedisError(err)
,统一处理redis异常
2.5 运行效果
三、打印签到信息
3.1 打印描述
将用户的签到信息输出到控制台,原本应该将签到信息以JSON格式传给前端,此处为了演示,只输出到控制台。
3.2 实现逻辑
- 从redis里读取签到信息
- 将签到表包装成JSON
- 输出到控制台
3.3 代码实现
1. 将byte[]数组转成int数组
通过redisClient.Get().Bytes()
可以将获取的value转成二进制,那如何将byte数组里的每个0和1转成相应int数组里的0和1呢?
go
// bitmapToIntArray 将一个字节数组转成一个整形数组,也就是将0和1转成整形的0和1
func bitmapToIntArray(bitmap []byte) []int {
binaryArray := make([]int, len(bitmap)*8) // 创建了一个长度和容量都为 len(bitmap)*8,即32的切片
for i, b := range bitmap { // 遍历字节数组,i 是索引,b 是字节值
for j := 7; j >= 0; j-- { // 遍历每个字节的8个位,从高位到低位,因为在bitmap里面SETBIT是从高位开始
bitValue := int((b >> j) & 1) // 用右移和与运算来获取第j位的值,然后转换为整数,(&1:同为1,则返回1,否则返回0)
binaryArray[i*8+7-j] = bitValue // 将位值存入整形数组,注意索引的计算方式(从高位获取,要从低位写)
}
}
return binaryArray // 返回整形数组
}
因为每个字节有8位:
- 外层循环每次读取一个字节
- 内层循环每次处理1个字节
- 通过循环将该字节右移X位,可以依次获取每一位
- 然后在跟1做与运算,就可以获取该位是0还是1
- 因为是从高位往低位获取,所以在存储的时候要从数组的后面往前写。
2. 将bitmap转为JSON
通过redis获取了bitmap后,需要进行可读性处理,并包装成JSON返回给前端。
签到表结构体
go
type SignRecord struct {
Date string `json:"date"`
Signed int `json:"signed"`
}
转换流程
go
// BitmapToJSON 将一个字节数组转成一个 JSON 格式的字符串,表示每天的签到情况
func BitmapToJSON(bitmap []byte, status *TimeStatus) []byte {
// 将bitmap转为int数组,也就是将0和1转成整形的0和1
bitmapArray := bitmapToIntArray(bitmap)
// 根据每月的天数设置SignRecord 切片的大小
signRecordSize := daysInMonth[status.Month-1]
// 处理闰年情况
if status.Month == 2 && status.IsLeapYear {
signRecordSize = februaryOfLeapYear // 29天
}
// 根据每月的天数创建 SignRecord 切片,用来存储每天的签到记录
signRecordArray := make([]SignRecord, signRecordSize)
// 拼接year和month
yearMonth := fmt.Sprintf("%d-%02d", status.Year, status.Month)
// 遍历bitmapArray生成数据
for i, v := range bitmapArray {
if i == signRecordSize {
break // bitmapArray数组共有32个元素,而每月最多只有31天,因此当i==signRecordSize,表示该月已结束
}
// 根据年月和索引生成日期字符串,例如 "2021-01-01"
date := fmt.Sprintf("%s-%02d", yearMonth, i+1)
// 创建一个 SignRecord 结构体,用来表示一天的签到情况
item := SignRecord{
Date: date, // 日期字段
Signed: v, // 签到状态字段,0 表示未签到,1 表示已签到
}
// 将 SignRecord 结构体存入 signRecordArray 切片中
signRecordArray[i] = item
}
// 生成JSON
jsonBytes, _ := json.Marshal(signRecordArray) // 使用 json 包的 Marshal 函数将 signRecordArray 切片转换为 JSON 格式的字节数组
return jsonBytes // 返回 JSON 字节数组
}
此处代码中,我们做了边界判断,因为一个bitmap是4个字节,所以可能会出现32天,但实际是不可能的,因此我们使用了边界判断,只返回指定天数的签到表。
因为一个bitmap是4个字节,我们最大的位是30(表示第31天),但redis对bitmap有一个规定,在获取时使用字节返回,虽然只使用了31位(0~30位),但最终会返回4个字节,也就是第31位会默认为零。
3. 控制流程
go
// PrintUserSignInData 打印用户登录数据,连续签到天数、累计签到天数以及签到表
func (s *Service) PrintUserSignInData(userID int64) error {
// 获取连续签到key
continuousKey := tools.GenContinuousKey(userID)
// 将多个 Redis 命令添加到 pipeline 中
pipe := redisClient.Pipeline()
pipe.Get(ctx, continuousKey) // 获取连续签到天数
pipe.TTL(ctx, continuousKey) // 获取连续签到天数的剩余过期时间
pipe.Get(ctx, tools.GenSignCountKey(userID)) // 获取累计签到天数
pipe.Get(ctx, tools.GenBitmapKey(userID, timeStatus.YearMonth)) // 获取位图信息
result, err := pipe.Exec(ctx)
// 执行 pipeline 中的所有命令
if err != nil {
return handleRedisError(err)
}
// 获取 pipeline 执行结果
signInContinuous := result[0].(*redis.StringCmd) // 连续签到天数
signInContinuousExpireAt := result[1].(*redis.DurationCmd) // 连续签到天数的剩余过期时间
signInCount := result[2].(*redis.StringCmd) // 累计签到天数
signInBitmap := result[3].(*redis.StringCmd) // 位图信息
// 打印用户签到数据
fmt.Printf("用户[%d]操作成功,已连续签到:%s(天),连续签到到期时间:%s(ttl:%s),累计签到:%s(天)\n",
userID,
signInContinuous.Val(),
tools.NextExpireTime(),
signInContinuousExpireAt.Val(),
signInCount.Val())
// 获取位图信息并转换为 JSON 格式
bitmapBytes, _ := signInBitmap.Bytes()
fmt.Println("用户签到表:")
bitmapJson := tools.BitmapToJSON(bitmapBytes, timeStatus)
fmt.Println(string(bitmapJson)) // 打印 JSON 字符串
return nil
}
使用pipeline
将一次性获取多个操作,并手动获取执行结果,并输出到控制台。
3.4 运行效果
四、补卡
4.1 补卡描述
用户每天通过点击补签按钮进行签到,后端将更新用户累计签到的次数、连续签到次数、连续签到过期时间,并且记录用户签到日期。
-
用户必须要在每天23:59:59秒前补卡
-
用户只能对当月未签到的日期进行补卡
4.2 实现逻辑
- 从redis里获用户的签到表bitmap
- 判断用户补签的day是否已经签到了
- 执行用户补签操作,更新用户签到信息
4.3 业务流程
4.4 代码实现
1. 流程控制
go
// MakeUpSignIn 用户补签
func (s *Service) MakeUpSignIn(userID int64, day int) error {
// 1. 获取用户签到表
bitmap, err := getSigninForm(userID)
if err != nil {
return err
}
// 2. 判断用户补签的day,并模拟用户补签
signed := checkAndSimulateSignin(bitmap, day)
// 用户已签到,提醒用户不可补签
if signed {
return fmt.Errorf("用户[%d]已签到,不支持补签\n", userID)
}
// 3. 执行用户补签,更新用户签到信息
if err := makeupCard(bitmap, day, userID); err != nil {
return handleRedisError(err)
}
// 补签成功
// 补签记录入influxDB
// 后续业务逻辑...
//4. 打印用户签到信息
if err := s.PrintUserSignInData(userID); err != nil {
return err
}
return nil
}
与签到流程类似,此处不再赘述。
2. 获取用户签到表
go
// getSigninForm 获取用户签到表
func getSigninForm(userID int64) ([]byte, error) {
if bitmap, err := redisClient.Get(ctx, tools.GenBitmapKey(userID, timeStatus.YearMonth)).Bytes(); err != nil {
return nil, err
} else {
return bitmap, nil
}
}
返回一个bitmap的字节数组
3. 判断用户补签的day,并模拟用户补签
go
func checkAndSimulateSignin(bitmap []byte, day int) bool {
// bitmap是一个4字节,32位,只要定位到相应的字节,然后根据day构建一个字节,然后对两个字节位运算
// setBit时从0开始,因此第day天在第day-1位上
bit := day - 1
// 1. 找到bit所属的byte
index := bit / 8
// 2. 根据bit,获取在byte中的相对位置,然后设置该位为1
mask := byte(1 << (7 - bit%8))
// 3. 判断bit位是否是1
signed := bitmap[index]&mask != 0
// 4. 模拟用户签到,将bit位置设置为1(后续要计算连续签到天数,因此在这里模拟用户签到)
bitmap[index] = bitmap[index] | mask
return signed
}
该函数先判断bitmap指定位上的值是否为1,然后手动在bitmap里面进行签到。
这样处理的原因是:减少redis查询。当通过redis对day进行SETBIT后,需要再次获取补签之后的签到表,计算连续签到次数。而我们手动模拟签到,后续直接计算bitmap,可以减少一次查询。
4. 执行用户补签,更新用户签到信息
go
// makeupCard 执行用户补签,更新用户签到信息
func makeupCard(bitmap []byte, day int, userID int64) error {
// 1. 获取补签后的连续签到次数
continuousDays := getContinuousDays(bitmap)
pipe := redisClient.Pipeline()
// 设置签到位图
pipe.SetBit(ctx, tools.GenBitmapKey(userID, timeStatus.YearMonth), int64(day-1), 1)
// 更新签到总数
pipe.Incr(ctx, tools.GenSignCountKey(userID))
// 获取连续签到key
continuousKey := tools.GenContinuousKey(userID)
// 更新连续签到总数
pipe.Set(ctx, continuousKey, continuousDays, 0)
// 更新连续签到的过期时间
pipe.ExpireAt(ctx, continuousKey, tools.NextExpireTime())
// 执行
if _, err := pipe.Exec(ctx); err != nil {
return handleRedisError(err)
}
return nil
}
pipe.Set(ctx, continuousKey, continuousDays, 0)
: 最后一个参数是过期时间,设置为 0
表示键值对永不过期。
与签到的更新流程类似,此处不再赘述。
4.5 运行效果
五、总结
我们使用Redis和Go语言实现一个简单的签到和补卡功能。
- 介绍了Go语言的redis客户端库、二进制运算和json编码等技术,以及如何使用它们来处理和展示用户的签到情况。
- 介绍了Redis的位图、字符串、过期时间和管道等数据结构和功能,以及如何使用它们来存储和操作用户的签到信息。
- 介绍了使用Redis的过期时间(expire)功能来设置连续签到天数的有效期,以及如何使用TTL命令来获取剩余的过期时间。
- 介绍了如何使用Go语言的redis客户端库(go-redis)来与Redis服务器进行交互,以及如何处理错误和结果。