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

news2024/11/16 22:01:56

业务需求分析

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

在这里插入图片描述

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/164464.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Qt之QDrag的使用(含源码+注释)

一、效果示例图 提示&#xff1a;主控件&#xff08;CDragTest界面&#xff0c;UI中中包含CWidget界面&#xff09;&#xff1b;子控件&#xff08;CWidget界面&#xff0c;在CDragTest界面添加&#xff09; 提示&#xff1a;源码中拖拽数据设置的文本不同&#xff0c;是博主准…

【ONE·C || 分支循环】

总言 C语言&#xff1a;分支循环。 文章目录总言1、分支语句1.1、if语句1.1.1、基本格式1.1.2、逻辑真假与悬空else1.1.3、练习1.2、switch语句1.2.1、基本格式&#xff1a;break、case、default1.2.2、练习&#xff1a;switch语句嵌套2、循环语句2.1、while循环2.1.1、基本格式…

uniapp实现界面可任意拖动小图标

uniapp实现界面可任意拖动小图标一、问题&#xff1a;二、解决步骤2.1 根据uni-app官方提供的案例&#xff0c;创建组件2.2 在需要的界面引入组件使用额外注意一、问题&#xff1a; 例如购物车小图标可任意拖动 二、解决步骤 2.1 根据uni-app官方提供的案例&#xff0c;创建…

Kubernetes教程(二)---集群网络之 Flannel 核心原理

来自&#xff1a;指月 https://www.lixueduan.com 原文&#xff1a;https://www.lixueduan.com/posts/kubernetes/02-cluster-network/ 本文主要记录了 Kubernetes 集群网络方案之 Flannel 核心原理详解&#xff0c;包括其隧道方案中的两种&#xff1a;UDP 实现和 VXLAN 实现…

Mysql之增删改查

这里的增删改查主要是对应表中的数据&#xff0c;不像前一篇那个列类型&#xff0c;耳机具体的哪一条数据 Insert 其实我们前面都用过好多次了 比如下面那个 可以 关于那个表名后面加不加&#xff08;列类型&#xff09;&#xff0c;下面有解释 INSERT INTO shanpin VALUES…

关于yolov8一些训练的情况

U神出品了最新的yolov8&#xff0c;从公开的参数量来看确实很优秀&#xff01;&#xff01;&#xff01;&#xff01;比如下图得一些指标&#xff1a; 可以看到s模型640得map已经达到了44.9&#xff0c;v8n得map也已经达到了37.3&#xff0c;很强了&#xff0c;但是实际上是怎么…

Python爬虫之Scrapy框架系列(3)——项目实战【某瓣top250电影信息获取】

目录&#xff1a;1. 某瓣电影top250首页电影信息的获取&#xff01;1.创建项目&#xff1a;2.创建爬虫文件&#xff1a;3.运行爬虫文件&#xff1a;4.设置请求头&#xff1a;5.获取到电影名字&#xff1a;5.1 使用shell交互式平台&#xff1a;5.1.1 首先&#xff1a;打开我们的…

239页10万字“联、管、用”三位一体雪亮工程整体建设方案

【版权声明】本资料来源网络&#xff0c;知识分享&#xff0c;仅供个人学习&#xff0c;请勿商用。【侵删致歉】如有侵权请联系小编&#xff0c;将在收到信息后第一时间删除&#xff01;完整资料领取见文末&#xff0c;部分资料内容&#xff1a; 目录 1、 项目概述 1.1 项目背…

用R语言绘制泰勒级数的逼近过程

文章目录泰勒级数是如何被发现的用图像理解Taylor级数的逼近过程前情提要 R语言微积分极限π,e,γ\pi, e, \gammaπ,e,γ洛必达法则连续性和导数数值导数差商与牛顿插值方向导数 泰勒级数是如何被发现的 如果我是泰勒&#xff0c;我会把思考的起点建立在这样的一个等式上 f(n…

Windows10电脑重装系统详细步骤(纯净版)

目录 前言&#xff1a; 一、准备工作 二、下载pe工具 三、下载系统镜像ISO文件 获取方式一 获取方式二 获取方式三 四、进入pe系统 1.检查以上的准备工作是否完成 2.然后拔出来u盘插入要重装的电脑上面 3.然后按电源键开机&#xff08;不能点击重启&#xff01;&…

