业务需求分析
一般像微博,各种社交软件,游戏等APP,都会有一个签到功能,连续签到多少天,送什么东西,比如:
- 签到1天送10积分,连续签到2天送20积分,3天送30积分,4天以上均送50积分等
- 如果连续签到中断,则重置计数,每月初重置计数
- 显示用户某个月的签到次数
高并发流量削峰
产品层策略,前端实现
当一毫秒内有百万级用户签到可能会造成服务器的压力,但是从产品层可以解决这个问题,我点开一个APP的时候,我点开签到,会弹出一个框,这个弹框的过程无形中进行了流量分散。其次,签到这种业务并发不会很高
缓存设计
这里缓存采用的数据结构毫无疑问是比特位图(bitmap)。
Redis-bitmap
比特位图是基于redis基本数据结构string
的一种高阶数据类型。Bitmap支持最大位数2^32位。计算了一下,使用512M的内存就可以存储多大42.9亿的字节信息(2 ^32 -> 4294967296)。
它由一组bit位组成,每个bit位有0或者1两个状态,虽然内部还是采用string类型存储。
使用方法(只是简单介绍部分指令)
# 设置值,value只接受0或者1
setbit key offset value
# 获取值
getbit key offset
# start和end非必填,不写的话,查询的是key里面含有value=1的总共有多少个
bitcount key [start] [end]
如何基于bitmap来进行业务实现?
签到
想法一:把日期直接作为偏移量,这样很方便:
# 2023年1月15日1314号用户签到了
setbit user:1314 20230115 1
本来以为这个想法很好的,因为bitmap完全可以承载20230115,但是后来仔细一想,大概20230115个比特位是被浪费的,因为现在已经2023年了,前面的年份已经不作数了20230115个比特位也就是2528字节。浪费非常严重。因此要想实现的话,必须手动编写程序改变基准值,我们可以以2022年为基准,算差值就可以了,这样前面就不会浪费了。
想法二:
# 2023年1月15日1314号用户签到了
SETBIT user:1314:2023:01 14 1
这样统计实际上也是非常优雅的。因为这样只会用得到几个比特位。
我个人认为想法一更好,理由如下:
- 两种方法占用的字节是0-3字节,主要的存储空间反而是redis字符串类型的SDS,所以在存储上实际上是忽略不计的。
- 但是第一种方式键是固定住的,不管先在是2023年1月还是2月还是3月还是3000年,键都是一样的。只是值不一样。
- 而第二种键是动态的,换一个月份,换一个年份就要把键改变。我例如现在是2023年4月份,我4月份的信息在缓存里面,然后我的用户现在马上查看3月份,2月份,1月份,2022年的很多签到信息,那么缓存过期了,就全部落库差了,增加很多IO,虽然单个用户的行为在庞大的用户体量面前是毫无意义的。但是骆驼往往是被最后一颗稻草压死的。选择第一个方法可以减少MySQL查询,缓存一次就全部都缓存了。
- 第一种比较适合应对用户连续签到多少天的场景。例如你从1月20日连续签到了30天,如果是第一种方式的话就很难去应对的。
但是下面的代码演示仍然是第二种方法,因为第二种方法比较好编码,第一种方法编码困难,而且计算量大,各有利弊,如果计算量太大不见得会很高效。
得到连续签到天数
从最后一次签到开始向前统计,直到遇到第一次未签到为止,就是连续签到天数。
如何得到本月到今天为止所有的签到数据
使用BITFIELD
命令。redis3.2后新增了一个bitfield命令,可以一次对多个位进行操作.这个指令有三个子指令,get,set,incrby,都可以对指定位片段进行读写,但最多只能处理64个连续的位,如超过64位,则要使用多个子指令,bitfield可以一次执行多个子指令.
#从w的第一个位开始取4个位(0110),结果为无符号数(u)
bitfield w get u4 0
#从w的第一个位开始取4个位(0110),结果为有符号数(i)
bitfield w get i4 0
bitmap还可以做哪些业务?
判断用户登录状态
Bitmap 提供了 GETBIT、SETBIT
操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。
只需要一个 key = login_status 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT
判断对应的用户是否在线。 50000 万 用户只需要 6 MB 的空间。
假如我们要判断 ID = 10086 的用户的登陆情况:
第一步,执行以下指令,表示用户已登录。
SETBIT login_status 10086 1
第二步,检查该用户是否登陆,返回值 1 表示已登录。
GETBIT login_status 10086
第三步,登出,将 offset 对应的 value 设置成 0。
SETBIT login_status 10086 0
等等,其实bitmap可以干的事情很多,本质是要了解 这个数据结构以及应用方法。
存储设计
这个签到信息必然要进入MySQL存储层。我们使用多级缓存。
redis+MySQL,修改,写入数据使用rabbitmq进行异步削峰,这都是老套路了,三板斧。
数据表设计
积分表:
跟在用户表里面。
签到信息表:
/*
Navicat Premium Data Transfer
Source Server : localhost_3306
Source Server Type : MySQL
Source Server Version : 80028 (8.0.28)
Source Host : localhost:3306
Source Schema : kaoyanyun_user
Target Server Type : MySQL
Target Server Version : 80028 (8.0.28)
File Encoding : 65001
Date: 15/01/2023 12:54:16
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sign
-- ----------------------------
DROP TABLE IF EXISTS `sign`;
CREATE TABLE `sign` (
`id` bigint NOT NULL COMMENT '主键',
`user_id` bigint NULL DEFAULT NULL COMMENT '用户ID',
`year` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`month` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`day` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
简单设计即可,主要取决于业务需求。
代码落地
这里给出Go的代码实现,因为Java的生态已经很好了,没必要了。
我们这里假设用户每签到一天送1积分。
先给出一些无关紧要的东西
常量:
// UserCheckIn 签到的key
UserCheckIn = "usercheckin:"
请求和回复:
// Response 通用Response
type Response struct {
Status int `json:"status"`
Msg string `json:"msg"`
}
type CheckInRequest struct {
UserId int64 `json:"userId"`
Year string `json:"year"`
Month string `json:"month"`
Day string `json:"day"`
}
采用MVC代码结构思想:
签到:
api层:
func CheckIn(ctx *gin.Context) {
// TODO 根据JWT,或者其他的什么东西获得用户的ID,这个得根据你的业务来
userId := int64(1) // 我们这里就直接随便给一个ID就可以了
// 获取目前的年份和月份还有天
year := time.Now().Format("2006")
month := time.Now().Format("01")
day := time.Now().Format("02")
// control层把东西发给service层进行业务逻辑开发
req := &request.CheckInRequest{
UserId: userId,
Year: year,
Month: month,
Day: day,
}
resp := service.CheckIn(req)
ctx.JSON(resp.Status, resp.Msg)
}
service层:
func CheckIn(request *request.CheckInRequest) *response.Response {
userId := request.UserId
year := request.Year
month := request.Month
day := request.Day
d, _ := strconv.ParseInt(day, 10, 64)
// 组装redis的key
id := strconv.FormatInt(userId, 10)
key := redis.UserCheckIn + id
// 拼装
// 2023:01:15 2023 01 15
value := fmt.Sprintf(":%s:%s", year, month)
key = key + value
// 签到的代码
redis2.Rdb.SetBit(redis2.RCtx, key, d-1, 1)
// 设置过期时间, 30天,可以长一点
redis2.Rdb.Expire(redis2.RCtx, key, time.Hour*24*30)
// 缓存层已经设置,接下来使用消息队列异步存储到存储层MySQL
message := rabbitmq.CheckInMessage{
UserId: userId,
Year: year,
Month: month,
Day: day,
}
mq := rabbitmq.NewRabbitMQTopics("sign", "sign-", "hello")
mq.PublishTopics(message)
return &response.Response{
Status: http.StatusOK,
Msg: "用户签到成功",
}
}
func InitSignConsumer() {
mq := rabbitmq.NewRabbitMQTopics("sign", "sign-", "hello")
go mq.ConsumeTopicsCheckIn()
}
路由:
// 签到
r.GET("/check", api.CheckIn)
// 查看签到信息
r.GET("/getSign", api.GetSign)
model:
// Sign 签到
type Sign struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
Year string `json:"year"`
Month string `json:"month"`
Day string `json:"day"`
}
func (Sign) TableName() string {
return "sign"
}
// User 积分
type User struct {
Id int64 `json:"id"`
UserName string `json:"userName"`
PasswordDigest string `json:"passwordDigest"`
Phone string `json:"phone"`
Integral int `json:"integral"`
}
func (User) TableName() string {
return "user"
}
rabbitmq里面的MySQL业务逻辑:
go func() {
for delivery := range msgs {
// 消息逻辑处理,可以自行设计逻辑
body := delivery.Body
message := &CheckInMessage{}
err = json.Unmarshal(body, message)
if err != nil {
log.Println(err)
}
userId := message.UserId
year := message.Year
month := message.Month
day := message.Day
worder, _ := util.NewWorker(1)
id := worder.GetId()
sign := &model.Sign{
Id: id,
UserId: userId,
Year: year,
Month: month,
Day: day,
}
// 插入数据库
mysql.MysqlDB.Debug().Create(sign)
// 根据签到信息赠送相应积分
err = mysql.MysqlDB.Exec("update user set integral = integral + 1 where id = ?", userId).Error
if err != nil {
log.Println(err)
}
// 为false表示确认当前消息
delivery.Ack(false)
}
}()
可以看到签到是成功的。
可以看到已经递增了。
读取签到信息:
请求:
type GetSignRequest struct {
UserId int64 `json:"userId"`
Year string
Month string
}
api层:
func GetSign(ctx *gin.Context) {
userId := int64(1)
year := ctx.Query("year")
month := ctx.Query("month")
req := &request.GetSignRequest{
UserId: userId,
Year: year,
Month: month,
}
resp := service.GetSign(req)
ctx.JSON(resp.Status, resp.Msg)
}
service层:
func GetSign(request *request.GetSignRequest) *response.Response {
userId := request.UserId
id := strconv.FormatInt(userId, 10)
year := request.Year
month := request.Month
// 拼接redis的key
key := redis.UserCheckIn + id + ":" + year + ":" + month
fmt.Println(key)
// 通过bitfield命令返回整个的数组
// 数组的第一个元素就是一个int64类型的值,我们通过位运算进行操作
s := fmt.Sprintf("i%d", 31)
fmt.Println(s)
result, err := redis2.Rdb.BitField(redis2.RCtx, key, "get", s, 0).Result()
if err != nil {
log.Println(err)
}
num := result[0]
fmt.Println(num)
arr := make([]int64, 31)
for i := 0; i < 31; i++ {
// 让这个数字与1做与运算,得到数据的最后一个比特
if (num & 1) == 0 {
// 如果为0,说明未签到
arr[i] = 0
} else {
// 如果不为0,说明已经签到了,计数器+1
arr[i] = 1
}
// 把数字右移动一位,抛弃最后一个bit位,继续下一个bit位
num = num >> 1
}
return &response.Response{
Status: http.StatusOK,
Msg: "获取信息成功",
Data: arr,
}
}
把这个返回给前端去判断,显示页面。
可以看到代码是完美运行且成功的。但是我没有在代码里面写缓存策略,这个可以单独做成一个服务,所以没写。