news 2026/10/27 11:17:49

营销黑名单查询优化:本地Roaring Bitmap实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
营销黑名单查询优化:本地Roaring Bitmap实践

1. 黑名单查询为什么成了营销系统的隐形瓶颈

营销系统的黑名单,说白了就是三张纸:不能发券的人、不能下单的人、不能参与活动的人。业务侧看很简单,一张表几个字段,查一下命中就拦掉。但真到了大促这种量级,黑名单查询往往是整条链路上最容易被忽视、又最先崩掉的一环。我参与过一个营销中台的名单过滤重构,核心诉求就是把这层查询从"每次请求都要碰一次数据库"改成本地内存里的一次位运算,最终方案选的是位图(Bitmap)。这篇就把这套东西怎么设计、怎么算账、踩了哪些坑,完整讲一遍。适合正在做名单过滤、人群包圈选、风控前置拦截、活动资格预判的同学参考,哪怕你现在只用 MySQL 顶着,也能从中判断出什么时候该换方案。

先把问题摆清楚。营销系统的请求链路通常是这样的:用户点"立即领取",服务端要过一串黑名单规则——设备黑名单、账号黑名单、历史作弊名单、活动互斥名单、渠道黑名单,五到八条规则串行查一遍,全部通过才继续往下走。每条规则背后是一份几十万到几百万条的名单。名单本身不大,但查询次数乘上去就非常可观。假设峰值 QPS 12 万,每条请求平均过 6 条规则,那就是每秒 72 万次名单判定。这个量级,任何一个"正常"的关系型数据库都扛不住,哪怕命中索引、哪怕走主键。

1.1 老方案的三个真实瓶颈

最早用的是 MySQL 单表加主键索引,select 1 from blacklist where user_id = ? and scene = ? and status = 1 limit 1,单次查询 1~3ms。听起来还行,但 72 万 QPS 意味着需要几百个连接和几十台从库,连接池排队比查询本身还慢。更麻烦的是名单变更频繁,运营一天改十几轮,主从延迟会让"刚拉黑的人还能领券",这种投诉非常难解释。

第二版改成 Redis Set,SISMEMBER blacklist:scene user_id。性能确实上来了,单次 0.3ms 左右。但内存账不好看:一条 userId 在 Redis Set 里的实际占用远不止 8 字节,底层是哈希表加 SDS 字符串,算上 dictEntry、指针、内存对齐和哈希表负载因子,实测下来平均每条 60~80 字节。500 万条的名单,单副本就要 300~400MB,而这份数据往往有七八个场景,再加多副本和主从,内存直接起飞。而且每次判定都是一次网络往返,72 万 QPS 对 Redis 集群也是不小的压力。

第三版有人提议用布隆过滤器,这个方向我直接否了。原因很简单:布隆过滤器有假阳性,它只能说"可能不在",不能说"一定在"。黑名单场景最怕的就是误杀——用户明明没问题却被拦下来,客诉成本远高于漏放。布隆过滤器适合做前置挡板,把绝大部分不在名单里的请求挡掉,剩下少量再去精确查询,但它不能作为最终判定依据。

注意:凡是"宁可放过一千,不可错杀一个"的场景,别用布隆过滤器做唯一判据。它的价值在削峰,不在定性。

1.2 位图方案的核心账本:一次容量与性能的换算

位图的思路极其朴素:给每个用户分配一个比特位,位是 1 就代表在黑名单里,是 0 就不在。查询变成"查第 N 位是几",本质是一次内存数组下标访问,没有哈希、没有字符串比较、没有网络往返。

算笔账。假设用户总量 1 亿,那就是 1 亿个比特位,也就是100000000 / 8 = 12.5MB。12.5MB 是什么概念?一台 4G 内存的应用节点,可以装下 300 份这样的完整位图。哪怕你有 8 个场景,全量加载也才 100MB 出头,还能再放一份在堆外做热备。

更关键的是查询开销。位图判定是一次位运算,本地内存访问,耗时在纳秒到微秒级,实测单次RoaringBitmap.contains(id)稳定在 50~200 纳秒。对比 Redis 的 0.3 毫秒,快了三个数量级;对比 MySQL 的 1~3 毫秒,快了四个数量级。这意味着原来需要几百台机器扛的查询,现在单机就能扛住,而且是纯 CPU 计算,不占连接、不占 IO。

