1. 为什么1000万用户连续登录统计值得单独设计
1.1 从业务场景倒推技术需求
连续登录统计这个需求,表面上看是个签到功能,实际落到1000万用户、连续7天这个量级上,性质就完全变了。我接触过的几个类似场景,包括电商签到领积分、在线教育连续打卡、内容社区活跃度激励,核心诉求都指向同一件事:在极低的存储成本和极高的并发写入下,快速判断某个用户在过去7天里是否每天都登录过。
先把这个需求拆开看。1000万用户,每人每天一条登录记录,7天就是7000万条状态数据。如果老老实实用关系型数据库建一张登录流水表,按用户ID加日期建索引,单表7000万行虽然不算天文数字,但每天要扛住1000万次写入,还要支持"查某用户连续7天是否全勤"这种聚合查询,数据库的压力会非常可观。更麻烦的是,这类业务通常还要求实时性——用户登录完立刻要知道自己连续几天了,不能等离线批处理跑完再告诉他。
所以技术选型的核心矛盾很清晰:写入要快、存储要省、查询要准。Redis的Bitmap恰好在这三点上都有天然优势,这也是标题里点名它的原因。Bitmap本质上是把一串二进制位映射到字符串上,每个位只有0和1两种状态,用一位表示"某用户某天是否登录",信息密度拉满。
1.2 Bitmap方案的成本账要算清楚
很多人一听Bitmap就觉得高级,但真正落地前必须把账算明白,否则容易在错误的地方省了钱、在正确的地方花了冤枉钱。我按1000万用户、7天窗口来算一笔账。
每个用户每天占1个bit,1000万用户一天就是1000万个bit,换算成字节是1000万除以8,等于125万字节,也就是约1.19MB。7天就是约8.34MB。这个数字意味着什么?意味着整个7天的登录状态,用一台普通Redis实例的内存就能轻松装下,甚至不需要考虑分片。对比一下用Hash存储的方案:每个用户一个field,value存日期列表,1000万个field加上Hash的元数据开销,轻松上GB级别。Bitmap在存储密度上的优势是数量级的。
但这里有个关键细节容易被忽略:Bitmap的位偏移量决定了内存占用。Redis的Bitmap底层是字符串,字符串的长度由最大偏移量决定。如果你直接用用户ID作为bit偏移量,而用户ID是自增的、从1开始,那没问题;但如果用户ID是雪花算法生成的19位长整型,偏移量直接飙到万亿级别,一个Bitmap就能把内存撑爆。这是新手最容易踩的坑,后面我会专门讲怎么处理。
1.3 这个方案适合谁、不适合谁
说句实在话,Bitmap不是银弹。它适合的场景有几个共同特征:用户基数大但状态维度单一(就是登录/未登录)、查询以单用户为主、对实时性有要求、能接受一定的精度取舍。反过来,如果你的需求是"统计每个用户过去7天每天登录的具体时间点",或者"按登录时长做分层",那Bitmap就不合适了,该用有序集合或者专门的时序存储。
还有一个隐含前提:用户ID必须能映射到连续的、可控范围的整数偏移量。如果你的系统用户ID是UUID或者无序长整型,就得先做一层ID到连续整数的映射,这层映射本身也是有成本的。我在实际项目里见过有人直接拿UUID的哈希值当偏移量,结果哈希碰撞导致两个用户的状态互相覆盖,这种bug排查起来极其痛苦。
2. 核心设计:位图布局与键结构规划
2.1 一天一张图还是七天一张图
这是设计阶段第一个要拍板的问题,两种布局各有取舍,我先把对比摆出来。
| 布局方式 | 键设计 | 优点 | 缺点 |
|---|---|---|---|
| 按天分图 | login:20240101 | 单图只存当天状态,内存可控;过期清理简单 | 查连续7天要跨7个键,多次GETBIT或一次BITOP |
| 七天一张图 | login:week:2024W01 | 单键查询,BITCOUNT一次出结果 | 单图偏移量随天数增长,需规划位段 |
我个人的倾向是按天分图,理由有三个。第一,按天分图的内存边界最清晰,每天固定约1.19MB,不会因为窗口滑动导致单键无限膨胀。第二,过期策略天然简单,给每个键设7天TTL,老数据自动淘汰,不需要额外的清理任务。第三,跨天查询虽然要多次操作,但可以用Redis的BITOP做位运算合并,或者用BITCOUNT配合GETBIT逐位判断,性能完全够用。
七天一张图的问题在于位段规划。如果每天占一段连续的位区间,比如第1天用偏移0到9999999,第2天用10000000到19999999,那单图最大偏移量就是7000万,内存约8.34MB,也还能接受。但这种布局下,判断"某用户是否连续7天"需要跨7个位段取值,逻辑反而比按天分图更绕。所以除非你的查询模式是"批量统计全站连续登录人数",否则按天分图更省心。
2.2 用户ID到位偏移量的映射策略
前面提到,位偏移量必须是可控的连续整数。这里分两种情况处理。
第一种,用户ID本身就是从1开始的自增主键,那直接用,偏移量等于用户ID。注意Redis的位偏移从0开始,用户ID从1开始的话,偏移量用userId - 1或者直接用userId都行,差一位不影响正确性,只要全系统统一即可。
第二种,用户ID是无序长整型或UUID,必须做映射。常见做法是维护一张"用户ID到连续序号"的映射表,可以用Redis的Hash存,也可以用数据库存。映射表的规模是1000万条,用Hash存的话内存开销约几百MB,这个成本要提前评估。另一种更轻量的做法是用布隆过滤器加自增计数器,但布隆过滤器有误判率,不适合要求精确的场景。
提示:映射表一旦建立就不要轻易重建,因为位偏移量和用户是一一绑定的,重建会导致历史登录状态错位。我见过有团队为了省事每次启动重新分配序号,结果连续登录统计全乱套,这个坑一定要避开。
2.3 键命名与过期策略
键命名要兼顾可读性和可维护性。我习惯用login:bitmap:{日期}这种格式,日期用yyyyMMdd,比如login:bitmap:20240115。这样在Redis客户端里SCAN的时候一眼就能看出是登录位图,排查问题方便。
过期策略上,给每个按天分图的键设置TTL为8天(比7天窗口多留1天缓冲,防止边界时刻的时钟偏差导致数据提前失效)。设置TTL的时机有两个选择:写入时用SETBIT之后跟一个EXPIRE,或者用PIPELINE把两个命令打包。注意SETBIT本身不会重置TTL,所以如果同一个键当天被多次写入,TTL不会被刷新,这是符合预期的——我们要的是从创建时刻算起的固定过期,而不是滑动过期。
3. 写入链路:登录状态如何高效落位
3.1 单用户登录写入的完整流程
用户登录成功后,写入逻辑要尽可能轻,因为登录接口本身就是高频调用,任何额外开销都会被放大1000万倍。完整流程我拆成四步。
第一步,拿到用户ID,通过映射表转换成位偏移量offset。如果映射表在Redis里,这一步是一次HGET;如果映射在本地缓存,那就是内存操作,更快。
第二步,计算当天日期对应的键名,比如login:bitmap:20240115。
第三步,执行SETBIT key offset 1。这个命令的复杂度是O(1),Redis内部直接定位到字节和位,把对应位置1。注意如果offset超出当前字符串长度,Redis会自动扩容并用0填充中间部分,所以第一次写入大偏移量时会有一次内存分配,但后续写入就是原地修改。
第四步,设置TTL。用EXPIRE key 691200(8天秒数)。这里建议用PIPELINE把SETBIT和EXPIRE打包发送,减少一次网络往返。实测下来,单次登录写入的Redis耗时在亚毫秒级,对登录接口的整体延迟影响可以忽略。
# 伪代码示意,实际用你熟悉的客户端 PIPELINE SETBIT login:bitmap:20240115 12345678 1 EXPIRE login:bitmap:20240115 691200 EXEC3.2 批量写入与并发控制
有些场景不是单用户登录触发写入,而是离线补录或者批量导入,这时候要考虑批量写入的效率。SETBIT单条命令虽然快,但1000万条逐条发也是不小的网络开销。优化思路有两个。
一是用PIPELINE批量打包,一次发几百上千条命令,减少往返次数。实测1000条一批,吞吐能提升一个数量级。二是如果补录的是整天的全量数据,可以考虑先在应用层拼好一个字节数组,用SET命令直接覆盖整个Bitmap。但这种方式要求你对位布局有完全掌控,且会覆盖已有数据,只适合初始化场景,日常增量写入还是老老实实用SETBIT。
并发方面,SETBIT本身是原子操作,多个客户端同时写同一个键的不同偏移量不会冲突。但如果多个客户端写同一个偏移量,后写的覆盖先写的,结果都是1,没有副作用。所以登录写入天然是幂等的,重复登录重复写也没关系,这一点对重试机制很友好。
3.3 写入侧的容量规划与监控
1000万用户每天1000万次SETBIT,按峰值集中在早晚登录高峰来估算,假设80%的写入集中在4小时内,那就是每小时200万次,约每秒556次。这个QPS对单台Redis来说毫无压力,但要注意网络带宽和连接数。每次SETBIT命令加上协议开销约几十字节,每秒556次也就是几十KB/s,带宽不是瓶颈。
真正要监控的是内存增长曲线。每天新增一个约1.19MB的键,7天窗口内稳定在8.34MB左右,加上Redis自身的开销和碎片率,预留20MB足够。如果发现内存异常增长,大概率是偏移量映射出了问题,比如某个用户ID被错误映射到了超大偏移量,导致Bitmap被撑大。建议在写入侧加一个偏移量范围校验,超过预设上限(比如1200万)的直接告警并拒绝写入,防止单个异常数据拖垮整个实例。
4. 查询链路:连续7天判断的几种实现
4.1 逐位判断法:最直观但要注意顺序
判断某用户是否连续7天登录,最直白的做法是拿用户偏移量,依次去7个按天分图的键里GETBIT,看是否都为1。逻辑简单,但有两个细节要注意。
第一是日期顺序。连续7天指的是从今天往前推7天,还是从某个固定起点往后推7天?业务上通常是"截至今天的连续天数",所以查询时要按日期从近到远或从远到近依次判断,一旦遇到0就中断,剩下的不用查了。这个提前中断的优化很关键,因为大部分用户不会连续7天全勤,平均可能查两三天就断了,实际GETBIT次数远小于7。
第二是边界处理。如果某个键因为TTL过期不存在了,GETBIT会返回0,这符合"那天没登录"的语义,不需要特殊处理。但要确保TTL设置合理,别把还在窗口内的数据提前清掉了。
# 伪代码:判断连续7天 def is_seven_day_streak(user_offset, today): for i in range(7): day = today - timedelta(days=i) key = f"login:bitmap:{day.strftime('%Y%m%d')}" if redis.getbit(key, user_offset) == 0: return False return True4.2 BITOP合并法:适合批量统计
如果需求不是查单个用户,而是统计"全站有多少人连续7天登录",逐位判断就不合适了,1000万用户逐个查7次,那就是7000万次GETBIT,再快也扛不住。这时候用BITOP AND把7天的位图做按位与运算,结果图里为1的位就是7天全勤的用户,再用BITCOUNT数一下1的个数,一次搞定。
BITOP AND destkey key1 key2 ... key7的复杂度是O(N),N是位图长度,约1000万位,Redis底层是C实现的位运算,速度很快。但要注意destkey会占用额外内存,约1.19MB,用完记得删掉或者设短TTL。另外BITOP是阻塞式命令,在大位图上执行会占用主线程,建议放在从节点或者低峰期跑。
| 查询方式 | 适用场景 | 单次耗时量级 | 注意事项 |
|---|---|---|---|
| 逐位GETBIT | 单用户实时查询 | 亚毫秒 | 利用提前中断优化 |
| BITOP AND + BITCOUNT | 全站批量统计 | 几十毫秒 | 避免高峰期执行 |
| BITCOUNT单日 | 单日活跃统计 | 毫秒级 | 只能算当天,不解决连续问题 |
4.3 连续天数计算的扩展玩法
业务上经常不只是判断"是否连续7天",而是要显示"当前连续登录X天"。这个可以在逐位判断的基础上扩展:从今天往前逐天查,查到第一个0为止,计数器就是连续天数。但要注意上限,如果用户连续登录了30天,你不可能无限往前查,通常设一个最大回溯窗口,比如30天或60天,超过就显示"30天以上"。
还有一种玩法是结合BITCOUNT算"7天内登录了几天",这个不要求连续,只要求总天数。直接对7个键分别BITCOUNT再求和?不对,那是全站统计。单用户的7天内登录天数,还是得逐位查7次然后累加,或者用BITOP OR合并后查该用户那一位——但BITOP OR是全图操作,为单个用户做这个不划算,老老实实循环7次GETBIT更实际。
5. 实操落地:从零搭建的完整步骤
5.1 环境准备与基础配置
先确认Redis版本,SETBIT、GETBIT、BITCOUNT、BITOP这些命令从2.2版本就有,但建议用5.0以上,稳定性和性能都更好。配置上重点关注两个参数:maxmemory要留足空间,按前面算的20MB加上其他业务共用,别设得太紧导致淘汰;maxmemory-policy建议用noeviction或者volatile-lru,因为登录位图是核心数据,不能被随意淘汰。
连接池配置也有讲究。登录写入是高频短操作,连接池的maxTotal要按峰值QPS乘以平均耗时来估算,比如峰值600 QPS、单次耗时1ms,理论上1个连接就够,但考虑网络抖动和突发,配20到50个比较稳妥。别配太大,连接数过多反而增加Redis的上下文切换开销。
5.2 核心代码骨架
下面用Java给一个可运行的骨架,基于Jedis客户端,其他客户端如Lettuce逻辑类似。
public class LoginBitmapService { private final JedisPool jedisPool; private static final int TTL_SECONDS = 8 * 24 * 3600; public LoginBitmapService(JedisPool jedisPool) { this.jedisPool = jedisPool; } // 记录登录 public void recordLogin(long userOffset, LocalDate date) { String key = buildKey(date); try (Jedis jedis = jedisPool.getResource()) { Pipeline pipe = jedis.pipelined(); pipe.setbit(key, userOffset, true); pipe.expire(key, TTL_SECONDS); pipe.sync(); } } // 判断连续N天 public boolean isContinuousLogin(long userOffset, LocalDate endDate, int days) { try (Jedis jedis = jedisPool.getResource()) { for (int i = 0; i < days; i++) { String key = buildKey(endDate.minusDays(i)); if (!jedis.getbit(key, userOffset)) { return false; } } return true; } } private String buildKey(LocalDate date) { return "login:bitmap:" + date.format(DateTimeFormatter.BASIC_ISO_DATE); } }这段代码里,recordLogin用了Pipeline打包写入和设TTL,isContinuousLogin用了提前中断。实际生产里还要加上异常处理和重试,比如Redis超时后重试一次,但要注意SETBIT是幂等的,重试安全。
5.3 压测验证与参数调优
上线前一定要压测。我一般用redis-benchmark先摸单机基线,再用业务代码做端到端压测。重点看三个指标:写入P99延迟、查询P99延迟、内存增长是否符合预期。
压测时有个技巧:模拟真实分布,别用均匀随机的用户ID。真实场景下活跃用户集中在头部,偏移量分布是不均匀的,这会影响Bitmap的扩容行为和内存碎片。我建议按二八分布构造测试数据,80%的写入落在20%的偏移量区间,这样压出来的结果更接近生产。
如果发现写入延迟偏高,先查是不是EXPIRE拖慢了——虽然EXPIRE是O(1),但和SETBIT打包时如果网络往返多,整体延迟会上去。解决办法是把TTL设置从每次写入改成每天定时任务统一设,减少热路径上的命令数。
6. 踩坑实录与常见问题排查
6.1 偏移量爆炸导致内存告警
这是最典型的坑,我在两个项目里都遇到过。现象是Redis内存突然从几十MB涨到几个GB,INFO memory里看到某个login:bitmap键的strlen异常大。根因是用户ID映射逻辑有bug,某个用户的ID被错误转换成了超大偏移量,SETBIT时Redis把整个字符串扩容到那个偏移量,中间全填0。
排查方法:用STRLEN key看键的字节长度,正常应该是125万字节左右,如果远超这个数,就是偏移量出问题了。再用DEBUG OBJECT key看序列化长度。定位到具体是哪个偏移量,可以写脚本用BITCOUNT分段扫描,找出异常区间。
预防措施:写入前做偏移量范围校验,超过预设上限直接拒绝并告警。另外映射表的分配逻辑要加单元测试,确保ID转换是单调且连续的。
6.2 TTL设置时机错误导致数据提前丢失
有团队在写入时用SETBIT之后单独发EXPIRE,但没放在Pipeline里,结果高并发下EXPIRE偶尔失败,那个键就永不过期,内存慢慢累积。反过来也有团队每次写入都刷新TTL,导致键永远不过期,同样内存泄漏。
正确做法是只在键首次创建时设TTL。可以用SETBIT的返回值判断:如果返回0且键原本不存在,说明是首次写入,这时设TTL;如果键已存在,跳过。但SETBIT返回的是该位原来的值,不是键是否存在,所以更稳妥的方式是用EXISTS先判断,或者干脆每天定时任务统一给当天键设TTL,逻辑最简单。
6.3 跨天边界的时间一致性问题
连续登录统计对日期边界很敏感。如果服务器时钟有偏差,或者应用层和Redis层对"今天"的理解不一致,就会出现用户明明登录了却被判为断签。我见过一个案例:应用服务器用UTC时间,Redis所在机器用本地时间,跨天那几个小时的数据全乱。
解决办法是统一时间源。所有日期计算都在应用层完成,用同一个时区的LocalDate,键名里的日期由应用层生成,不依赖Redis的TIME命令。另外在跨天时刻(比如23:59到00:01)做一次对账,抽查部分用户的连续状态是否符合预期。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 内存异常增长 | 偏移量爆炸 | STRLEN查键长度 | 加偏移量范围校验 |
| 连续判断总为false | 键名日期格式不一致 | 对比写入和查询的键名 | 统一日期格式化工具 |
| 查询延迟高 | 逐位查询未中断 | 看是否查满7次 | 加提前中断逻辑 |
| 数据提前消失 | TTL设太短 | TTL命令查剩余时间 | 调整为8天缓冲 |
| 批量统计慢 | BITOP在主节点跑 | 看命令耗时监控 | 挪到从节点或低峰期 |
注意:
BITOP和BITCOUNT在大位图上都是O(N)操作,N是位图长度。1000万位的图单次操作几十毫秒,如果业务要求高频批量统计,建议把结果缓存起来,比如每小时算一次存到另一个键,查询直接读缓存。
7. 方案边界与后续扩展思路
7.1 什么时候该换方案
Bitmap方案在1000万用户、7天窗口这个量级上很舒服,但业务是会长的。如果用户涨到1亿,单日位图就是12.5MB,7天87.5MB,单机还能扛但开始有压力;如果窗口从7天扩到30天,键数量翻几倍,查询逻辑也更复杂。到了这个阶段,可以考虑按用户ID哈希分片到多个Redis实例,每个实例管一段偏移量区间,查询时路由到对应实例。
另一个换方案的信号是状态维度变多。比如业务要求记录"登录设备类型""登录地点"等多维状态,Bitmap一位只能表达是/否,就不够用了,该上布隆过滤器或者专门的位图索引库。但在状态维度单一的前提下,Bitmap的性价比很难被超越。
7.2 和签到积分等业务的结合
连续登录统计往往是签到积分系统的底座。判断出连续7天后,触发积分发放,这个逻辑放在应用层做,别塞进Redis的Lua脚本里——Lua脚本虽然原子,但调试困难,而且积分发放涉及数据库写入,不适合放在Redis侧。我的做法是查询接口返回连续天数,业务层根据天数决定发多少积分,积分发放用消息队列异步处理,保证登录接口的响应速度。
还有一个扩展点是补签。用户断签后想补,补签本质上是把历史某天的位从0改成1,直接SETBIT key offset 1即可,但要注意补签后连续天数会变化,需要重新计算。补签的权限控制和次数限制放在业务层,Redis只负责状态存储,职责清晰。
7.3 数据导出与离线分析
线上Redis里的位图数据,有时候需要导出做离线分析,比如算月度活跃、画用户活跃曲线。导出时别直接GET整个字符串再解析,1000万位约1.19MB,单次导出没问题,但批量导出要注意别把Redis带宽打满。可以用BITCOUNT按字节分段统计,或者用DUMP命令导出序列化数据再离线解析。
离线分析时,把每天的位图按位展开成用户ID列表,再和用户属性表做关联,就能算出"连续7天登录用户的平均消费额"这类指标。这一步用Spark或者Flink读导出的位图文件,按位解析成(offset, 1)的稀疏记录,再和映射表join还原成用户ID,整个链路就打通了。
我个人在实际操作中的体会是,Bitmap方案的上手门槛不高,但真正决定成败的是偏移量映射的稳定性和TTL策略的严谨性这两件小事。把这两点做扎实,1000万用户的连续登录统计就是个很轻量的功能;这两点马虎了,再精巧的查询逻辑也救不回来。另外压测一定要用真实分布的数据,均匀随机的压测结果会给你虚假的安全感,上线后被打脸的例子我见得太多了。