两个Redis数据结构搞定签到和UV统计:Bitmap与HyperLogLog实战
引言
做后端开发,签到和UV统计几乎是绕不开的需求。表面上看都不复杂,但一旦用户量上来,用什么数据结构存储就成了关键------存错了,内存直接爆炸。
这篇文章就解决两个实际问题:
- 签到: 一个用户一年365天,每天签没签,怎么用最少的内存存下来?
- UV统计: 一篇博客一天有10万次访问,怎么快速算出有多少个"独立访客"?
答案分别是Redis的 Bitmap 和 HyperLogLog。
涉及的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位。
注意 : 这里SETBIT和EXPIRE是两步操作,不是原子的。如果对一致性要求更高,可以用Lua脚本或TxPipeline把它们合成一步。
连续签到统计
签到写进去之后,通常还要统计"连续签到几天"。思路很直接:
- 用
BITFIELD一次性读出从1号到今天的所有位 - 从今天往前数,遇到第一个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。前者精确且极致省内存,后者用微小的误差换来固定的内存开销,各取所需。