当然,这笔账有个前提:位图的下标必须是连续的、紧凑的整数。如果你直接拿雪花 ID 当偏移量,那就出大事了。雪花 ID 是 64 位长整型,高位带着时间戳,随便一个 ID 都能到 10^18 量级。Redis 的SETBIT偏移量上限是 2^32 - 1,大约 42.9 亿,超了直接报错。就算不报错的场合,你在偏移量 10 亿的位置置一个位,Redis 也得老老实实给你分配 125MB 的内存——这就是位图最经典的"稀疏灾难"。

所以真实的工程方案里,位图前面一定要加一层ID 映射:用一个自增的、连续的内部序号(offset)来替代业务 ID,位图只认这个序号。这层映射通常是"业务 ID → 连续序号"的字典,本身就是个小表或小缓存,查询时先换序号,再查位图。

1.3 方案选型:几种落地形态的横向对比

在决定用哪种位图之前,我把几种常见落地形态摆在一起对比过,维度集中在内存、查询延迟、更新成本、运维复杂度四项上。

方案单次查询延迟500 万名单内存占用单点更新运维复杂度适用判断
MySQL 单表1~3ms磁盘为主,索引缓存几百 MB直接 update低名单小于 10 万、QPS 低于 500 可用
Redis Set0.2~0.5ms约 300~400MBSADD/SREM中名单小、场景少、能接受网络往返
布隆过滤器0.05~0.1ms约 6MB(1% 误判率)不支持删除中只做前置过滤,不做最终判定
Redis Bitmap0.2~0.5ms(含 RTT)12.5MB(全量位位图)SETBIT中名单大、需要多节点共享
本地 Roaring Bitmap0.0002ms5~13MB(看密度)需自己维护同步偏高名单大、QPS 极高、能接受最终一致

最终的取舍是以本地 Roaring Bitmap 为主、Redis Bitmap 为兜底、数据库为真理源的三级结构。理由不复杂:黑名单是典型的读多写少、允许秒级延迟的数据,把它的读性能推到极限(本地内存)、把写性能放宽到秒级(异步同步),是性价比最高的选择。

这套组合里,Roaring Bitmap 是关键。它不是普通的裸位图,而是在位图基础上做了容器化压缩,稀疏区间用有序数组存,稠密区间用 8KB 的位图块存,兼顾了内存和运算速度。下一节专门拆它的原理。

2. 位图到底省在哪:从裸位图到 Roaring 的压缩机制拆解

很多人对位图的理解停在"用 bit 代替 byte",觉得省了 8 倍空间就完了。这个理解只对了一半。裸位图确实比Set<Long>省,但它在稀疏场景下依然浪费,而且不支持高效的集合运算。真正让位图在名单过滤场景站住脚的,是它的位级并行和容器化压缩这两个特性。

2.1 裸位图:一次数组寻址完成判定

标准的位图就是一个long[]数组。要判断 ID 为n的用户是否在名单里,做法是:找到第n / 64个 long,取出它,右移n % 64位,与 1 做按位与,结果非零就是命中。

index = n >> 6 // 相当于 n / 64 offset = n & 63 // 相当于 n % 64 hit = (bits[index] >> offset) & 1L

三步算术加一次内存访问,没有循环、没有分支预测失败。这种结构对 CPU 极其友好,而且天然支持向量化——一次处理 64 位,等于一次比较 64 个用户。

更重要的是集合运算的并行性。两份名单求交集,普通 Set 要遍历小的那份、逐个去大的里面查,复杂度 O(min(m,n)) 次哈希查找;位图求交集只需要把两个 long 数组按位与,一次&操作同时处理 64 个用户,复杂度降到 O(n/64) 次操作。这个差距在大名单上是碾压性的——500 万条的交叉比对,Set 要几百万次哈希,位图只要 8 万次按位与。

日常业务里这个能力用得非常顺手。比如"既在作弊名单、又在活动互斥名单"的双重命中判定,或者"本次活动的目标人群 = 活跃人群 ∩ 有券人群 − 黑名单人群",用位运算一行就出来了,不需要写任何 JOIN。

2.2 Roaring Bitmap 的容器分层:稀疏用数组,稠密用位块

裸位图的问题在于:不管名单里有多少人,它都要按最大 ID 分配完整空间。1 亿用户就是 12.5MB,哪怕名单里只有 1000 个人,也照样占 12.5MB。这在名单稀疏的场景下非常浪费。

