两个Redis数据结构搞定签到和UV统计:Bitmap与HyperLogLog实战

两个Redis数据结构搞定签到和UV统计:Bitmap与HyperLogLog实战

引言

做后端开发,签到和UV统计几乎是绕不开的需求。表面上看都不复杂,但一旦用户量上来,用什么数据结构存储就成了关键------存错了,内存直接爆炸。

这篇文章就解决两个实际问题:

  • 签到: 一个用户一年365天,每天签没签,怎么用最少的内存存下来?
  • UV统计: 一篇博客一天有10万次访问,怎么快速算出有多少个"独立访客"?

答案分别是Redis的 BitmapHyperLogLog

涉及的Redis命令

命令 用途
SETBIT 设置位图中某一位的值(签到写入)
BITFIELD 一次读取一段位数据(连续签到统计)
EXPIRE 设置过期时间,防止内存无限增长
PFADD 向HyperLogLog中添加元素(UV上报)
PFCOUNT 获取HyperLogLog的估算计数(UV查询)

键的设计

复制代码
签到:  sign:<userId>:<yyyyMM>     例如 sign:1001:202605
UV:    UV:<yyyyMMdd>:<blogId>     例如 UV:20260519:42

签到按 用户+月份 划分,每个月一个位图;UV按 日期+博客 划分,每天每篇博客一个HyperLogLog。


一、用户签到:Bitmap

为什么用Bitmap

签到的本质就是"今天签了还是没签"------只有0和1两种状态。一个字节有8个位,一个月最多31天,也就是说 一个用户一个月的签到数据只需要4个字节

对比一下:如果用Set存储签到日期,一天一个key,31天就是31个key,内存开销差了几个数量级。

签到写入

