资讯动态

亿级用户登录统计与连续签到:用Redis Bitmap把内存压缩到极致

发布时间:2026/10/23 9:34:20 来源:尧图企业网站定制
做过亿级DAU后端统计的同学应该都懂那种纠结产品要“登录用户数”要得急运营要“连续签到”要得勤数据量一上来第一版用 MySQL 表或者 Redis String 硬扛的方案很快就得推翻重做。亿级用户场景下登录统计和连续签到这两个功能难点从来不是功能本身而是“内存”。一个用户一条记录一亿条数据就是天文数字但如果一个用户只占 1 个 bit一亿用户也才 12.5MB。方向对了后面全是事半功倍。这篇文章我不讲空泛的架构理念直接拆解两层东西一是登录统计和连续签到到底在算什么问题二是怎么用 Bitmap 和 BITFIELD 这类位图结构把内存压到极致。纯工程实践向涉及的命令、计算、排坑都是我实际跑过、压过、线上验证过的东西照着抄能少走好几个月的弯路。1. 先拆解需求登录统计和连续签到到底在算什么很多方案跑偏不是因为技术不行而是没想清楚“这两个需求本质是什么”。登录统计和连续签到看着都跟用户行为有关但背后的计算模型完全不同。1.1 登录统计的本质一个“去重集合”问题登录统计最常见的形态是判断某用户今天是否登录过、今天有多少独立用户登录。往深了说这其实是一个 Set 操作——把当天所有登录过的用户 ID 丢进一个集合然后对集合做“判存在”和“计大小”。但我见过不少人第一版用关系库比如建一张 login_log 表用户每次登录插一行。一亿用户里假设日活两千万一天就是两千万行按每行 100 字节算单日 200MB一个月 6GB。这只是原始数据还没算索引和备份。这在中小规模没问题亿级规模是用不了一点。换成 Redis 里用 String 存每个用户一个 key 也是灾难。一亿个 key光是 key 本身的元数据每个 key 的指针、长度、过期时间等粗略按 60-80 字节算就要吃掉 600-800MB这还没算 value 的内存。就算你把用户 ID 拼成一个长字符串塞进一个大 key查询也要把整个字符串拉出来解析性能直接崩。所以登录统计需要的数据结构必须满足三点判存在要快、计数要快、内存要小。BitMap 就是专门为这种场景长的每个用户占一位存在性判断 O(1)计数就是数 1 的个数内存按位算一个亿用户 12.5MB。1.2 连续签到的本质一个“路径连续”问题连续签到的难点和登录统计不一样。它要的不是“今天集合里有没有你”而是“你的签到历史里从今天往前数连续有几个 1”。断一天前面的连续天数全部归零重算。这种“连续性”诉求要求数据结构能回答“第 N 天是否签到”。你可以用每天的 Bitmap 按用户 ID 查 GETBIT——今天查一次昨天查一次前天查一次直到查到 0。也可以用固定位宽的槽位直接存“连续天数”这个数值——每天签到后写一次按用户 ID 定位槽位。我最开始做签到系统时第一直觉是给每个用户存一个 int 字段表示连续天数。一亿用户每个用户一个 4 字节 int就是 400MB。如果是用 Redis Hash 分片存每个 field 还有额外开销更贵。所以连续签到要想极致省内存思路也得回到位图上来能按位存就不要按字节存更不要按对象存。1.3 两个功能放在一起设计的默契点登录统计和连续签到可以共用一套“按天分 key 的 Bitmap”体系。登录是一个相对低频的行为一天最多记一次“是否登录”签到也是同理。两者都是“用户 ID 日期”的二元关系天然适合用同一种方式存储。把两个需求放进同一个框架里好处是可以统一维护 TTL、统一走分桶逻辑、统一做过期清理。下面我展开讲的方案都是按这个“第一天共享”的思路设计的不是两套割裂的系统。2. 为什么是位图省内存的根本原因这个部分讲原理。很多人知道 Bitmap 省内存但不知道为什么省省到什么程度以及 Redis 的 Bitmap 和内存里的 Bitmap 有哪些不一样。搞清楚这些你才能在亿级场景下大胆拍板。2.1 位图省内存的本质下标即数据普通的存储你要么存一个 ID要么存一个 Map要么存一个对象每个都有结构开销。位图的思路很暴力不存 ID而是用“位置”代表 ID。用户 ID 是 10086好第 10086 个 bit 置为 1。别的用户其他位置互不干扰。用一个类比普通存储像是给每个学生发一张名牌名牌上写学号一万个学生要一万张牌位图像是给一万个座位编号学生坐到自己编号的座位上看一眼哪个座位有人就知道谁来上课了。座位本身不写字但位置就是信息——“有没有人”这个信息由 1 个 bit 承载。所以位图省内存并不是压缩了什么而是它根本不需要存那个“ID 字符串”。它只存一个 0/1 状态ID 变成了地址。这在有大量稀疏、连续整数 ID 的场景里效率高得吓人。2.2 Redis Bitmap 的核心命令与边界Redis 里 Bitmap 是构建在 String 类型之上的二进制位操作最大的 value 上限是 512MB也就是最多能承载 2^32 个 bit42.9 亿个位置。亿级用户在容量上是完全够的。常用命令四个SETBIT、GETBIT、BITCOUNT、BITFIELD。SETBIT key offset value 把第 offset 位设为 0 或 1GETBIT 读某一位BITCOUNT 统计整个 key 里 1 的个数BITFIELD 是对位数组做更精细的读写操作支持按任意位宽读写一段二进制。登录统计核心用前三个连续签到的进阶方案会用到 BITFIELD。需要注意一个边界SETBIT 时如果 offset 很大Redis 会直接扩展字符串到对应长度中间补零。这意味着你第一天插入一个最大用户 ID 的时候内存会瞬间拉满。所以如果用户 ID 不是从 0 开始连续编号的必须先做映射或偏移不能直接拿原始业务 ID 当 offset。这个我后面排坑部分会重点讲。2.3 内存估算公式和对比表来一个直观的对比。假设 1 亿用户日活 2000 万存“当天是否登录”这个状态。方案存储形态单用户开销1 亿用户内存估算MySQL 行存每登录一行记录行索引约 100B单日 2GB2000 万行Redis String每用户一个 keykey 元数据value 约 80B约 8GBRedis Hash按分片存用户状态fieldvalue 约 20B约 2GBRedis Bitmap一位一个用户1 bit12.5MB1 亿用户只需要 12.5MB这个数字你抛给老板基本没人不信。即便保留 31 天31 个 key 全部拉满总计也才 387.5MB。同一套需求如果用 String 方案31 天要 248GB完全不是一个量级。3. 登录统计的落地实现一天一位图原理清楚之后落地就快了。登录统计用“日期作 key、用户 ID 作 offset、0/1 作 value”的方式一张位图记一天。下面给出完整设计、内存计算和分桶策略。3.1 key 设计与核心命令key 的命名建议长这样login:stat:{yyyyMMdd}。为什么把日期放在后面因为 Redis key 是按字典序存储的日期放后面方便同类 key 集中管理也方便用scan批量处理某段时间的 key。用户登录时只做一步SETBIT login:stat:20250217 10086 1查询某用户是否登录过GETBIT login:stat:20250217 10086查询当日 DAU也就是当天登录去重人数BITCOUNT login:stat:20250217这套方案的优点在于每一次登录是 O(1) 写入判断存在是 O(1) 读取统计当天人数是 Redis 内置的 C 语言位运算速度极快。亿级 key 长度的 String 用 BITCOUNT 扫描大概几十毫秒完全能接受。3.2 TTL 与内存滚动清理登录统计按天为粒度历史数据绝大部分场景只保留 30 天。每次创建当天 key 时直接设置过期时间EXPIRE login:stat:20250217 2592000同时每周的某个低峰期主动把更早的历史 key 删掉UNLINK login:stat:20250101这里特意说用 UNLINK不用 DEL。DEL 在删除大 key 时会阻塞 Redis 主线程亿级 bit 的 key 删除瞬间可能卡出几十毫秒的延迟UNLINK 是异步删除把释放内存的操作放到后台线程线上实测几乎无感。按 31 天滚动保留31 个 key 每个 12.5MB总占用 387.5MB。这个数字对任何 Redis 实例都毫无压力。为了让单 key 不那么大还可以引入分桶。3.3 分桶防止单 key 过长和高位 offset 稀疏单 key 12.5MB 其实并不算大但我仍然建议按用户 ID 做分桶。原因有二第一SETBIT 到一个很深的 offset 时Redis 为了保证随机访问内部字符串必须是一次性扩展到对应长度。如果用户 ID 最大值是 9000 万但前 1000 万个 ID 没登录你为 1000 万用户建 key 时仍然会开辟 1.25MB 空间数据稀疏会让空间白白浪费。第二虽然 BITCOUNT 亿级 bit 还是快的但拆分到多个 key 后可以并行统计单 key 的阻塞风险更小。分桶方式SETBIT login:stat:20250217:0 1086 1 SETBIT login:stat:20250217:1 10086 1规则是桶号 用户 ID 对 1000 取模桶内 offset 用户 ID 除以 1000。这样每个桶最多 10 万个用户bit 位置也密集很多。统计总 DAU 时把 1000 个 key 的 BITCOUNT 结果加起来BITCOUNT login:stat:20250217:0 BITCOUNT login:stat:20250217:1 ...分桶之后的额外收益是Redis 集群模式下还能按桶号做 slot 打散把 1000 个 key 分散到不同节点读和写都走并行性能上限更高。代价是统计 DAU 时要做 1000 次 BITCOUNT 再求和但这 1000 次可以 pipeline实测总耗时比单 key 还快。3.4 对外查询和缓存策略做后台报表时如果每次都实时 BITCOUNT 1000 个 key虽然不慢但没必要。建议每 5 分钟把当日 DAU 写进一个普通 String 缓存# 伪代码示意定时任务 shard_count 1000 total 0 for shard in range(shard_count): total redis.bitcount(flogin:stat:{today}:{shard}) redis.set(flogin:dau:{today}, total, ex300)这样报表系统读的就是一个简单字符串不会对 Redis 产生持续的 BITCOUNT 压力也避免统计任务和写入高峰互相挤占。4. 连续签到的两种极致方案以及怎么选连续签到在“亿级用户 极致内存”这个约束下有两个可行的路线。一个偏向查“某一用户连续签几天”另一个偏向存“每个用户的连续天数数值”。我两个都实现过下面把优劣一起讲透。4.1 方案 A按天 Bitmap 逐日 GETBIT 回溯查连续天数这个方案沿用登录统计的“一天一个 key”结构。签到 key 建议命名sign:{yyyyMMdd}结构完全复用登录统计的位图。用户签到SETBIT sign:20250217 10086 1查询用户连续签到天数时从今天开始往前逐天 GETBIT遇到 0 就停GETBIT sign:20250217 10086 # 今天 GETBIT sign:20250216 10086 # 昨天 GETBIT sign:20250215 10086 # 前天如果今天没签到连续天数直接是 0如果今天签了就继续往前数。连续查 7 天或者 30 天都是固定次数的 O(1) 操作一天一次查询毫无压力。Redis 的 BITFIELD 还能把多次 GETBIT 合并成一条命令BITFIELD sign:20250217 GET u1 10086 BITFIELD sign:20250216 GET u1 10086实际上可以用一个 key 加多个 GET 子命令多个 key 之间没法合并所以跨天查询还是得循环但这个循环在客户端做完全没问题。这套方案最大的优点是把“某天是否有签到记录”和“登录统计”完美统一一个 key 两种用途。内存测算保留最近 30 天的签到 key每个 key 按 1 亿用户算 12.5MB总占用 375MB。因为登录统计和签到可以各自一套 key也可以共用同一套 Bitmap共用时总内存不变边际成本非常低。缺点在于一个用户查一次连续签到要走 N 次 GETBIT。如果日活用户每天都要来一次“签到 查看”相当于每天要做日活 × 连续 N 天的 GETBIT 查询。以 2000 万日活、平均连续查 7 天为例一天就是 1.4 亿次 GETBIT这对 Redis 的 QPS 是一笔不小的开销需要压测确认容量。4.2 方案 B固定位宽槽位用 BITFIELD 直接存连续天数如果想更进一步压缩查询开销可以用 BITFIELD 直接把“连续签到天数”这个数值存下来而不是每次都临时去数。思路是这样的在整个字符串里给每个用户预留固定数量的 bit 位比如 5 个 bit 能存 0-31 天6 个 bit 能存 0-63 天。假设用 6 bit 存一个用户的连续签到天数用户 ID 为 10086那么它在字符串里的起始 bit 位置就是offset 10086 * 6签到当天先读昨天的值GETBIT sign:20250216 10086昨天没签今天连续天数置为 1昨天签了今天连续天数加 1。写入用 BITFIELDBITFIELD sign:streak SET u6 60516 1 BITFIELD sign:streak SET u6 60516 7注意 60516 就是 10086 * 6必须保证任何一次加 1 后的最大值不超过 63否则会溢出覆盖到下一个用户的位置。为了防止溢出可以在写之前先读出来判断一下或者用 BITFIELD 的溢出控制子命令OVERFLOW SAT让写入饱和在最大值BITFIELD sign:streak OVERFLOW SAT SET u6 60516 1内存计算1 亿用户 × 6 bit 6 亿 bit 75MB。相比方案 A 的 375MB30 天方案 B 要省得多而且查询连续天数只需要一次 BITFIELD 读不需要跨天循环。但方案 B 有致命前提你必须在每天签到后就把“连续天数”刷新到最新。也就是说每天要把昨日签到过的所有用户做一轮批量更新比如日活 2000 万每天就是 2000 万次读 2000 万次写。这个压力转嫁到了“离线批量任务”上而不是读路径上。4.3 两个方案对比与选型建议维度方案 A按天 Bitmap方案 B位宽槽位存天数内存1 亿用户30 天约 375MB75MB单用户查询连续天数N 次 GETBIT1 次 BITFIELD每日维护成本无天然更新需要批量任务刷天数断签重置查询时自然发现 0需每天扫描判断并清零实现复杂度低中适用场景中小日活、查询不密集亿级日活、极低内存要求我的个人倾向是如果查询量不大、运营活动不是每天弹窗那种方案 A 完全够用代码简单且不需要额外的每日批处理任务。如果做了大促签到活动用户可以随时查看“我已连续签到 X 天”并发量极大那就必须上方案 B把查连续天数的时间压到一次命令。4.4 断签重置和跨自然周的特殊处理连续签到最容易被遗漏的是“断签”规则。产品经常定义“连续 7 天有奖”“连续 30 天有奖”这里的连续通常会区分自然周或自然月。技术上的处理完全一样读昨天和今天的位昨天为 0 而今天为 1就等于断签重计。方案 B 的断签重置稍微麻烦。必须在每日结算任务中查出“昨日签到但今日未签到”的用户把他们的槽位清零。这个集合怎么来可以反过来用方案 A 的按天位图做差集昨日 key与今日 key的差异可以通过遍历两个 bitmap 找出只出现在昨日的用户 ID。遍历 1 亿 bit 约等于遍历 12.5MB 字节单机内存就能跑完用 C 语言或 Go 位运算都很快。我踩过的坑是结算任务如果选在 00:00 整点跑会和用户跨夜的签到高峰撞在一起导致今日 key 刚建号就大量写入任务读取时数据还在变化。建议在凌晨 1 点到 4 点之间跑并把“昨日 key”作为一个固定快照不随实时写入变化。5. 工程化落地最容易踩的五个坑方案看着简单真正落地总会有一堆意外。以下五个坑我全踩过写出来给后来人当路标。5.1 业务 ID 不能直接当 Bitmap offset这是最隐蔽的坑。很多系统用户 ID 是自增的但也有不少是雪花 ID 或带分库分表后缀的 ID。直接拿雪花 ID比如 7238901234567890当 offsetSETBIT 会在字符串里一次性开辟 900TB 的空间Redis 直接拒绝或 OOM。解决方案是维护一个“用户 ID 到密集序号”的映射表。映射表可以用一个独立的 Redis Hash 做也可以用数据库做。注册时给用户分配一个从 0 开始的自增序号Login 逻辑先查一次映射表拿到序号再写 Bitmap。查询场景也是一样先映射再查位。映射表本身会有内存开销用户 ID String → 序号 int但映射表是增量增长可以按需加载而且它只跟“注册用户数”成正比不会按“每日登录行为”增长。相比业务 ID 直接放位图导致的内存爆炸这个代价是值得的。5.2 大 key 删除的阻塞隐患一天一个 key、一亿个 bit这个 key 大小是 12.5MB。说大不大但如果你在高峰期 DEL 一个几十个这样的 keyRedis 主线程会停下逐个释放内存。我线上实测过DEL 一个 200MB 的大 key耗时约 200ms高峰期这种卡顿足以拖垮一批请求。所以删除历史 key 时务必用 UNLINK。另一个细节是如果 redis 版本老不支持 UNLINK可以先 RENAME 到一个临时 key再对临时 key 做 DEL这也能把阻塞风险从“删除的瞬间”分散掉一点点。升级 Redis 4.0 以上直接用 UNLINK 最省心。5.3 BITFIELD 负数与有符号位宽限制Redis 的 BITFIELD 支持i有符号和u无符号两种类型。注意支持的有符号最大宽度是 63不是 64。用BITFIELD key SET i63时能表现的范围是 -2^62 到 2^62 - 1所以如果你想用 64 bit 存一个 longBITFIELD 做不到。更重要的是连续天数这类计数如果用有符号类型一旦不小心读到负数比如位模式被垃圾数据污染加 1 可能直接溢出。我的建议是保存连续天数一律用无符号u6或u7并且写入之前先判断是否超过最大连续天数。大促场景设置一个“封顶值”比如连续 31 天后不再加 1既是产品需求也是防溢出兜底。5.4 过期时间设置很多 key 白存了登录统计和签到 key 如果必须保留 30 天我建议 TTL 不要从“创建当天”算而是从“这天的最后一天”算。举个例子2 月 17 日的 key在 2 月 18 日零点以后才可能不再写入所以 EXPIRE 应该是“2 月 18 日 0 点 30 天”而不是“2 月 17 日 0 点 30 天”。实现上更优雅的做法是每天定时任务统一对当天 key 续期或者在建 key 时直接指定周期EXPIRE login:stat:20250217 2592000如果 TTL 设短了比如 27 天那每月会出现几天数据被提前清掉的情况到时候产品来问“为什么图表缺了两天”排查起来非常麻烦。命名里带日期之后也可以用 cron 定时清理早于 30 天的 key比依赖 TTL 更可控。5.5 测试环境的压测方法要真实别拿几万个用户测亿级方案的性能。Bitmap 的底层是字符串性能瓶颈往往在大 key 的分布式节点传输和 GC 上。压测时至少生成 1000 万用户的位图数据用真实随机 ID 做读写。我压测时用的方法是写一个脚本随机选 1000 万用户 ID 作为 offset循环 SETBIT再用 pipeline 跑 GETBIT统计 P99 延迟。跑完后用MEMORY USAGE login:stat:20250217看实际内存占用对比理论值 12.5MB/亿能及时发现稀疏位图导致的内存膨胀。还有一个容易被忽略的点Redis 实例的 maxmemory 如果设得太小位图 key 的数据量超过上限时会触发 key 淘汰。如果淘汰策略是 allkeys-lru很可能把当天的登录 key 给淘汰掉次日统计直接缺数据。所以这类关键统计 key 建议使用 noeviction 策略或者给统计库单独开一个 Redis 实例物理隔离业务缓存。6. 最后再分享两个实践细节第一点是关于“共用一套 Bitmap”。登录行为和签到行为在业务上高度相关很多用户登进来就是为了签到。登录统计需要判断“今天是否来过”签到需要判断“今天是否签过”。如果一个用户登录过但没签到那它应该出现在登录位图但不出现在签到位图。这两个位图用同一套 offset 体系毫无冲突反而能省掉一遍用户映射。第二点是离线数据的衔接。亿级场景下Redis 里保留的是热数据但运营要做用户画像分析、行为轨迹回放光靠 Redis 不行。我建议每天把当日登录、签到 Bitmap 导出成 RoaringBitmap 文件落到对象存储或离线数仓。这样既能对历史数据做任意时段的连续签到分析又不用担心 Redis 内存被冷数据占满。导出时可以直接用 Redis 的 DUMP 命令序列化也可以用 SCAN 读出所有 bit 位组装前者简单但依赖 Redis 版本兼容后者更可控。实际测试下来12.5MB 的 bitmap 导出到文件再压缩通常只有 2-3MB存 10 年也就 1GB 级别成本几乎可以忽略。这两个细节不涉及复杂的架构但对上生产之后的维护体验影响非常大。我见过团队把方案落地了却因为共用映射没做好导致登录和签到各维护一张映射表产生数据不一致也见过没做离线导出的领导突然要看 90 天签到趋势Redis 里根本查不到。提前把这两件事做了后续会特别省心。亿级用户的登录统计和连续签到说到底就是一句话能用位表示的绝不用字节表示。把用户 ID 变成地址把日期变成 key把状态变成 0 和 1内存问题自然就解开了。真到线上运行起来你会发现 12.5MB 一个 key 轻得像一片羽毛跟最初几 GB 的方案相比这才是亿级系统该有的姿态。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