
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个bit1000万用户一天就是1000万个bit换算成字节是1000万除以8等于125万字节也就是约1.19MB。7天就是约8.34MB。这个数字意味着什么意味着整个7天的登录状态用一台普通Redis实例的内存就能轻松装下甚至不需要考虑分片。对比一下用Hash存储的方案每个用户一个fieldvalue存日期列表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 6912008天秒数。这里建议用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(daysi) key flogin: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打包写入和设TTLisContinuousLogin用了提前中断。实际生产里还要加上异常处理和重试比如Redis超时后重试一次但要注意SETBIT是幂等的重试安全。5.3 压测验证与参数调优上线前一定要压测。我一般用redis-benchmark先摸单机基线再用业务代码做端到端压测。重点看三个指标写入P99延迟、查询P99延迟、内存增长是否符合预期。压测时有个技巧模拟真实分布别用均匀随机的用户ID。真实场景下活跃用户集中在头部偏移量分布是不均匀的这会影响Bitmap的扩容行为和内存碎片。我建议按二八分布构造测试数据80%的写入落在20%的偏移量区间这样压出来的结果更接近生产。如果发现写入延迟偏高先查是不是EXPIRE拖慢了——虽然EXPIRE是O(1)但和SETBIT打包时如果网络往返多整体延迟会上去。解决办法是把TTL设置从每次写入改成每天定时任务统一设减少热路径上的命令数。6. 踩坑实录与常见问题排查6.1 偏移量爆炸导致内存告警这是最典型的坑我在两个项目里都遇到过。现象是Redis内存突然从几十MB涨到几个GBINFO 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.5MB7天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万用户的连续登录统计就是个很轻量的功能这两点马虎了再精巧的查询逻辑也救不回来。另外压测一定要用真实分布的数据均匀随机的压测结果会给你虚假的安全感上线后被打脸的例子我见得太多了。