Redis应用—掘金签到&补卡 | 青训营

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 实现逻辑

  1. 从redis里获取签到表里指定偏移量的bit的值,检查用户是否已签到
  2. 执行签到操作,更新用户签到信息
    1. 设置签到位图
    2. 更新累计签到总数
    3. 更新连续签到总数以及过期时间

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里的成员函数,用来控制签到处理的流程

  1. 检查用户是否签到
  2. 执行签到操作,即更新签到信息
  3. 签到成功后,打印签到信息

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生成指定的key
  • timeStatus变量是自定义的时间管理对象,用于处理签到时的时间问题,并通过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 实现逻辑

  1. 从redis里读取签到信息
  2. 将签到表包装成JSON
  3. 输出到控制台

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个字节
    1. 通过循环将该字节右移X位,可以依次获取每一位
    2. 然后在跟1做与运算,就可以获取该位是0还是1
    3. 因为是从高位往低位获取,所以在存储的时候要从数组的后面往前写。

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 实现逻辑

  1. 从redis里获用户的签到表bitmap
  2. 判断用户补签的day是否已经签到了
  3. 执行用户补签操作,更新用户签到信息

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服务器进行交互,以及如何处理错误和结果。
相关推荐
Find21 天前
MaxKB 集成langchain + Vue + PostgreSQL 的 本地大模型+本地知识库 构建私有大模型 | MarsCode AI刷题
青训营笔记
理tan王子21 天前
伴学笔记 AI刷题 14.数组元素之和最小化 | 豆包MarsCode AI刷题
青训营笔记
理tan王子21 天前
伴学笔记 AI刷题 25.DNA序列编辑距离 | 豆包MarsCode AI刷题
青训营笔记
理tan王子21 天前
伴学笔记 AI刷题 9.超市里的货物架调整 | 豆包MarsCode AI刷题
青训营笔记
夭要7夜宵23 天前
分而治之,主题分片Partition | 豆包MarsCode AI刷题
青训营笔记
三六24 天前
刷题漫漫路(二)| 豆包MarsCode AI刷题
青训营笔记
tabzzz25 天前
突破Zustand的局限性:与React ContentAPI搭配使用
前端·青训营笔记
Serendipity5651 个月前
Go 语言入门指南——单元测试 | 豆包MarsCode AI刷题;
青训营笔记
wml1 个月前
前端实践-使用React实现简单代办事项列表 | 豆包MarsCode AI刷题
青训营笔记
用户44710308932421 个月前
详解前端框架中的设计模式 | 豆包MarsCode AI刷题
青训营笔记