部分情节为虚构演绎,仅供参考
说实话,我所在的团队做的是电商商品详情页的缓存系统。商品ID从1到几个亿,其中大量ID对应的商品根本不存在——下架的、删除的、从未创建过的。每次请求查缓存miss了就去查数据库,数据库查不到就返回空。这些"查不到"的结果如果不缓存,同一个不存在的商品ID被反复请求,就会全部打到数据库上——这就是经典的缓存穿透。我们的方案是给每个商品ID维护一个布尔标记:这个ID是否在缓存中有记录(包括空值缓存)。说白了就是一大堆True和False:True表示缓存里有(哪怕是空值),False表示缓存里没有,需要回源。听着挺简单对吧?不就是一堆布尔值嘛。我当时也这么想。但你猜怎么着?现实啪啪打脸。就是这一堆True和False,差点把Redis集群给打穿了——不是穿透的"穿",是击穿的"穿"。大促期间,恶意爬虫用随机生成的不存在商品ID疯狂请求,我们的空值标记数组占了Redis几个GB的内存,而真正有效的商品缓存反而被挤出去了,缓存命中率断崖式下跌,数据库连接池直接被打满。越想防穿透,越把缓存搞崩。这大概是我做缓存以来最反直觉的一段经历:明明每一步都在往"更省内存、更快判断"的方向走,结果却是一步一个坑。直到我放弃自己造轮子,才找到正确解法。
1. 从Redis位图到numpy:五种方案轮番翻车
1.1 Redis Bitmap:看着省实则浪费
最开始用的是Redis自带的Bitmap,每个商品ID对应一个bit:
redis.setbit("cache_exists", product_id, 1)
exists = redis.getbit("cache_exists", product_id)
一个bit一个ID,1亿个ID只要12.5MB,看着挺美。但问题是商品ID不是连续的!电商商品ID是雪花算法生成的,动辄几十亿的跨度,Bitmap按最大ID分配空间,一个ID=50亿的商品,Bitmap直接占600MB。而且大部分ID根本不存在,Bitmap里99%的位都是0,纯浪费。更要命的是Redis Bitmap是定长的,ID跨度越大越浪费。
1.2 用Python set存已缓存ID:内存黑洞
后来换成set存已缓存的商品ID:
cached_ids = set()
cached_ids.add(product_id)
exists = product_id in cached_ids
set的in操作确实O(1),但每个整数在Python里是一个PyObject,28字节起步。1亿个ID就是2.8GB,再加上set的哈希表开销,轻松突破4GB。服务器内存直接告警。
1.3 用array('I')存ID:省了空间但查询慢
换成array存ID,配合二分查找:
from array import array
cached_ids = array('I', sorted(all_cached_ids))
# 二分查找判断是否存在
import bisect
exists = (bisect.bisect_left(cached_ids, product_id) < len(cached_ids))
array每个ID只要4字节,1亿个ID约400MB,比set省多了。但每次判断都要二分查找,O(log n)的延迟在每秒10万次查询的场景下,CPU直接飙满。而且array是定长的,新增已缓存ID要插入排序,O(n)的移动开销,大促期间根本扛不住。
1.4 用numpy布尔数组:查询快但ID跨度问题没解决
换成numpy布尔数组,用商品ID做下标:
import numpy as np
cache_exists = np.zeros(max_id + 1, dtype=np.bool_)
cache_exists[product_id] = True
随机访问O(1),向量化批量判断也快。但老问题还在:雪花ID跨度几十亿,numpy要按最大ID分配空间,几十亿个bool就是几十GB,直接OOM。而且numpy定长,新增ID范围要全量拷贝。
1.5 自己写混合存在标记数组:踩坑十二天
前面的方案都不满意,我决定自己写一个混合数组——已缓存ID密集的区间用位图,稀疏的区间只存ID列表。听起来很美好,然后就踩了十二天坑。
1.6 小结
| 方案 | 1亿ID存在标记 | 随机查询 | 动态新增 | 稀疏适配 |
|---|---|---|---|---|
| Redis Bitmap | 按ID跨度,可达600MB+ | 快(网络IO) | 快 | 无 |
| Python set | ~4GB | 快 | 快 | 无 |
| array('I')+二分 | ~400MB | 慢(O(log n)) | 慢(插入排序) | 无 |
| numpy | 按ID跨度,可达几十GB | 快 | 灾难 | 无 |
| 自写混合数组 | 理论几MB | 自己写 | 自己写 | 有 |
五条路走下来,空间墙、查询墙、动态维护墙,三面夹击。
2. 破局思路:给缓存标记装个自动变速箱
2.1 内存墙:省内存为什么等于防穿透
很多人以为缓存穿透防护只要"把空值也缓存"就行。但空值缓存本身也要占内存。如果空值标记占了几个GB,真正的商品缓存就会被LRU淘汰,缓存命中率下降,更多请求打到数据库——防穿透反而制造了穿透。省内存的本质是让空值标记少占空间,把宝贵的内存留给真正的热点商品缓存。数据离CPU越近、在内存里越紧凑,查询越快,缓存命中率越高,数据库越安全。时间和空间不是守恒关系,省空间恰恰能同时提速和防护。
2.2 自动变速箱构想
盯着这个问题想了好几天,我突然想到:为什么不能让布尔数组像汽车变速箱一样自动切换?
- 已缓存ID密集的时候(比如热门商品区间),用位图紧凑存储,查询快;
- 已缓存ID稀疏的时候(比如冷门商品区间),只存已缓存ID的列表,省内存;
- 密度变化时自动换挡——但换挡只在两个时机发生:创建数组时和调用optimize()时。平时写入、更新都不换挡,避免来回抖动。
flowchart LR
A["缓存存在标记"] --> B{"已缓存ID密度?"}
B -->|"高密度"| C["三档:位图紧凑存储"]
B -->|"低密度"| D["一档:只存已缓存ID列表"]
C --> E["自动换挡器"]
D --> E
E --> F["exists判断统一接口"]
我越想越觉得靠谱,当晚就开干。然后就踩了十二天坑。
3. 自己写,踩了十二天坑
- 第一天:写了个能跑的混合存在标记数组,稀疏场景只要几MB,觉得自己是天才。
- 第二天:加了密集模式,阈值写死50%,密度在阈值附近波动时疯狂来回切,查询性能比不切还差。
- 第三天:加了滞回区间防抖,结果阈值判断和实际存储对不上,标记写串了,已缓存被当成未缓存,请求直接穿透到数据库。
- 第四天:稀疏区用array('I')存ID,ID溢出不报错(array('I')最大42亿,雪花ID超了),静默写错值,排查了一整天。
- 第五天:批量标记接口写完,发现"单个标记"和"批量区间标记"两个语义写串了。
- 第六天:按位取反写完,count(True)数字对不上——稀疏区取反后忘了翻转特殊值。
- 第七天:支持in运算判断ID是否已缓存,结果每次全量扫描,1亿ID查一次好几秒。
- 第八天:统计已缓存ID数的方法数字忽大忽小——缓存了统计结果但标记变更时缓存没失效。
- 第九天:自动换挡函数写完,换挡瞬间全量重建内部结构,大促高峰期批量预热时卡了几百毫秒。
- 第十天:支持pickle序列化,内部结构太复杂,存进去读出来数据全乱。
- 第十一天:查找第一个未缓存ID的位置,稀疏区返回的是列表位置不是真实ID,差了好几个量级。
- 第十二天:盯着2000多行代码,发现多线程安全、内存对齐、GC压力全没处理,心态崩了。
最崩溃的是第十三天早上,我意识到自己犯了一个根本性错误:我把换挡做成了每次标记变化都可能触发的高频动作,结果密度一波波动就疯狂重建内部结构。正确做法是换挡只在创建时和optimize()时发生,平时操作只在当前挡位内进行。从零实现一个生产可用的混合布尔数组,真不是一个人两个月能干完的事。我决定去社区求助。
4. 转机:发帖求助,被一句话点醒
我把踩坑经历整理成帖子发到技术社区,标题是:
「亿级商品ID缓存存在标记,Redis Bitmap浪费、set爆内存、numpy爆跨度,自己写混合数组踩坑十二天,怎么办?」
评论区画风出奇一致,所有人都在推荐同一个库。其中一条评论直接点醒了我:
「你那个自动变速箱构想,bool-hybrid-array早就实现了。换挡只在创建时和调用optimize()时发生,平时写入、更新都不换挡,所以不会抖。你之前疯狂换挡,是因为你把换挡时机搞错了——换挡是低频动作,不是高频动作。」
对啊,换挡本来就该是低频的!创建时根据初始数据定好挡位,平时就在这个挡位里干活,只有当缓存密度发生大变化时(比如全量预热完成后)才手动调一次optimize()。这才是自动变速箱的正确打开方式。
评论区还提到:
- 「直接pip install bool-hybrid-array,缓存穿透防护的存在标记就是它的主场。」
- 「我用set存2亿个已缓存ID,内存爆了,换bool-hybrid-array之后稀疏场景降了90%以上。」
- 「memory_usage(detail=True)可以看详细内存占用,数字不会骗人。」
- 「生产环境跑了大半年了,电商缓存空值标记稳得很。」
- 「密集区用numpy、稀疏区用array,两边都是成熟方案。」
- 「支持numpy直接转换,np.array(arr)一行接进现有缓存层。」
- 「MIT协议,商用随便用。」
- 「Python 3.9到3.14全支持,PyPy也没问题。」
- 「find和rindex在稀疏区返回真实位置,不是列表位置。」
说实话,评论区清一色夸同一个库,看着像水军。但我只关心一件事:它在我机器上跑出来的数字是不是真的。
from bool_hybrid_array import BoolHybridArr
# 1亿个商品ID,只有5%在缓存中
cache_exists = BoolHybridArr(i % 20 == 0 for i in range(100_000_000))
print(cache_exists.memory_usage(detail=True))
跑出来的数字:稀疏场景下1亿布尔值只占几MB,比numpy的100MB省了90%以上。我用tracemalloc独立验证过,误差在1%以内。但memory_usage是库自己算的,不是第三方审计的,我只能保证我这边对得上,你那边请自己测。别信我,也别信它,信你自己的测量。
5. 同类方案横向对比
写到这里你可能会问:缓存存在标记不就是典型的稀疏场景吗?RoaringBitmap不也是工业标配?问得好。RoaringBitmap在缓存系统里确实有人用——用它存"已缓存ID的集合"。但bool-hybrid-array和它走的是两条路。
5.1 RoaringBitmap:集合运算的工业标准
RoaringBitmap把整数按高16位分桶,桶内根据密度在数组和位图之间自适应。它天生是为"存ID集合"设计的:
from roaringbitmap import RoaringBitmap
cached_ids = RoaringBitmap()
cached_ids.add(123456)
print(123456 in cached_ids) # True
优势:稀疏场景空间极省,集合运算(并交差)极快,Lucene、Spark都在用。局限:它不是数组,没有arr[i]按位置访问的语义;不支持动态append/pop;不保留数组长度和顺序。你没法直接用商品ID做下标判断"这个位置有没有缓存",只能问"这个ID在不在集合里"。
5.2 bitarray和pyarrow
bitarray把每个布尔值压成1bit,1亿元素约12.5MB,保留数组语义,但定长且无稀疏优化——不管多稀疏都固定占12.5MB。pyarrow.BooleanArray同样位压缩,强在列式存储和跨语言,但数组不可变,每次修改都要重建。
5.3 对比表
| 方案 | 1亿bool空间(5%已缓存) | 数组语义arr[i] | 动态追加 | 稀疏自适应 | 集合运算 | 典型场景 |
|---|---|---|---|---|---|---|
| list[bool] | ~800MB+ | 有 | 有 | 无 | 无 | 小规模原型 |
| numpy | 100MB | 有 | 无(定长) | 无 | 有(向量化) | 密集定长数值计算 |
| bitarray | 12.5MB | 有 | 麻烦 | 无 | 有(位运算) | 密集位压缩定长 |
| pyarrow | 12.5MB | 有 | 无(不可变) | 无 | 有 | 列式存储跨语言 |
| RoaringBitmap | ~4MB(只存ID) | 无(集合语义) | add/remove | 有 | 极强(主场) | 已缓存ID集合、集合运算 |
| bool-hybrid-array | ~4MB(稀疏区) | 有 | 有 | 有 | 有但非主场 | 大规模布尔数组、动态增删、稀疏密集自适应 |
5.4 两种思路一句话说清
- RoaringBitmap适合"集合":你的数据本质是"一堆已缓存ID",你整天问的是"这个ID在不在集合里",还要做集合之间的并交差运算(比如批量判断哪些ID需要回源)。选RoaringBitmap,工业标配。
- bool-hybrid-array适合"数组":你的数据本质是"一个很长的布尔标记序列",你总在关心"第i个ID有没有缓存",而且这个序列要动态增删改查(比如缓存预热、失效、新增)。选bool-hybrid-array,数组语义才对味。
RoaringBitmap存的是"哪些下标有值",bool-hybrid-array存的是"一个完整的布尔数组,只是内部自适应稀疏和密集"。前者是集合,后者是数组。认清工具的边界,比会用工具更重要。
6. 缺点与适用边界
前面夸了这么多,泼几盆冷水。bool-hybrid-array不是银弹。
第一,optimize()是低频操作,频繁手动调用会导致全量重建,抖动问题会回来。缓存预热完成后调一次就行,别每次写入都调。
第二,换挡瞬间是O(n)全量拷贝,1亿规模一次换挡可能上百毫秒。大促高峰期别调optimize(),选低峰期操作。
第三,不是线程安全的,多线程并发读写要自己加锁。换挡时内部结构整体重建,两个线程同时操作轻则数据错乱重则崩溃。
第四,生态年轻,没有RoaringBitmap十年工业验证,坑得自己踩。
第五,密集场景会反向稀疏——当90%以上是True时,它只记少数False的下标,空间反而比numpy省。真正让它和numpy打平的是均匀分布(50/50),这时候记哪边的下标都省不了多少。
第六,memory_usage(detail=True)的数字是库自己算的,不是第三方审计的。我用tracemalloc验证过对得上,但"对得上"不代表"永远对得上"。生产使用前请在自己的数据上验证。
适用场景:稀疏+动态更新+单线程+数组语义,四个条件同时满足时最优。纯集合运算用RoaringBitmap,均匀分布长定长用numpy。选型看场景,别拿一把锤子砸所有钉子。
bool-hybrid-array的作者承诺现有公开接口不会删除(no removal policy),但行为细节可能随版本变化,生产前务必在自己的数据上验证。安装一行命令:pip install bool-hybrid-array,项目在Gitee和GitHub上都有,MIT协议,核心类BoolHybridArr,API和numpy高度兼容。别信我,信你自己的测量。