【Git 从入门到精通】使用Git将本地代码推送到Github

文章目录一、创建远程库二、Git操作远程库1.推送代码2.克隆代码3.拉取代码4.Pull request5.常用命令总结一、创建远程库 打开github.com&#xff0c;点击右上角加号&#xff0c;点击第一个选项。 填写库的基本信息&#xff0c;如果你想代码开源就选择public&#xff0c;否则就…

开发模型和测试模型

开发模型瀑布模型特点&#xff1a;线性结构&#xff0c;每个阶段只执行一次&#xff0c;必须完成上一个才能执行下一个。是其他模型的基础框架缺点&#xff1a;测试后置&#xff0c;1&#xff09;前面各个阶段的遗留的风险推迟到测试阶段才被发现&#xff0c;导致项目大面积返工…

【7】SCI易中期刊推荐——图像处理领域(中科院4区)

🚀🚀🚀NEW!!!SCI易中期刊推荐栏目来啦 ~ 📚🍀 SCI即《科学引文索引》(Science Citation Index, SCI),是1961年由美国科学信息研究所(Institute for Scientific Information, ISI)创办的文献检索工具,创始人是美国著名情报专家尤金加菲尔德(Eugene Garfield…

【LGR-(-17)】洛谷入门赛 #8个人思考

T306713 Hello, 2023 题目背景 Goodbye, 2022 Hello, 2023 题目描述 某 E 在 2022 年的幸运数字是 xxx&#xff0c;这个数可能是正的&#xff0c;也可能是负的。 某 E 想要知道 xmod2023x \bmod 2023xmod2023 的值。其中&#xff0c;mod\bmodmod 是取模操作。也就是说&am…

数据结构:线性表的顺序表示和实现

在实际应用程序中涉及的线性表的基本操作都需要针对线性表的具体存储结构加以实现。线性表可以有两种存储表示方法:顺序存储表示和链式存储表示。下面我们先说说顺序存储表示。 1、顺序表——线性表的顺序存储表示 在计算机中表示线性表的最简单的方法是用一组地址连续的存储…

Linux:自动化构建工具make/Makefile

文章目录一.前言二.Makefile如何写入/make命令使用2.1清楚依赖关系和依赖方法2.2删除文件2.3Makefile中的关键字.PHONY2.4一个小补充一.前言 在此之前我们已经可以用vim编写代码和用gcc编译代码。但是如果现在要写一个大型项目&#xff0c;一下子写了很多源文件&#xff0c;在…

C. Zero Path(DP)

Problem - 1695C - Codeforces 给你一个有n行和m列的网格。我们用(i,j)表示第i(1≤i≤n)行和第j(1≤j≤m)列的方格&#xff0c;用aij表示那里的数字。所有的数字都等于1或等于-1。 你从方格&#xff08;1,1&#xff09;开始&#xff0c;每次可以向下或向右移动一个方格。最后&…

基于结点的数据结构——链表(单链表双向循环链表)| 附完整源码 | C语言版

本章内容 1.什么是链表 2.链表常见几种形式 3.无头单向非循环链表的实现 3.1结点结构的定义 3.2函数接口的实现 3.2.1尾插 3.2.2尾删 4. 带头双向循环链表的实现 4.1结点结构的定义 4.2函数接口的实现 5.两种链表的差异 ①尾插与尾删的时间复杂度 ②头插与头删的时…

Ai 作图 stable-diffusion-webui prompt

文章参考了 prompt指导手册 &#xff1a; https://strikingloo.github.io/stable-diffusion-vs-dalle-2 https://prompthero.com/stable-diffusion-prompt-guide 一般来说&#xff0c;最好的稳定扩散提示会有这样的形式&#xff1a; “ [主要主题]的[图片类型] &#xff0…

C语言-文件操作(13.1)

目录 思维导图&#xff1a; 1. 为什么使用文件 2. 什么是文件 2.1 程序文件 2.2 数据文件 2.3 文件名 3. 文件的打开和关闭 3.1 文件指针 3.2 文件的打开和关闭 4. 文件的顺序读写 4.1 对比一组函数 5. 文件的随机读写 5.1 fseek 5.2 ftell 5.3 rewind 6. 文本…