返回Notes

/ notes

签到系统选型记:从 380GB 内存告警到 Redis 混合架构

一场梦走完签到系统三次重构:KV 为何爆到 380GB、Bitmap 快在哪又残在哪、为什么生产环境最终都是混合架构。

设计一个支撑 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 个 keyBITCOUNT 单 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 失败重放”:签到读多写少,允许秒级不一致。要追求强一致就得引入消息队列异步落库,为这个功能付这个代价,不值。

六、复盘会上,我讲了四条

踩过的坑背后的技术本质
内存 380GBKV 的复杂度 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,手机显示下午三点四十。