高并发系统设计 --基于bitmap的用户签到

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

业务需求分析

一般像微博各种社交软件游戏等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,
	}
}

在这里插入图片描述

把这个返回给前端去判断显示页面。

可以看到代码是完美运行且成功的。但是我没有在代码里面写缓存策略这个可以单独做成一个服务所以没写。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6