设计一个支撑 1 亿用户的签到系统,要支持月签到天数、连续签到和两日活跃交集。
我扫了一眼,心想这有什么难的,SET/GET 存一下不就行了。为了把细节想清楚,我趴在桌上闭了会儿眼——再睁眼,已经站在一间会议室里,成了这个签到系统的负责人。
一、需求评审会上,我以为这是送分题
需求很简单,至少当时看起来是。
产品要给 1 亿用户上签到功能,查询需求三条:某用户这个月签了多少天、连续签了多少天、任意两天都签到的用户有多少(给留存分析用)。
签到不就是”用户 + 日期 + 一个布尔值”吗?我几乎没犹豫,选了最直白的建模:
SET sign:10086:20240115 "1" EX 7776000 # 90 天过期
查某一天签没签,GET 一下,毫秒级返回。上线第一周,风平浪静。
二、凌晨的电话:20GB 是怎么变成 380GB 的
上线一个月后,凌晨两点,运维的电话把我叫醒:Redis 内存从 20GB 一路涨到 380GB,再涨就要触发内存驱逐策略了。
我爬起来算账,算完一身冷汗。
- 1 亿用户,每人每天一条记录,一个月就是 30 亿个 key;
- 一个 key 的真实成本远不是 1 字节:key 名字符串本身 20 多字节,Redis 还要为它挂 dictEntry、robj、SDS 头和值对象,摊下来单 key 70~100 字节;
- 30 亿 × 70B ≈ 210GB,而 dict 的容量要按 2 的幂扩容,再算上内存碎片,380GB 一分不多。
换句话说,我把”一次签到”这个布尔状态,拆成了 30 亿份独立的 key 存进 Redis。问题不在 Redis,在建模。
第二天老板问加机器多少钱。我报了个数,每月十几万。他没立刻批,只问了一句:有没有不花钱的办法。
三、POC:文档里的一句话
回去翻 Redis 文档,我被一句话钉住了:1 亿个布尔状态,用 Bitmap 只需要约 12MB。
原理不复杂:一个用户一个月 30 天的签到,不再用 30 个 key,而是压进一个字符串,一位表示一天:
SETBIT sign:10086:202401 14 1 # 1月15日签到
SETBIT sign:10086:202401 15 1 # 1月16日签到
BITCOUNT sign:10086:202401 # 这个月签了几天 → 2
我搭了个 POC 压测,数字漂亮得不像真的:
| 指标 | KV 方案 | Bitmap |
|---|---|---|
| 单月存储 | ~210GB | ~500MB |
| 查月签到天数 | 800ms(SCAN 全库匹配) | 0.3ms(BITCOUNT) |
| 两日交集 | 应用层捞数据自己算,秒级 | 5ms(BITOP AND) |
报告交上去,批复就两个字:换它。
但 POC 里其实埋了一颗雷,我当时没看出来——这是后话。
四、迁移上线,现实连打三拳
迁移很顺利,内存从 380GB 直接掉到 2GB。高兴了大概两周。
第一拳来自产品:签到记录里看不到具体时间。 用户投诉”我明明早上八点签的,为什么只显示已签到”,需求变成要存时间戳。我盯着 BIT 位发呆:一位只有 0 和 1,这不是优化问题,是信息论问题——1 bit 里塞不下一个 13 位时间戳。
第二拳来自运营:1 月 15 日的数据有误,当天签到全部清掉。 我又愣住了。Bitmap 按月存,Redis 没有”位级 TTL”这种东西,想清某一位,得把整月 1 亿个 key 全读出来、改完再写回去。一条运营操作,变成了全库迁移级别的工作量。
第三拳来自测试,这一拳最疼。 一个新用户,uid=3,000,000,000(老系统迁移过来的号段),第一次签到:
SETBIT sign:20240115 3000000000 1
监控显示,这一条命令吃掉了约 375MB 内存。
原因在于 Bitmap 的底层就是普通字符串,一段连续的字节数组。要把第 30 亿位设成 1,它前面的 30 亿位必须先全部填 0(3e9 / 8 ≈ 375MB)。uid 如果是自增主键、连续紧凑,永远碰不到这个问题;一旦号段来自业务合并或老系统迁移,稀疏就是常态。一个用户 375MB,来上几十个,内存告警会再响一次。
POC 里那颗雷,也在这时炸了
回头看那张压测表,我才发现”两日交集 5ms”和”月天数 0.3ms”根本不是同一套 key 结构算出来的。Bitmap 怎么组织 key、offset 代表什么,直接决定了它擅长哪类查询。
方式 A:按天建 key,uid 当 offset
# sign:20240115 的第 10086 位 = uid 10086 这天签没签
SETBIT sign:20240115 10086 1
两个日期的大 key 直接 BITOP AND,交集 5ms 是真的;但单用户的月统计要跨 30 个 key,稀疏 uid 膨胀也只发生在这种结构里。
方式 B:按用户+月建 key,日期当 offset
# sign:10086:202401 的第 14 位 = 1月15日签没签
SETBIT sign:10086:202401 14 1
BITCOUNT 一个 4 字节的 key 得月天数,0.3ms 是真的;但想算”两天都签到的用户”,得把 1 亿个用户 key 挨个读一遍——BITOP 不认识通配符,它只对明确给出的几个 key 做运算。
| 方式 A:按天建 key | 方式 B:按用户+月建 key | |
|---|---|---|
| offset 含义 | uid | 日期 |
| 单 key 大小 | 12.5MB(覆盖全量用户) | 4 字节(覆盖 31 天) |
| 当日/两日聚合 | BITCOUNT / BITOP,快 | 要扫全部用户 key |
| 单用户月统计 | 要跨 30 个 key | BITCOUNT 单 key,快 |
| 稀疏 uid | 会内存膨胀 | 不受影响 |
POC 里,我把两种结构各自的最优数字拼进了同一张表。按天聚合选 A,按用户聚合选 B,想两者兼得,就得付两份内存——没有两头占的好事。
五、最后的形态:各干各擅长的事
被打了三拳之后,我不再找银弹了。最终架构是双写分流:
写入(双写:先明细后统计,失败补偿重放)
├─ SETBIT → Bitmap:统计聚合(天数 / 交集 / 活跃)
└─ HSET → Hash :明细(时间戳 / 设备 / 按天 TTL)
读取(按需分流)
├─ “签了几天” → BITCOUNT
├─ “几点签的” → HGET
├─ “删某天数据” → HDEL(Bitmap 月底统一清)
└─ uid 稀疏 → KV 兜底,不进 Bitmap
- 统计类查询(月天数、留存交集、活跃判断)走 Bitmap,位运算和 popcount 天生干这个;
- 明细类查询(时间戳、设备、删某天)走 Hash,字段级读写,还能带 TTL;
- uid 超过合理 offset 上限的稀疏用户,前置校验后走传统 KV 兜底。
双写一致性也纠结过。最后选了”先写 Hash 再写 Bitmap、Bitmap 失败重放”:签到读多写少,允许秒级不一致。要追求强一致就得引入消息队列异步落库,为这个功能付这个代价,不值。
六、复盘会上,我讲了四条
| 踩过的坑 | 背后的技术本质 |
|---|---|
| 内存 380GB | KV 的复杂度 O(N×key_size),布尔状态不该拆成 30 亿个 key |
| BITCOUNT 0.3ms vs SCAN 800ms | 位运算 + popcount 计数,聚合比全库扫描快几个数量级 |
| 塞不下时间戳 | Bitmap 信息容量就是 1 bit,只装得下布尔 |
| 删不掉某天 | Bitmap 没有位级 TTL,细粒度过期要换结构 |
| 一个用户 375MB | 稀疏 offset 触发整段连续内存分配 |
| 最终双写 | 统计与明细是两种查询模式,一种结构撑不起两种 |
第一,先算账,再选型。70 字节 × 1 亿 × 30 天,这个乘法应该在写代码之前算,而不是凌晨接完运维电话之后算。
第二,Bitmap 不是万能药。它只擅长一件事:海量布尔状态的紧凑存储和位运算。要元数据、要细粒度过期、要枚举成员,它都做不到。
第三,生产环境里活下来的都是混合架构。统计归 Bitmap,明细归 Hash,异常归 KV,各管一摊。
第四,小心文档首页之外的边界。offset 上限、稀疏膨胀,还有 BITOP/BITCOUNT 在大 key 上会阻塞 Redis 主线程——单线程模型下,对一个 2GB 的 key 做 BITOP,整个实例都会卡住。这些坑教程不写,但它们会在凌晨三点给你打电话。
讲完这四条,散会。我起身时腿一麻,眼前一花——再抬头,没有会议室,还是眼前那张桌子,面前还是面试题,草稿纸上只写了两行 SET/GET,手机显示下午三点四十。