☰
Redis 从 0 到 1(二十):1 亿用户签到一年只占 44MB——Bitmap 的 Go 实现
2026/11/4 2:11:32 网站建设 项目流程

我不会起名字322· 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • Redis 从 0 到 1(二十):1 亿用户签到一年只占 44MB——Bitmap 的 Go 实现
    • 一、现象:一个签到功能吃掉 30GB
    • 二、Bitmap 方案:每用户每年 365 bit(收藏点 1)
    • 三、连续签到天数:位运算一次算清(收藏点 2)
    • 四、月活统计:BITCOUNT 一把梭
    • 五、内存对比与命令清单(收藏点 3)
    • 六、排查步骤
    • 七、总结

Redis 从 0 到 1(二十):1 亿用户签到一年只占 44MB——Bitmap 的 Go 实现

上一篇《Redis 从 0 到 1(十九):JWT 还是 Redis Session?登录态存储的 Go 实现》我们实现了登录态,链接:https://blog.csdn.net/2501_92769340/article/details/167399819 。
本篇是实战弧线的收官:签到打卡。这个功能简单到产品经理一句话能说完,但存储方案选错,内存账单能差出三个数量级。

一、现象:一个签到功能吃掉 30GB

第一版签到用 Hash 实现:每天HSET signin:20261010 <uid> 1。粗略算账:1 亿用户、每人每年签到 200 天,就是 200 亿个 field。每个 field 即使只存 1,Redis 的 Hash 条目开销也要 60 字节左右——200 亿 × 60B ≈ 12TB。就算换成"每用户一个 key、每天一个 field",也是几十 GB 起步。

而信息量的本质是什么?“uid=1001 在 2026-10-10 签到了”——一个布尔值,1 个 bit 就够了。Redis 的 Bitmap 就是干这个的。

二、Bitmap 方案:每用户每年 365 bit(收藏点 1)

设计:signin:2026:<uid>是一个 Bitmap(底层 String),第 N 位 = 第 N 天是否签到:

signin:2026:1001 = 00110110011...(365 bit = 45.6 字节) 1 亿用户 × 45.6B ≈ 4.56GB/年 —— 对比 Hash 方案的 30GB+,省一个数量级 且 String 有编码优化(< 64 字节走 embstr),小用户实际占用更低
packagemainimport("context""fmt""strconv""time""redis-0to1/redisx")funcdayOfYear(t time.Time)int64{returnint64(t.YearDay())// 1~366}// SignIn 打卡:把第 N 位设为 1funcSignIn(ctx context.Context,rdb*redis.Client,uidstring,t time.Time)error{bit:=dayOfYear(t)key:=fmt.Sprintf("signin:%d:%s",t.Year(),uid)// SETBIT 返回旧值:0=首次打卡,1=重复打卡(幂等,天然防重复发奖)old,err:=rdb.SetBit(ctx,key,bit,1).Result()iferr!=nil{returnerr}ifold==0{fmt.Println(uid,"今天首次打卡,发放奖励")}else{fmt.Println(uid,"今天已打过卡(重复请求被幂等挡掉)")}// key 年底自然过期(EXPIRE 到年底 + 余量),不用手动清untilYearEnd:=time.Date(t.Year()+1,1,1,0,0,0,0,time.UTC).Sub(t)returnrdb.Expire(ctx,key,untilYearEnd).Err()}// IsSigned 查某天是否签到funcIsSigned(ctx context.Context,rdb*redis.Client,uidstring,t time.Time)(bool,error){key:=fmt.Sprintf("signin:%d:%s",t.Year(),uid)v,err:=rdb.GetBit(ctx,key,dayOfYear(t)).Result()returnv==1,err}

三、连续签到天数:位运算一次算清(收藏点 2)

产品要展示"已连续签到 N 天"。拿到 365 bit 后,纯位运算搞定,不需要遍历:

// ContinuousDays 计算到今天为止连续签到的天数// 思路:位图右移到今天位,取反得到"未签到的天",数末尾有多少个 0funcContinuousDays(ctx context.Context,rdb*redis.Client,uidstring,t time.Time)(int,error){key:=fmt.Sprintf("signin:%d:%s",t.Year(),uid)bits,err:=rdb.Get(ctx,key).Bytes()// 取出整个位图(最多 46 字节)iferr!=nil{return0,err}varbitmapuint64fori:=len(bits)-1;i>=0&&i>=len(bits)-8;i--{// 低 64 位足够覆盖全年bitmap=bitmap<<8|uint64(bits[i])}todayBit:=uint(dayOfYear(t)-1)// 位号从 0 开始mask:=uint64(1)<<(todayBit+1)-1// 只保留 [0, today] 位x:=bitmap&maskifx&(1<<todayBit)==0{return0,nil// 今天没签,连续天数为 0(或按业务显示昨天起)}// 取反 + 1 后 & x:得到从末尾开始的连续 1 的长度n:=(^(x)+1)&x days:=0forn>0{n>>=1days++}returndays,nil}

核心技巧一句话:x & (-x)取最低位的 1,它右移的位数 +1 就是末尾连续 1 的个数。如果今天的位是 0,先按业务规则(通常回退到昨天)调整 mask。

四、月活统计:BITCOUNT 一把梭

月度签到报表 =BITCOUNT signin:2026:<uid>是该用户全年签到总数;反向问"3 月份有多少用户签过到"就要换 key 设计——活动统计用"以天为单位"的 Bitmap:

// 月活位图:bit = (uid % 8^8),1 亿用户需要 12.5MB/天rdb.SetBit(ctx,"mau:202610",uidHash(uid),1)// 月活 = BITCOUNT(一次命令,O(N) 但 12.5MB 很快)n,_:=rdb.BitCount(ctx,"mau:202610",nil).Result()

注意 BITCOUNT 的复杂度:位图越大越慢,12.5MB 约几十毫秒,别在请求路径里同步调,离线统计或缓存结果。

五、内存对比与命令清单(收藏点 3)

方案1 亿用户一年能做什么
Hash 每天一个 field~30GB+精确、可带额外字段
Bitmap 每用户一个 key~4.6GB打卡、查某日、连续天数、BITCOUNT 总数
Bitmap 每天一个 key(MAU 用)12.5MB/天计数、跨天 BITOP 交并
命令作用
SETBIT key offset 1打卡(返回旧值,幂等)
GETBIT key offset查某日
BITCOUNT key [start end]统计 1 的个数
BITOP AND/OR dest k1 k2多天/多月交并(如"连续两个月都签到的用户")
STRLEN key位图字节数(验证内存)

六、排查步骤

  1. 内存异常:签到 key 没带年底过期(EXPIRE 到 12-31+余量),跨年 key 无限累积;
  2. 连续天数不对:先GETBIT抽查近几天,再查位运算 mask 是否越界(闰年 366 天);
  3. BITCOUNT 慢:确认是离线统计场景,大位图考虑分段 BITCOUNT 再求和。

七、总结

  1. 签到 = 布尔值 = 1 bit,Bitmap 比 Hash 省一个数量级内存;
  2. SETBIT 返回旧值,天然幂等防重复发奖;
  3. 连续天数用位运算(x & -x),别遍历;
  4. 跨年 key 必须设年底过期;BITOP 让跨天统计变成一条命令。

下一篇预告:实战弧线收官,进入性能优化弧线——第一篇先解决"怎么发现慢":下一篇《P99 从 2ms 涨到 80ms,慢查询日志 5 步定位法 + Go 定时采集器》,我们把 SLOWLOG 变成一套自动告警。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询