go 复制代码
func SignIn(c *gin.Context) {
	userId := c.Param(consts.UserIdKey)
	userIdInt, err := strconv.Atoi(userId)
	if err != nil || userIdInt <= 0 {
		response.Error(c, response.ErrValidation, "invalid userId")
		return
	}

	key := signKeyPre + userId + ":" + time.Now().Format(yearMonthFormat)
	dayOfMonth := time.Now().Day()

	// SETBIT key offset value
	// offset = dayOfMonth - 1, 因为位图从0开始
	// 返回值是该位修改前的旧值,可以用来判断是否重复签到
	oldBit, err := db.RedisDb.SetBit(context.Background(), key, int64(dayOfMonth)-1, 1).Result()
	if err != nil {
		slog.Error("Database error", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	if oldBit == 1 {
		response.Success(c, "already signed in today")
		return
	}

	// 首次签到,设置3个月过期
	db.RedisDb.Expire(context.Background(), key, expireDuration)
	response.Success(c, "sign in successful")
}

核心就一行: SETBIT key offset 1。offset用dayOfMonth - 1,这样1号对应第0位,31号对应第30位。

注意 : 这里SETBITEXPIRE是两步操作,不是原子的。如果对一致性要求更高,可以用Lua脚本或TxPipeline把它们合成一步。

连续签到统计

签到写进去之后,通常还要统计"连续签到几天"。思路很直接:

  1. BITFIELD一次性读出从1号到今天的所有位
  2. 从今天往前数,遇到第一个0就停
go 复制代码
func ContinuousSigninStatistics(c *gin.Context) {
	userId := c.Param(consts.UserIdKey)
	userIdInt, err := strconv.Atoi(userId)
	if err != nil || userIdInt <= 0 {
		response.Error(c, response.ErrValidation, "invalid userId")
		return
	}

	key := signKeyPre + userId + ":" + time.Now().Format(yearMonthFormat)
	dayOfMonth := time.Now().Day()

	// BITFIELD key GET u<bitwidth> offset
	// u11 表示无符号11位整数,从offset=0开始读取
	res, err := db.RedisDb.BitField(context.Background(), key, "GET", "u"+strconv.Itoa(dayOfMonth), 0).Result()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			response.Error(c, response.ErrNotFound, "用户没有签到记录")
			return
		}
		slog.Error("BitField error", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	if len(res) == 0 {
		response.Success(c, signInStats{Count: 0})
		return
	}

	count := getSignInStats(res[0], dayOfMonth)
	response.Success(c, signInStats{Count: count})
}

func getSignInStats(num int64, dayOfMonth int) int {
	var count int
	for day := dayOfMonth; day >= 1; day-- {
		bitPos := day - 1
		if (num>>bitPos)&1 == 1 {
			count++
		} else {
			break
		}
	}
	return count
}

BITFIELD的好处是 一次网络请求就能拿到所有位 ,不用逐位GETBIT。拿到整数后,用位运算从高位往低位遍历,遇到0就break,就是连续签到天数。


二、UV统计:HyperLogLog

为什么用HyperLogLog

UV统计的核心需求是 去重计数:同一个人访问10次,只算1个。

如果用Set存userId,10万UV就是10万个元素,每个元素至少占几字节,加起来轻松MB级别。而HyperLogLog无论多少个元素, 固定只占12KB内存,误差在0.81%以内。

对于UV这种"不需要精确到个位,但需要快速出结果"的场景,HyperLogLog是最佳选择。

UV上报

go 复制代码
type uvStatistics struct {
	BlogId int `json:"blogId"`
	UserId int `json:"userId"`
}

func AddUniqueVisitor(c *gin.Context) {
	var uv uvStatistics
	err := c.ShouldBindJSON(&uv)
	if err != nil {
		slog.Error("bind json error", "error", err)
		response.Error(c, response.ErrBind)
		return
	}
	if uv.BlogId <= 0 || uv.UserId <= 0 {
		response.Error(c, response.ErrValidation, "invalid blogId or userId")
		return
	}

	now := time.Now().Format(dateFormat)
	err = db.RedisDb.PFAdd(context.Background(), buildUVKey(now, strconv.Itoa(uv.BlogId)), uv.UserId).Err()
	if err != nil {
		slog.Error("add unique visitor error", "error", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, nil)
}

PFADD天然去重,同一个userId多次添加不会重复计数。

UV查询

go 复制代码
func GetUniqueVisitor(c *gin.Context) {
	blogId := c.Param(consts.BlogIdKey)
	date := c.Query("date")

	if date == "" {
		date = time.Now().Format(dateFormat)
	}
	if blogId == "" {
		response.Error(c, response.ErrValidation, "blogId is required")
		return
	}

	if _, err := time.Parse(dateFormat, date); err != nil {
		response.Error(c, response.ErrValidation, "invalid date format, expected YYYYMMDD")
		return
	}
	if _, err := strconv.Atoi(blogId); err != nil {
		response.Error(c, response.ErrValidation, "invalid blogId format")
		return
	}

	res, err := db.RedisDb.PFCount(context.Background(), buildUVKey(date, blogId)).Result()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			response.Success(c, 0)
			return
		}
		slog.Error("get unique visitor count error", "error", err, "blogId", blogId, "date", date)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, res)
}

查询就是PFCOUNT,返回的是估算值,不是精确值。对UV场景来说完全够用。

提醒: 按天统计的key记得加TTL,否则Redis里的数据会越积越多。


总结

场景 数据结构 内存开销 精确度 核心命令
签到 Bitmap 每用户每月约4字节 精确 SETBIT / BITFIELD
UV统计 HyperLogLog 固定12KB 误差约0.81% PFADD / PFCOUNT

一句话: 签到用Bitmap,UV用HyperLogLog。前者精确且极致省内存,后者用微小的误差换来固定的内存开销,各取所需。

相关推荐
悠仁さん3 小时前
数据结构 栈与队
数据结构
Plan-C-3 小时前
二叉树的遍历
java·数据结构·算法
历程里程碑3 小时前
54 深入解析poll多路复用技术
java·linux·服务器·开发语言·前端·数据结构·c++
橙子圆1234 小时前
Redis知识7之主从复制
数据库·redis·缓存
一切皆是因缘际会4 小时前
AI Agent落地困局与突破:从技术架构到企业解析
数据结构·人工智能·算法·架构
qeen874 小时前
【算法笔记】各种常见排序算法详细解析(下)
c语言·数据结构·c++·笔记·学习·算法·排序算法
欢璃5 小时前
笔试强训练习
java·开发语言·jvm·数据结构·算法·贪心算法·动态规划
青柠代码录5 小时前
【Redis】数据类型:String
数据库·redis·缓存
Cthy_hy6 小时前
并查集(Disjoint Set Union):巧判「连通聚类关系」的极简利器
数据结构·算法