从空值到缓存穿透:JSON与布隆过滤器的技术哲学 从一次线上事故说起。我们有个商品详情的缓存服务QPS 高的时候能到两万缓存用 Redis底层是 MySQL。某天发布新版本之后监控突然报警数据库慢查询暴涨。查了一圈问题出在空值上——用户搜了一堆不存在的商品 ID请求穿透了缓存每次都在打数据库而这些不存在根本没有被任何一层拦住。这场事故让我把 JSON 存储和布隆过滤器专门拿出来对比了一遍发现这两个东西在处理空值这件事上的哲学完全相反但组合起来却刚好能解决很多存储层的真实痛点。这篇博文就围绕空值这个字眼把两种方案的设计思想、底层原理、实操参数和踩坑记录都摊开聊一聊希望对正在做存储层设计、缓存防穿透的朋友有参考价值。1. 为什么空值值得单独聊1.1 所有系统都会被没有数据这回事难住日常开发中最容易被忽略的恰恰是空值本身。接口返回 JSON里面某个字段是 null 还是直接不返回很多人觉得无所谓MySQL 里某列是 NULL 还是空字符串也有人觉得差不多查缓存查不到到底是直接返回没有还是继续向下游查更是各写各的。但实际运行起来这些差不多的差距会被放大。一个接口给前端返回 null 和缺失字段前者表示这个字段确实存在只是值为空后者表示这个对象根本没有这个字段两种语义在前端渲染、状态判断、逻辑分支上都能引发完全不同的行为。存储层也一样一个 key 在 Redis 里不存在、存在但值为空、存在但值为一个序列化后的 null三种状态对业务的含义截然不同。到底怎么定义空决定了一个系统的行为边界。JSON 作为应用层最通用的数据交换格式它对空值的表达方式是显式的、确定的但这套表达方式搬到强类型语言和数据库里就会产生错位。布隆过滤器对空值的处理则走向另一个极端它不存原始数据只用位数组里的 0 和 1 来表达在不在0 代表确定不存在1 代表可能存在连空值这个概念都没有。1.2 JSON 和布隆过滤器放在一起对比的意义有人会问JSON 是数据格式布隆过滤器是概率数据结构两者八竿子打不着为什么要放在一起对比因为它们在存储链路里经常扮演相邻的两层。JSON 负责承载业务数据的最终形态布隆过滤器负责在缓存和数据源之间做存在性判断。一个负责怎么存一个负责判断存没存过而这两件事的交汇点恰恰就是空值处理。缓存穿透的本质就是空值没有被正确表达和拦截这既涉及 JSON 层对空结果的序列化策略也涉及布隆过滤器层对不存在数据的快速否决能力。深入拆解之后会发现两者对空值有着完全对立的假设。JSON 假设空值是一种需要被明确表达的状态所以定义了 null 作为一等公民布隆过滤器假设空值是一种需要被快速否决的情况所以用 0 位表示确定没有。一个在语义上追求精确一个在性能上追求极致理解这种对立设计存储层方案时思路会清晰很多。2. JSON 里的空值显式缺席的确定语义2.1 null、缺失字段和空字符串的三种语义JSON 规范定义了四种基础类型加上 nullnull 表示该位置的值是空的。但实际工程里我们经常会接触到三种容易混淆的状态第一种是显式 null即对象里的某个键存在值为 null比如{nickname: null}。它表达的是这是一个用户对象它有昵称这个属性只不过该用户没设置昵称。第二种是字段缺失比如{nickname: }但这个键压根没出现在 JSON 里它表达的是这个对象没有昵称属性或者序列化时主动忽略了该字段。第三种是空字符串即{nickname: }这是真实存在的一个字符串长度为零和 null 完全不同。举个例子就明白三种状态的差异了。用户资料表里有一列 addressA 用户没填写地址B 用户填写了但地址确实是空字符串C 用户的数据模型里根本没有 address 字段。数据库层面三者可以分别用 NULL、空字符串、无该列来区分但一旦经由 JSON 输出如果序列化配置不当这三种状态可能全部变成同一形态或者全部报错业务层到时候想区分没设置和设置了但是空就彻底没戏。很多反序列化错误也源自这三种状态没分清。比如热词里那条failed to deserialize the json body into the target type: input: missing fie...典型场景就是 Rust 的 serde 或 Spring 的 Jackson 在反序列化 JSON 时要求某个字段必填但请求体里这个字段缺失了于是直接把整个请求打回。这种缺字段即报错的策略在某些场景下是合理的但如果服务端只是想知道这个字段没传而不是传入非法值直接报错就过于粗暴了。2.2 JSON null 在不同语言里的本质差异这里有一个很容易踩的坑JSON 的 null 是统一的但不同语言对它的承载方式完全不同。Python 里 JSON null 会被解析成 None而 None 是唯一的单例对象可以直接用is None判断非常干净。Go 里 JSON null 对应 nil解析到一个指向结构体的指针时会把指针置为 nil但如果目标是一个string类型的字段直接解析 null 会直接报错因为字符串类型不能承载 nil。Java 里 JSON null 是多少年争论不断的话题用 Integer 包装类型可以接收 null用基本类型 int 就会在反序列化时抛异常项目里经常出现为什么我接口里传了 null 就报 500的疑问根因就在这。JavaScript 里又额外多出一个 undefinedJSON.stringify 时 undefined 会被直接丢弃这导致前后端对缺失的理解经常不一样。所以JSON 中的空值这句话本身就是个陷阱它看起来统一实际上语义完全由消费方的语言和类型系统决定。设计跨语言接口时最好在接口文档里明确约定三种情况的使用规则否则前后端各自按自己的理解处理空值早晚会出事。2.3 序列化与反序列化中的空值策略选型既然语言差异这么大工程上就需要明确的空值策略。我在实际项目里总结出几条可落地的规则一是字段缺失和 null 要二选一不要混用。如果团队约定值为空的字段一律返回 null 而不是省略那就全链路统一执行如果约定空值直接不返回那所有序列化配置都要保证一致的 omit 行为。混用的灾难场景是老接口用 null 表示空新接口用缺失表示空前端就得同时写两套判断。二是反序列化时强类型字段要避免 primitive 类型。Java 里优先用包装类型 Integer、LongGo 里优先用指针或者sql.NullString这类带是否有效标记的结构Python 里尽量用 Optional 类型注解。这样 null 和缺失都能安全落入业务模型不会因为语言底层限制产生意外异常。三是给前端返回时建议统一用 null 而不是缺失。原因是缺失字段在浏览器的 Object.keys 和 for...in 遍历中直接不可见前端很难区分接口没返回和返回了但还没加载出来。用 null 时程序员至少能明确知道这个键存在只是值为空。热词里大量出现 json 格式化工具、json 转换、json 解析、vscode 拓展更改存储位置这类内容说明很多开发者的日常工作依然耗在 JSON 的解析和格式化上而这些工作往下追一层大多都跟空值语义有关。3. 布隆过滤器里的空值概率世界的存在哲学3.1 原理回顾位数组和哈希函数的组合布隆过滤器的工作原理不复杂但每次讲都要把细节强调一遍。它用一个大位数组和 k 个独立的哈希函数来记录集合中元素的痕迹。插入一个元素时计算 k 个哈希值把对应位置的位全部置为 1查询一个元素是否存在时同样计算 k 个哈希值检查所有对应位置是否都是 1。如果任何一个位置是 0那就可以百分百确定这个元素不在集合里因为如果它在插入时这些位置势必已经被置 1不会被漏掉。但如果所有位置都是 1只能说明它可能在因为位置为 1 可能是其他元素的哈希痕迹偶然重叠导致的。这就是布隆过滤器最核心的特性对不存在的判断是绝对确定的对存在的判断是概率性的。用生活类比来说它就像一张签到登记表每个人来的时候在本子上多个固定位置打勾。查询一个人来没来过时只要发现他对应位置有空白就能断定他没打过卡即使所有位置都有勾也可能是别人把那些位置占满了他本人未必来过。3.2 布隆过滤器根本没有空值这个状态现在回到空值这个主题。布隆过滤器里所有位置初始都是 0插入元素就是把某些位置变成 1。在整个生命周期里一个位只有两种状态0 和 1不存在未知或者空的状态。这个设计哲学跟 JSON 完全相反。JSON 费尽心思用 null、undefined、缺失字段来表达这里有一个明确的空洞布隆过滤器则直接不跟你谈空这个事。它既不存原始值也不存任何属于业务语义的内容只保存哈希运算留下的痕迹。0 位表达的语义是没有任何已知元素的哈希落在这个位置这更像是一种数学上的证据而不是业务上的空值。从工程角度理解布隆过滤器的核心价值就是让不存在这件事变得极其廉价。数据库里查一条记录是否存在可能要经历索引查找、磁盘 IO、网络传输布隆过滤器只需要算几个哈希做 k 次位数组读取全程耗尽的工作量几乎可以忽略。它把空值判断从一次完整的存储访问简化成常数级别的位运算。这里还有一个看似反直觉的地方。布隆过滤器对不存在的回答是绝对可靠的对存在的回答反而是不确定的。而 JSON 里恰恰相反如果一个键存在它的值要么是某个确定的数据要么是确定的 null不存在可能值这种说法如果一个键缺失反而需要依赖调用方对模式的理解才能确定语义。一个是数据结构层面追求精确语义却在实际工程中产生歧义一个是概率结构的天然不确定性却被设计用来做最硬的存在性否决。3.3 假阳性率公式与参数选择的实操布隆过滤器的参数选择直接决定空值哲学能否在实际场景中落地。核心参数有三个预期元素数量 n、位数组长度 m、哈希函数个数 k三者与假阳性率 p 的关系是p ≈ (1 - e^(-kn/m))^k实际使用时通常先确定 n 和可接受的 p再反推 m 和 k。m 的最优值是 -n * ln(p) / (ln 2)^2k 的最优值是 m/n * ln 2。举个例子。假设一个商品系统里有十万个有效商品 ID希望假阳性率控制在 1%那么 m -(100000 * ln(0.01)) / (ln2)^2 ≈ -100000 * (-4.60517) / 0.48045约等于 958,505 位也就是 117KB 左右。k (958505 / 100000) * 0.6931 约等于 6.64取整数 7。也就是说用 117KB 的空间和 7 个哈希函数就能为十万个商品 ID 提供 99% 以上的存在性判断准确率。这个空间代价放在缓存层面非常划算。一万个商品的 ID 列表本身可能就要几百 KB但布隆过滤器用 117KB 就完成了存在性索引而且查询耗时与数据量几乎无关。真正的工程问题是哈希函数的选择。生产环境常用 MurmurHash、FNV、xxHash 这类分布均匀的非加密哈希配合不同的种子派生 k 个哈希避免引入多个独立哈希函数的实现成本。4. 空值哲学的正面碰撞4.1 确定性 vs 概率性的行为分歧把两种技术放在一起对比最核心的分歧是是否允许不确定性存在。JSON 的空值表达是确定的null 就是 null缺失就是缺失布隆过滤器则刻意引入可控的不确定性用假阳性率换空间和速度。这个分歧带来的工程后果非常明显。基于 JSON 的系统在做存在性判断时必须依赖真实的存储查询因为只有真正去读那份数据才能确认它是不是 null、是不是存在这个过程天然是重量级的。基于布隆过滤器的系统则把存在性判断前置用极小代价过滤掉绝大多数确定不存在的请求只有那些可能存在的请求才会进一步访问真实存储。两种哲学各有各的代价。JSON 的确定语义让业务逻辑可以精确处理空值却让每一次空值确认都可能触发一次完整存储访问。布隆过滤器的概率特性让空值拦截变得极快却允许少量元素根本不存在但过滤器说可能存在的请求漏过去这些漏网之鱼如果落到数据库上就是一种概率性穿透。4.2 存储与缓存链路中的真实冲突在缓存链路里这种冲突会被反复放大。最常见的场景是缓存穿透流程是查询一个 key缓存里没有于是去数据库查数据库里也没有返回一个空结果。如果这个 key 是恶意构造的、大量不存在的 ID那么缓存层永远无法命中数据库会被这些空值请求持续打爆。有人会说可以把空结果也缓存起来这就是空值缓存思路是把 null 或一个特定标记存入缓存并设置较短的过期时间。这个方案能解决一部分问题但布隆过滤器提供了另一条路在缓存之前先布一层存在性过滤器凡是过滤器判定不存在的请求直接返回未命中根本不进入缓存和数据库。实际工程里这两种方案经常被组合使用因为布隆过滤器的假阳性注定了判定存在的请求里可能混入少量不存在的 ID这些请求还是要靠空值缓存来兜底。这里就出现了两层空值哲学的嵌套外层布隆过滤器用概率否决应对大多数空请求内层缓存用显式的空值标记兜底剩余的漏网之鱼。懂了这个嵌套关系才能设计出既快又稳的存储链路。4.3 同一个问题两套答案一张对比表为了更直观地对比我整理了下面这张表对比维度JSON 中的空值布隆过滤器中的空值表达方式null、缺失字段、空字符串位数组中的 0语义确定性确定null 就是 null0 位确定代表不存在1 位只是可能对存在的回答确定且精确概率性有假阳性对不存在的回答依赖查询结果绝对确定0 位即否决代价需要真实存储访问确认常数级位运算典型问题跨语言语义错位、反序列化报错空间参数选择不当导致假阳性偏高工程角色数据承载与交换存在性快速过滤这张表多看几遍会发现一个有趣的互补关系JSON 的弱点是确认空值太贵布隆过滤器的弱点是确认存在不可靠恰好互相弥补。用布隆过滤器先挡掉确定不存在的请求用 JSON 在业务层精确表达确实是空的返回体二者各司其职就能把空值处理从成本黑洞变成高效机制。5. 实操构建一个防穿透的存储查询服务5.1 场景设定与架构拆解直接上实战。假设我们要做一个商品查询服务核心接口是根据商品 ID 返回商品详情 JSON。数据在 MySQL前面加 Redis 缓存现在要解决两件事一是高并发下大量不存在的商品 ID 打穿缓存直到数据库二是接口对空值结果有明确的语义约定。架构上是四层最前面是查询入口往下走是布隆过滤器层然后是 Redis 缓存层最后是 MySQL 层。布隆过滤器层只负责回答这个 ID 可能存在吗回答不存在就直接返回一个 JSON 格式的空结果Redis 层负责缓存存在的商品和少量穿透空值MySQL 是最终的数据源。这个拆解的价值在于布隆过滤器层拦截掉 99% 以上的无效 IDRedis 的命中率因此大幅提高MySQL 收到的查询里基本都是真正有效的请求整体负载曲线被拉平。5.2 布隆过滤器参数计算与实现代码按前文的思路商品 ID 总规模约十万假阳性率目标 1%。用 Python 实现一个精简版便于测试和观察import math import mmh3 from bitarray import bitarray class BloomFilter: def __init__(self, items_count, fp_prob): self.fp_prob fp_prob self.size int(-(items_count * math.log(fp_prob)) / (math.log(2) ** 2)) self.hash_count int((self.size / items_count) * math.log(2)) self.bit_array bitarray(self.size) self.bit_array.setall(0) def add(self, item): for i in range(self.hash_count): index mmh3.hash(item, i) % self.size self.bit_array[index] 1 def check(self, item): for i in range(self.hash_count): index mmh3.hash(item, i) % self.size if not self.bit_array[index]: return False return True bf BloomFilter(100000, 0.01) print(位数组大小(Bytes):, len(bf.bit_array) // 8) print(哈希函数个数:, bf.hash_count) bf.add(sku_100001) print(bf.check(sku_100001)) # 输出 True print(bf.check(sku_999999)) # 大概率输出 False这段代码里有一个关键点容易被忽视mmh3.hash(item, i)的第二个参数是种子这样能从一个哈希函数派生出多个相互独立的哈希值避免实现多个哈希函数带来的额外复杂度。实际生产里可以把位数组放入 Redis 的 BITMAP 中用 Lua 脚本或者分布式锁保证并发安全避免单机内存重启丢失的问题。5.3 JSON 空值语义在接口层的落地过滤器层说不存在之后接口直接返回的标准空结果建议统一为{ code: 404, data: null, message: product not found }这里的data: null就体现了 JSON 的空值哲学。它不是把整个data字段删掉而是显式声明这个响应有 data 这个键但它的值是空的前端拿到后可以直接做空态判断不需要担心字段未定义导致的 TypeError。如果攻击者知道了这套规则也可以根据布隆过滤器的返回差异来判断某个 ID 是否存在所以在敏感场景下响应体尽量要保持结构一致避免让 data 为 null 和 data 为对象 成为信息泄露的侧信道。5.4 缓存层与空值缓存兜底机制布隆过滤器有假阳性所以不能完全替代空值缓存。完整流程是第一步查询请求进来先走布隆过滤器check 返回 False 就直接返回 JSON 空结果不再往下访问。第二步check 返回 True进入 Redis 查询。这一步要区分三种结果键存在且值为商品 JSON直接返回键存在且值为一个空值占位符直接返回约定的空结果键不存在继续往下查数据库。第三步数据库查询。查到了就把商品 JSON 写入 Redis并设置常规过期时间查不到就写入一个空值占位符比如一个约定好的特殊 JSON 或空字符串过期时间设置得比真实数据短比如 60 秒到五分钟。空值缓存的哲学也很微妙。它把一个不存在的状态缓存在了 Redis 里让后续同样的无效请求不再穿透到数据库。但空值占位符本身必须有明确的语义不能让业务把缓存里是空值和缓存里没这个 key混淆否则就会出现刷新缓存后查不到数据、空值占位符却还在的时效性问题。我从实践中得到的一个土方法是空值占位符的值用一个带前缀的字符串比如__NULL__业务读取的时候先判断这个前缀再决定是否继续查询数据库。这样既不会把 null 真正写入 JSON 序列化后的缓存避免反序列化时类型不匹配又能快速识别空值状态。6. 常见问题与排查心得问题原因分析解决方案布隆过滤器假阳性偏高位数组太小或哈希函数数量不在最优区间按 m 和 k 公式重新计算参数必要时加大位数组缓存穿透仍然发生只用了布隆过滤器没做空值缓存漏网请求压垮数据库在缓存层增加空值占位符设置短 TTLJSON 反序列化报 missing field请求体缺少必填字段或字段名大小写不匹配明确接口字段约定把可空字段声明为 Optional 类型前端拿不到 null 判断依据后端把 null 字段省略了在序列化配置中关闭忽略空值选项统一返回 nullnull 被误判为 0 或空字符串业务代码没有区分 null 和默认值用is None、 nil等显式判断避免和基础类型默认值混淆布隆过滤器重启后丢失位数组只存在内存中定期持久化到 Redis 或文件启动时加载6.1 JSON 空值导致的反序列化错误热词里那条failed to deserialize the json body into the target type: input: missing fie...我要特别展开讲。这是我见过最多的 JSON 空值错误之一。最常见的原因有几种一是前端以为不传字段就等于 null但后端把该字段声明成了必填二是字段名拼写不一致JSON 里是 snake_casestruct 里是 camelCase三是版本迭代后接口新增了必填字段旧客户端还在用旧格式请求。排查这类问题我习惯先打印原始请求体再用 json 格式化工具肉眼确认字段是否存在而不是直接对着报错猜。很多时候missing field的真实原因是某个字段的值是 null 但序列化时被忽略掉了导致反序列化端看到的 JSON 里根本没有这个字段。这种情况下解决方案不是让客户端传一个显式 null而是调整序列化配置把忽略空值的行为关闭确保 null 能够以field: null的形式出现在 JSON 里。6.2 布隆过滤器位数组与 JSON 的存储空间对比有人会问位数组这么省空间为什么不干脆用它替代缓存或者索引这个问题正好点中了两种哲学的本质差异。布隆过滤器只回答在不在不回答是什么它没有能力也没有义务返回具体数据。JSON 缓存的职责是承载完整业务数据数据量大、结构复杂但每一次查询都能拿到语义完整的答案。我用一个电商场景来对比十万商品 ID 的布隆过滤器位数组约 117KB而十万个商品详情 JSON 如果每个平均 2KB就是 200MB 起步。前者在内存里连一个普通缓存服务器的零头都占不到后者可能要把缓存集群撑爆。正因如此布隆过滤器往往是架构里最轻薄的一层而 JSON 存储在数据完整性和响应体复杂度上承担更大的责任。两者定位不同硬要拿来互相替代是没有意义的。6.3 我踩过的几个坑第一个坑是只加布隆过滤器、不加空值缓存。当时以为布隆过滤器能把不存在的请求全部挡住忽略了它的假阳性率。结果 1% 的假阳性在高 QPS 下被放大成每秒上百个无效请求打到数据库慢查询曲线又起来了。后来把空值缓存补上用 60 秒短 TTL 兜底问题立刻缓解。第二个坑是把布隆过滤器的位数组放在应用本地内存没有持久化。每次发布新版本应用重启位数组全部归零等于过滤器失效了流量直接穿透到数据库。后来把位数组迁到 Redis用 BITFIELD 命令做分布式位操作才彻底解决重启失效的问题。第三个坑是 JSON 层面对 null 的序列化配置在不同微服务之间不统一。A 服务返回{address: null}B 服务返回{address: }C 服务直接把 address 字段省略前端对接这三个服务至少要写三种空态判断。最后团队在 API 规范里统一为空值一律返回 null并给所有 Java 服务配置了JsonInclude.Include.NON_NULL的反向规则才把问题压下去。这些坑让我越来越觉得空值从来不是一个小事它贯穿了从 JSON 语义到概率过滤的整个存储链路。两种方案看似各走极端但真正理解了它们的哲学就知道在架构里该让谁打头阵、该让谁兜底。我个人目前最舒服的搭配就是这套组合拳布隆过滤器负责用最小的代价把确定不存在的请求挡在门外JSON 负责在接口语义上把空值表达得清清楚楚空值缓存负责吸收布隆过滤器那一点点假阳性漏网之鱼。三者各司其职整套链路既扛得住高并发又不会在空值处理上出幺蛾子。如果你现在正被缓存穿透或者空值语义混乱困扰可以试着按这个思路梳理一遍自己的存储链路大概率能找到那个一直在添乱的空值。