Roaring Bitmap 的做法是把 32 位整数空间按高 16 位切成最多 65536 个"桶",每个桶管一段连续的 65536 个 ID。桶内部根据数据密度自动选择两种容器:

  • ArrayContainer:桶内元素少于 4096 个时,用有序 short 数组存,每个元素 2 字节。4096 个元素占 8KB,和位图容器打平,这就是 4096 这个阈值的由来(4096 * 2 = 8192)。
  • BitmapContainer:桶内元素达到或超过 4096 个时,换成固定 8KB 的位图(65536 bit = 8192 字节)。

这个设计非常巧妙。它保证了任何情况下每个桶的占用都不超过 8KB,也就是整体内存上限和裸位图一致;同时稀疏桶只占实际元素数的 2 倍字节,能做到远低于裸位图。

拿我们的实际数据算一遍。总用户 1 亿,黑名单 500 万,平均密度 5%。均匀分布下,每个桶(65536 个 ID)里大约命中 3277 个,低于 4096 阈值,所以绝大部分桶走 ArrayContainer。总内存 = 500 万 × 2 字节 ≈ 10MB,再加上约 1526 个桶的容器对象开销(每个几十字节),总计 10MB 出头。和裸位图的 12.5MB 相比省得不多,但关键是稀疏场景下的优势巨大:如果某个场景的黑名单只有 10 万条,裸位图仍要 12.5MB,Roaring 只要 200KB 左右。

提示:别死记"Roaring 一定比位图省"。它的价值是内存上限可控(不超过裸位图)+ 稀疏时省得多 + 交并运算快。稠密场景下它和裸位图基本等价。

实际选型时还有个小细节:如果你的 ID 空间不超过 2^32(约 42.9 亿),用 32 位的RoaringBitmap;超过就得用Roaring64NavigableMap,后者内部是 32 位位图的分片树,查询多一层跳转,性能略降但可接受。绝大多数营销系统的用户量都在 32 位范围内,优先用 32 位版本。

2.3 为什么不用布隆过滤器:假阳性在名单场景是致命的

前面提过一嘴,这里展开说清楚,因为这是我在评审时被问得最多的一个问题。

布隆过滤器的空间效率确实惊人,1% 误判率下每个元素只要约 9.6 bit,500 万条只要 6MB,比 Roaring 还省。但它的两个特性决定了黑名单场景不能用它做最终判据:

第一,假阳性不可消除。布隆过滤器判定"不在"是绝对准确的,判定"在"则有误判概率。对黑名单来说,误判意味着把一个正常用户拦在门外,这是业务事故。你可能会说 1% 误判率不高,但营销系统一天几千万次判定,1% 就是几十万次误杀,客诉会直接打爆。

第二,不支持删除。用户申诉成功需要从名单里移除,布隆过滤器做不到。虽然有计数型布隆过滤器(Counting Bloom Filter)这类变种,但空间开销上去了,还得处理计数溢出,复杂度陡增。

我最终的用法是:布隆过滤器只用在一层可选的"前置削峰"上,专门挡掉那 95% 根本不在任何名单里的请求,剩下的 5% 再走精确位图判定。这样既拿到了布隆的极低开销,又保证了判定结果的准确性。不过实测下来,本地 Roaring 位图的查询本来就是纳秒级,加这层前置的收益有限,后来干脆去掉了,链路越短越不容易出问题。

3. 落地实操:从业务 ID 到可查询位图的完整链路

原理讲完,进入最有价值的部分——这套东西到底怎么落地。我会按"ID 映射 → 位图构建 → 写入同步 → 查询接入 → 一致性保障"的顺序走一遍,每一步给出可直接参考的实现和参数。

3.1 ID 映射:连续序号是整个方案的基石

前面强调过,位图的下标必须是紧凑整数。我们系统里的 userId 是 19 位长整型,直接当偏移量会炸。所以第一步是建一张映射表:

