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

业务需求分析

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

在这里插入图片描述

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

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

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
以下是使用 Delphi-OpenCV 库在 Delphi XE 中将 IplImage 对象转换为Bitmap 对象的详细代码: ```delphiuses OpenCV_Core, OpenCV_ImageProc, // Delphi-OpenCV 库单元 Vcl.Graphics; // VCL 图形单元 function IplImageToBitmap(const Image: pIplImage): TBitmap; var Depth, Channels: Integer; LineSize: Integer; ImageData, SrcLine, DestLine: Pointer; Bitmap: TBitmap; Row, Col: Integer; Data: Byte; begin Depth := Image.depth; Channels := Image.nChannels; LineSize := Image.width * Channels; // 分配 Bitmap 对象 Bitmap := TBitmap.Create; Bitmap.PixelFormat := pf24bit; Bitmap.Width := Image.width; Bitmap.Height := Image.height; // 按行遍历 IplImage 数据并转换为 TBitmap 数据 ImageData := Image.imageData; for Row := 0 to Image.height - 1 do begin SrcLine := ImageData + Row * Image.widthStep; DestLine := Bitmap.ScanLine[Row]; case Depth of IPL_DEPTH_8U: begin for Col := 0 to Image.width - 1 do begin Data := PByte(SrcLine + Col * Channels)^; PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; IPL_DEPTH_8S: begin for Col := 0 to Image.width - 1 do begin Data := Byte(PShortInt(SrcLine + Col * Channels)^); PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; IPL_DEPTH_16U: begin for Col := 0 to Image.width - 1 do begin Data := Byte(PWord(SrcLine + Col * Channels)^ shr 8); PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; IPL_DEPTH_16S: begin for Col := 0 to Image.width - 1 do begin Data := Byte(PShortInt(SrcLine + Col * Channels)^ shr 8 + 128); PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; IPL_DEPTH_32S: begin for Col := 0 to Image.width - 1 do begin Data := Byte(PInteger(SrcLine + Col * Channels)^ shr 24); PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; IPL_DEPTH_32F: begin for Col := 0 to Image.width - 1 do begin Data := Byte(PSingle(SrcLine + Col * Channels)^ * 255); PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; IPL_DEPTH_64F: begin for Col := 0 to Image.width - 1 do begin Data := Byte(PDouble(SrcLine + Col * Channels)^ * 255); PByte(DestLine + Col * 3)^ := Data; PByte(DestLine + Col * 3 + 1)^ := Data; PByte(DestLine + Col * 3 + 2)^ := Data; end; end; end; end; Result := Bitmap; end; ``` 使用方法: ```delphi var Image: pIplImage; Bitmap: TBitmap; begin // 加载图像到 Image 变量中 Image := cvLoadImage('image.jpg'); // 将 IplImage 对象转换为 TBitmap 对象 Bitmap := IplImageToBitmap(Image); // 将 TBitmap 对象显示在 TImage 组件上 Image1.Picture.Assign(Bitmap); // 释放 IplImage 对象内存 cvReleaseImage(@Image); end; ``` 需要注意的是,由于 Delphi-OpenCV 库中的 IplImage 对象是指针类型的,因此需要传入指针的指针作为参数。在使用完毕后,需要手动释放 IplImage 对象的内存。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值