CREATE TABLE user_offset_map ( user_id BIGINT NOT NULL COMMENT '业务用户ID', offset_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '位图内部序号,从0开始', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (offset_id), UNIQUE KEY uk_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这张表的offset_id自增、连续、从 0 开始,正是位图需要的下标。新用户注册时插入一行,拿到 offset;老用户则查表获取。为了扛住高频查询,这张映射表全量加载进本地 Caffeine 缓存,同时用 Redis Hash 做二级缓存,offset_id本身也可以用 Redis 的INCR生成来避免数据库自增的写热点。

这里有个必须提前想清楚的问题:映射表会一直增长,位图的下标空间也跟着增长。1 亿用户意味着位图最多 12.5MB,10 亿就是 125MB。所以映射表要做容量规划,并且和位图一起定期快照。

还有一个坑:如果你们系统的用户量增长快,映射表用BIGINT AUTO_INCREMENT可能因为批量导入、回滚导致 offset 不连续(自增 ID 有空洞)。空洞本身不影响正确性,只是浪费一点位图空间,可以接受。但如果为了"连续"去用MAX(offset)+1手工分配,并发下必然冲突,绝对不要这么干。

3.2 位图构建:全量重建 + 增量更新的双轨机制

位图的数据来源是黑名单明细表,真理源永远是数据库:

CREATE TABLE blacklist_detail ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, scene VARCHAR(64) NOT NULL COMMENT '业务场景:coupon/order/activity...', source VARCHAR(64) NOT NULL COMMENT '数据来源:风控同步/人工拉黑/申诉回滚', expire_at DATETIME NULL COMMENT '过期时间,NULL表示永久', status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0已删除', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_scene_user (scene, user_id), KEY idx_update (update_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

构建任务分两条轨道走。

全量重建轨道:每天凌晨低峰期跑一次,按场景扫全表有效记录,逐条翻译成 offset 后置位,构建出新的位图实例,构建完成后通过原子引用替换掉旧的。构建过程大约需要:500 万条记录,每条一次映射查询(走本地缓存命中率 99% 以上)+ 一次位设置,单机 8 线程并行,实测 40~60 秒完成,完全可以放在凌晨窗口里。

增量更新轨道:监听数据库 binlog 或订阅业务侧发出的变更消息,把insert/update/delete转成位操作,毫秒级推送到各个应用节点。这条轨道负责实时性,全量轨道负责兜底修正,两者互补。

关键点是增量必须幂等。消息可能重复投递、可能乱序,所以每条消息都带上update_time,节点本地记录已处理的最大时间戳,旧消息直接丢弃。位图置位本身就是幂等操作(置 1 两次还是 1),但"删除"和"新增"的乱序会导致最终状态错误,必须靠时间戳兜住。

public class BlacklistBitmapHolder { // 场景 -> 位图,用 volatile 保证可见性,切换时整体替换引用 private volatile Map<String, RoaringBitmap> bitmapMap = Collections.emptyMap(); private final Map<String, Long> lastSyncTime = new ConcurrentHashMap<>(); /** 全量重建:构建新实例后原子替换,旧实例等读请求自然退出 */ public void rebuild(String scene, List<Long> userIds) { RoaringBitmap fresh = new RoaringBitmap(); for (Long uid : userIds) { Long offset = offsetCache.get(uid); if (offset != null && offset >= 0) { fresh.add(offset.intValue()); } } Map<String, RoaringBitmap> copy = new HashMap<>(this.bitmapMap); copy.put(scene, fresh); this.bitmapMap = Collections.unmodifiableMap(copy); } /** 增量更新:幂等,可重复消费 */ public void applyDelta(String scene, long userId, boolean add, long eventTime) { long last = lastSyncTime.getOrDefault(scene, 0L); if (eventTime < last) { return; // 过期消息,丢弃 } Long offset = offsetCache.get(userId); if (offset == null || offset < 0) { return; } Map<String, RoaringBitmap> copy = new HashMap<>(this.bitmapMap); RoaringBitmap bm = copy.computeIfAbsent(scene, k -> new RoaringBitmap()); if (add) { bm.add(offset.intValue()); } else { bm.remove(offset.intValue()); } lastSyncTime.put(scene, eventTime); this.bitmapMap = Collections.unmodifiableMap(copy); } public boolean hit(String scene, long userId) { Long offset = offsetCache.get(userId); if (offset == null || offset < 0) { return false; // 未映射的用户不可能在黑名单里 } RoaringBitmap bm = bitmapMap.get(scene); return bm != null && bm.contains(offset.intValue()); } }

这段代码里有个容易忽略的性能点:RoaringBitmap的add/remove会修改内部容器结构,不是线程安全的。如果读请求在遍历的同时有写请求在改,会出现脏读甚至抛异常。我的处理是"写时复制"——每次增量更新都基于旧引用构建,改完再整体替换,读请求永远读到一个不可变的快照。代价是每次更新要复制一份位图引用(注意不是复制位图数据,RoaringBitmap的拷贝是浅拷贝容器数组,成本很低),换来的是读写完全无锁。

注意:RoaringBitmap.clone()是深拷贝,几百 MB 的位图克隆一次要几十毫秒,千万别在增量路径上调用。要么用不可变快照 + 容器数组浅拷贝,要么老老实实加读写锁。我们选的是后者,因为增量 QPS 只有几百,锁竞争可忽略。

3.3 查询接入:三级缓存的读取顺序

查询链路的顺序是本地位图 → Redis Bitmap → 数据库兜底,前两级命中就直接返回。

public boolean isBlacklisted(String scene, long userId, String traceId) { // L0:本地 Roaring 位图,纳秒级 Boolean local = localHolder.query(scene, userId); if (local != null) { return local; } // L1:Redis Bitmap,毫秒级,用于本节点数据缺失或新场景冷启动 String key = "bl:" + scene; Long offset = offsetCache.get(userId); if (offset != null) { Boolean bit = redisTemplate.opsForValue().getBit(key, offset); if (Boolean.TRUE.equals(bit)) { localHolder.applyDelta(scene, userId, true, System.currentTimeMillis()); return true; } return false; } // L2:数据库兜底,仅用于新用户等极端情况 return detailMapper.countActive(scene, userId) > 0; }

Redis 那层用GETBIT/SETBIT,注意这是网络往返,单次 0.2~0.5ms。它的定位不是主查询通道,而是"节点刚重启、位图还没加载完"时的临时兜底。为了避免重启风暴,节点启动时会异步拉一次全量快照,加载完成前置一个就绪标志,没就绪的节点不接流量。

顺便说下 Redis Bitmap 的几个硬约束,都是踩过的:

约束项具体数值说明
offset 上限2^32 - 1(约 42.9 亿)超出直接抛错,必须做映射
单个 key 最大512MB对应 42.9 亿位,和上面一致
稀疏分配按最大 offset 分配在 offset=10 亿处置位,立刻占用 125MB
单次 SETBITO(1),但可能触发扩容大 key 扩容有阻塞风险,建议预热

第三条最坑。曾经有个测试环境,同事图省事直接用原始 userId 当 offset,结果一个SETBIT直接把 Redis 节点内存打满,触发了淘汰。生产环境必须严格走映射表,没有任何商量余地。

3.4 一致性保障:对账与可观测性

本地位图最大的风险是"节点之间不一致"。A 节点收到了删除消息,B 节点因为消费失败没收到,两个节点对同一个用户的判定结果不同,用户刷新几次页面会看到不同结果,这种体验非常糟。

我们的做法是三管齐下:

第一,变更消息持久化 + 重试。所有名单变更先落一张 outbox 表,再由投递器发 MQ,消费失败自动重试,超过阈值进死信队列人工介入。这样至少保证消息不丢。

第二,定时对账。每 10 分钟跑一次轻量对账,对比数据库的活跃记录数、各节点位图的getCardinality()、Redis 位图的BITCOUNT,三者偏差超过 0.1% 就告警并触发该场景的全量重建。这个手段非常有效,我们上线后抓到过两次因为消费积压导致的一致性偏差。

第三,判定结果打标。每次黑名单命中都记录traceId + scene + source + 位图版本号,出问题时能精确回溯是哪个环节、哪个版本的数据导致误杀。这条日志一度帮我们定位到一个"过期名单没清理"的 bug。

4. 更新、过期与容量:位图方案绕不开的三个硬骨头

位图查询快是快,但它有两个天然缺陷:位不可回收、更新有代价。这一节专门处理这些"不优雅"的部分,也是决定方案能不能长期跑下去的关键。

4.1 过期名单怎么处理:版本化 + 双缓冲

位图能置 1,不能"删掉"那一块内存。你把所有位都清 0,位图占用的空间一分不少。这意味着如果一个场景的黑名单历史累计涨到 5000 万、当前只有 500 万有效,位图空间仍然按最大 offset 撑着。

更麻烦的是过期。黑名单有大量"封 7 天""封 30 天"的临时名单,到期必须生效。有几种做法:

做法一:定时扫描清理。每分钟扫一次expire_at < now()的记录,逐条置 0。简单直接,但清理任务和查询任务抢同一份数据结构,得加锁。而且如果过期量集中(比如一批活动结束,几十万条同时过期),清理会比查询更吃 CPU。

做法二:版本化位图。把名单按"过期窗口"分成多个位图,比如永久、7天、30天,查询时依次查多个位图做并集。缺点是查询次数翻几倍,优势是过期只需要整体丢弃一个位图,成本极低。

做法三:双缓冲 + 定期全量重建。维护一份"当前有效"的位图,每次全量重建(我们定的是每小时一次增量重建、每天一次全量重建)时按有效数据重新构建,构建完成后原子切换。查询永远读稳定的快照,不需要处理过期逻辑。

我们最终选的是做法三为主、做法一为辅。全量重建保证整体收敛,分钟级的清理任务处理少量突发过期。实测重建一次 500 万条数据耗时 40 秒左右,对线上几乎无感。

提示:重建一定要用双缓冲,构建新实例、切换引用、等旧实例的读请求自然结束。千万不要原地清空再重建,中间会有几秒钟的"全放行"窗口,这个窗口足够造成事故。

4.2 容量规划:把位图大小算到具体节点上

容量这件事,光算总量没用,得算到每个节点、每份副本上。我们实际的计算过程是这样的:

项目计算方式结果
用户总量现有 + 年增长 30%1.3 亿
单场景位图上限1.3 亿 / 816.25MB
场景数量通用名单 + 各业务线9 个
位图总占用16.25 × 9146MB
单节点堆内预算位图 + 映射缓存 + 业务对象约 900MB
节点堆配置预留 50% 余量4G

映射缓存是大头。1.3 亿条userId → offset的映射,如果用HashMap<Long, Long>存,每对约 48 字节(对象头 + 两个 Long + 节点指针),总占用 6GB 以上,根本放不下。这里必须用专门的结构:

// 方案一:LongObjectHashMap(避免装箱),约 1.3亿 * 24 字节 ≈ 3.1GB,仍偏大 // 方案二:分段 + 只在需要时加载热点用户映射,内存可控但多一次判断 // 方案三:用 LongLongHashMap(第三方原生 long 容器),约 1.3亿 * 16 字节 ≈ 2GB

最终我们用的是方案三配合分层加载:热数据(近 90 天活跃)全量加载进本地,约占 30%,冷数据查 Redis Hash。这样单节点映射占用控制在 600MB 以内,和位图加起来 800MB 左右,4G 堆完全够用。

这里得出一个反直觉的结论:在这个方案里,ID 映射表的开销往往比位图本身大十倍以上。做容量规划时如果只盯着位图算,上线必炸。我见过一个团队按"位图才 12MB,没问题"的结论直接上,结果节点第一天就 OOM,问题就出在映射缓存上。

4.3 更新延迟与业务感知:把"最终一致"讲清楚

本地位图是最终一致的,延迟取决于消息链路,我们实测 P99 在 800ms 左右。这个延迟能不能接受,取决于业务场景。

对于"发券、下单、参与活动"这类拦截,800ms 完全没问题——运营拉黑一个人,通常是因为刚才发现了异常行为,晚一秒拦截没有实质影响。但对于"用户申诉解封后立即恢复"这种场景,用户点了解封、立刻去领券、结果还是被拦,体验会很差。

我们的处理方式是解封走强一致读:申诉解封的接口,在返回成功之前同步等待本地节点更新完成(走一个本地事件),最多等 200ms,超时则降级为异步并给用户一个"1 分钟后生效"的提示。这个细节看起来小,但客诉量下降了非常明显。

还有一类特殊的:批量导入后的立即可用。运营导入 10 万条名单,期望立刻生效。这种我们走"同步推送到 Redis + 广播失效本地"的路径,广播用轻量的内部消息总线,几十个节点在 100ms 内全部刷新。如果名单超过 50 万条,就改成异步,前端提示"名单生效中,预计 1 分钟"。

5. 踩坑实录:典型故障与排查清单

这一节都是我实际遇到过的问题,按发生频率排序。如果你准备上这套方案,建议先对着这张清单自查一遍。

5.1 内存问题:从 OOM 到 GC 抖动

现象一:节点启动后堆内存持续上涨,最终 OOM。排查下来是两个原因叠加。一是全量快照加载时用了readObject直接反序列化整个位图,过程中产生了 3 倍于最终大小的临时对象;二是映射表全量加载没有分批,一次性构造了上亿条记录。改法是:位图快照分批加载,每 100 万条提交一次;映射表按活跃度分批,先加载热点再后台补全。

现象二:GC 时间从 20ms 涨到 400ms,接口 P99 毛刺明显。这个更隐蔽。原因是每次增量更新都调用了RoaringBitmap.clone()做快照,500 万条数据的位图克隆一次要 30~50ms,而且产生大量短命对象。秒杀期间变更消息密集,每秒克隆几十次,直接把 Young GC 打满。改法是改成读写锁 + 原地修改,读多写少的场景下StampedLock的读锁几乎无开销。

现象三:Redis 内存暴涨。老问题,还是有人直接用业务 ID 当 offset。加了一道强制校验:写入前检查 offset 是否小于2^32,超过直接拒绝并告警。

现象根因处置
启动 OOM全量反序列化 + 映射表一次性加载分批加载,热点优先
GC 毛刺频繁 clone 大位图改读写锁 + 原地更新
Redis 内存暴涨未做 ID 映射,偏移量过大强制校验 offset 上限
堆外内存泄漏Netty 缓冲未释放(位图快照传输)显式 release + 监控 direct buffer

5.2 判定错误:误杀与漏杀的排查路径

误杀排查。用户反馈"我没违规却被拦了",标准排查路径是:拿 userId 查映射表拿 offset → 查各节点位图contains(offset)→ 对比数据库blacklist_detail是否有有效记录。三种结果对应三种问题:

  • 数据库有记录,位图也有:说明名单本身有问题,去查source字段找数据来源方。
  • 数据库无记录,位图有:典型的"删除消息丢失",说明消息链路有问题,或者重建任务没跑。
  • 数据库有记录,位图没有:漏杀方向,说明增量同步延迟或失败。

我们后来把这条路径做成了内部工具,输入 userId 一键输出三个环节的状态对比,排查时间从半小时降到两分钟。

漏杀排查。相对少见,但更危险。典型原因是映射表缺失——新注册用户还没来得及写入映射,offset == null,代码直接 return false,等于放行。我们的处理是给这种情况打一个明确的日志标记OFFSET_MISS,并且在新用户注册的同步链路里强制写入映射,写失败则降级查数据库。

5.3 特殊场景:大促期间的位图重建抖动

大促当天最怕的就是后台任务。我们曾经在一次大促下午触发了位图全量重建,虽然只花了 50 秒,但那 50 秒里 CPU 使用率从 30% 冲到 85%,接口 P99 从 15ms 涨到 120ms,直接被监控告警。

后来的规矩是:大促期间禁止任何全量重建,只允许增量更新。增量如果积压,宁可延迟也不重建。同时把重建任务加了开关和熔断,检测到 QPS 超过阈值自动推迟。另外把重建的线程优先级调到最低,用Thread.MIN_PRIORITY,并把批处理大小从 10000 降到 2000,让出更多 CPU 时间片。

还有一个小技巧:重建前先做一次System.gc()提示,不是因为需要,而是让堆处于相对干净的状态,减少重建过程中的 Full GC 概率。这个做法有争议,但在我们的场景里实测有效。

6. 效果与延伸:位图还能用在哪些地方

方案上线后跑了三个月,压测和线上数据都稳定,这里把关键指标列一下,方便你判断这套方案值不值得抄。

指标改造前(MySQL)改造后(本地 Roaring)变化
单次名单判定延迟1.5~3ms0.1~0.2 微秒约 4 个数量级
名单查询 QPS 承载3000(单实例)80 万(单实例)约 260 倍
500 万名单内存占用依赖 DB 缓存约 10MB大幅下降
变更生效延迟主从延迟 1~3sP99 800ms略优
数据库连接数峰值 800+峰值 60下降 90%

真正让我意外的不是查询快了,而是数据库压力下来了。原来这套名单查询占了主库 40% 的 QPS,改造后这部分流量彻底消失,主库负载降到 15% 以下,连带着整个营销链路的稳定性都上了一个台阶。

6.1 位图的延伸用法:人群圈选与实时叠加

名单过滤只是位图最基础的用法。同一套结构稍加改造,能覆盖不少相关需求。

人群圈选。运营要圈"近 30 天有下单 + 未退过款 + 不属于高风险"的人群,本质是几个位图求交并差。原来这种需求要跑离线任务、出 CSV、再导入,一个需求两三天。用位图,三个集合andNot一下,几十毫秒出结果。我们后来做了一个简易的圈选面板,运营自己勾条件,实时看到预估人数。

活动互斥。"同一用户 7 天内只能参与一次同类活动",做法是按活动类型维护一个"已参与"位图,参与时置位,7 天后重建时自然清除。比查订单表快得多。

频控。严格说频控需要计数,位图只能表达"有没有",不能表达"几次"。但如果把粒度改一下——每天一个位图,7 个位图叠在一起查,就能实现"7 天内参与天数"的判定。这个技巧在限制"每日参与"的场景里非常好用,代价是查询次数变成 7 次,仍然是微秒级。

6.2 我在这个项目里最想分享的三条经验

第一条:先把 ID 映射做扎实,再谈位图。这个方案 80% 的坑都在映射层——映射不全会漏杀、映射表太大会 OOM、映射缓存不一致会导致节点间判定不同。位图本身的 API 反而简单得没什么好讲的。如果重来一次,我会在映射层的容量规划和一致性上投入更多前期设计时间。

第二条:别迷信压缩率,先算清楚总量。Roaring 的压缩很漂亮,但在这个场景里省下的那点内存(10MB vs 12.5MB),和映射缓存那 600MB 比起来完全不值一提。做技术选型时容易被"压缩率高"这种亮点带偏,实际上真正决定成败的是那些不起眼的辅助结构。

第三条:去掉不必要的复杂度。我们最初的方案里还有布隆过滤器前置、Redis 二级缓存、本地 LRU 三级淘汰,链路有四层。后来逐个砍,最后只剩"本地位图 + 数据库兜底"两层,性能反而更好,故障率也低得多。名单过滤这个场景的访问模式非常规律——读极多、写极少、数据量固定,这种场景下最简单的结构往往是最优解。中间层的每一次增加,都要问一句"它解决的具体问题是什么,能不能用更笨的办法解决"。

如果你的团队现在还在用数据库硬扛名单查询,我建议先做一件事:把最近一周的名单查询 QPS、平均延迟、数据库 CPU 占比拉出来,算一下名单查询占了多少。如果这个比例超过 20%,那基本就值得动手了;如果不到 5%,那就先别折腾,把精力放在别的地方。技术改造的价值永远取决于你在解决多大的问题,而不是用了多漂亮的结构。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 11:17:30

AI大模型在临床诊疗中的技术架构与实战应用

1. 项目概述&#xff1a;AI大模型在临床诊疗中的革命性应用 医疗行业正经历着由AI大模型驱动的数字化转型浪潮。作为从业十年的技术人&#xff0c;我亲眼见证了从早期规则系统到如今GPT-4级别大模型的技术跃迁。当前最前沿的临床决策支持系统&#xff08;CDSS&#xff09;已能实…

作者头像 李华
网站建设 2026/10/27 11:17:17

Windows进程管理与命令行工具实战指南

1. Windows进程管理基础Windows进程是操作系统中最基本的执行单元&#xff0c;每个运行中的程序都会创建一个或多个进程。与Linux系统不同&#xff0c;Windows的进程管理有着自己独特的机制和工具链。理解这些基础概念对于系统管理、性能优化和故障排查都至关重要。在任务管理器…

作者头像 李华
网站建设 2026/10/27 11:16:38

智能优化算法在光伏参数辨识中的应用与对比

1. 光伏参数辨识模型概述 光伏发电作为可再生能源的重要组成部分&#xff0c;其精确建模对系统性能评估和优化控制至关重要。光伏参数辨识的核心在于通过实验数据反推光伏电池/组件的关键电气参数&#xff0c;建立能够准确反映其输出特性的数学模型。 在实际工程中&#xff0c…

作者头像 李华
网站建设 2026/10/27 11:16:36

Golang多态实现与接口设计实践

1. 为什么Golang需要讨论多态&#xff1f; 在传统面向对象语言&#xff08;如Java、C&#xff09;中&#xff0c;多态通常与继承深度绑定。但Golang作为一门"非典型"面向对象语言&#xff0c;既没有class关键字&#xff0c;也不支持传统继承体系。这种设计让很多从Ja…

作者头像 李华
网站建设 2026/10/27 11:14:21

Ollama本地AI模型部署指南与优化实践

1. 为什么选择Ollama进行本地模型部署第一次接触Ollama是在去年底的一个AI技术分享会上&#xff0c;当时一位资深工程师演示了如何用这个工具在本地笔记本上跑通70亿参数的Llama2模型。作为长期被云端API调用成本和响应延迟困扰的开发者&#xff0c;我立刻被这种"开箱即用…

作者头像 李华
网站建设 2026/10/27 11:13:04

医院管理系统数据库设计实战:从需求分析到可执行建表与业务闭环验证

简介&#xff1a;这份文档面向计算机专业学生与数据库初学者&#xff0c;聚焦医院管理系统数据库设计这一典型课程设计场景&#xff0c;帮助读者理清从需求分析到关系模型落地的完整思路。内容围绕门诊与住院两大部门展开&#xff0c;梳理挂号、收费、药房、检查、病案等科室的…

作者头像 李华