1. 单体布隆过滤器在分布式环境会出现的问题?
如果这个模块会部署多个 IW9 实例(或者多节点应用),并且每个实例都需要使用布隆过滤器来进行手机号查重,那么目前的设计方案需要做一些额外的考虑和优化,以确保布隆过滤器在分布式环境下的一致性和有效性。直接在多个实例中使用布隆过滤器时,可能会遇到以下几个问题:
问题分析:
- 布隆过滤器的本地性:
- 布隆过滤器是一个本地数据结构,它通常在每个应用实例中独立运行。每个实例拥有自己独立的布隆过滤器,这意味着不同实例之间的布隆过滤器数据是不一致的。也就是说,一个实例的布隆过滤器无法“感知”其他实例中插入的手机号。
- 跨实例数据共享:
- 如果手机号在多个实例中被并发查询,布隆过滤器可能无法正确判断是否已存在某个手机号,因为它没有跨实例的共享机制。
- 布隆过滤器的同步问题:
- 假设多个实例同时运行,并且你在一个实例中插入了某个手机号到布隆过滤器中,另一个实例可能并没有这个更新,从而导致查询时产生误判。
解决方案:
为了在多实例部署环境下使用布隆过滤器,可以考虑以下几种策略:
1. 分布式布隆过滤器(推荐方案)
一种常见的解决方案是使用 分布式布隆过滤器。分布式布隆过滤器能够在多个节点之间共享数据,确保每个节点都可以获得其他节点插入的信息。具体做法包括:
- 使用分布式存储:例如使用 Redis 或 ZooKeeper 来存储布隆过滤器的数据。这样,所有实例都可以访问共享的布隆过滤器。
- 同步布隆过滤器的更新:每当某个实例插入一个手机号到布隆过滤器时,需要将该手机号的哈希值同步到 Redis 中,其他实例也能通过 Redis 访问到最新的数据。
- 集群模式的布隆过滤器:比如使用 Apache Ignite 或 Hazelcast 等分布式缓存系统,它们提供了分布式数据结构(包括布隆过滤器),可以在多实例中共享布隆过滤器的数据。
具体实现:
- 使用 Redis 将布隆过滤器的数据作为一个 位数组 存储。每个实例都能通过访问 Redis 来进行布隆过滤器的查询和插入操作。
- 使用 Redis 发布/订阅机制(Pub/Sub)来广播布隆过滤器的更新操作。例如,当一个实例插入手机号时,发布一条更新消息,其他实例通过订阅来接收到这个更新操作。
2. 共享 Redis 缓存 + 本地布隆过滤器
如果不想完全依赖分布式布隆过滤器,可以选择将 布隆过滤器的功能与 Redis 缓存结合使用:
- Redis缓存存储布隆过滤器:每个实例将布隆过滤器存储在 Redis 中,而不是本地内存中。这样所有实例都可以访问相同的布隆过滤器。
- Redis缓存同步:每次插入数据时,实例会将布隆过滤器的更新操作写入 Redis,其他实例能够同步更新。
- 缓存更新频率:定期将布隆过滤器的数据从本地同步到 Redis 中,或者每次插入手机号时立即将布隆过滤器的哈希值同步到 Redis。
这种方式的优点是布隆过滤器的数据不再是本地的,而是集中在 Redis 中,保证了多实例之间的数据一致性。缺点是需要处理布隆过滤器数据的更新频率和缓存同步的开销。
3. 定期同步布隆过滤器
如果布隆过滤器的数据量较小,且对实时性要求不是特别高,可以考虑定期同步布隆过滤器的数据:
- 每个实例仍然维护本地布隆过滤器,但定期将其同步到共享的 Redis 缓存 或 数据库 中。
- 每次有新的手机号插入时,将插入记录同步到 Redis 中,并在一定时间间隔后(例如每小时或每天)同步布隆过滤器的数据。
这种方案适用于对布隆过滤器数据一致性要求不高的场景,但对于高并发、高实时性要求的应用可能不太适合。
4. 使用外部分布式数据存储
如果布隆过滤器数据的量非常大,Redis 可能面临内存压力,可以使用 外部分布式存储 来持久化布隆过滤器的位数组。例如,可以使用 Apache Kafka、Hadoop 或 Elasticsearch 来存储布隆过滤器数据,这些工具都能够处理分布式环境下的数据同步。
总结:
如果你的应用部署了多个实例,直接在每个实例中使用本地布隆过滤器会导致数据不一致的问题。为了解决这一问题,你可以选择以下策略:
- 分布式布隆过滤器:将布隆过滤器的数据存储在共享的分布式存储中(如 Redis、Apache Ignite、Hazelcast),确保多个实例能够访问到一致的数据。
- 共享 Redis 缓存与本地布隆过滤器:将布隆过滤器的操作和更新同步到 Redis 中,使得多个实例可以共享相同的布隆过滤器数据。
- 定期同步布隆过滤器:定期将本地的布隆过滤器数据同步到 Redis 或其他共享存储中,适用于一致性要求不高的场景。
这些方法可以帮助你确保在多实例部署的情况下,布隆过滤器仍然能够有效地进行手机号查重,并避免数据不一致的问题。
2. Redis实现布隆过滤
我建议将布隆过滤器从本地内存实现改为基于Redis的分布式实现。主要需要完成以下改造:将布隆过滤器的位数组存储在Redis中,使用Redis的位图(bitmap)数据结构来实现,同时保持原有的哈希函数逻辑。这样可以让多个服务实例共享同一个布隆过滤器,解决分布式环境下的数据一致性问题。
将布隆过滤器配置改为基于Redis实现,使用Redis的bitmap数据结构存储位数组
package cn.com.jetour.scrm.clue.core.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
@Configuration
public class BloomFilterConfig {
// 预期插入的元素数量
private static final int EXPECTED_INSERTIONS = 1000000;
// 误判率
private static final double FALSE_POSITIVE_PROBABILITY = 0.001;
// Redis中布隆过滤器的键名
private static final String BLOOM_FILTER_KEY = "clue:phone:bloom";
// 哈希函数的数量
private static final int NUM_HASH_FUNCTIONS = 7;
/**
* 配置并创建Redis布隆过滤器
* @param connectionFactory Redis连接工厂
* @return 配置好的Redis布隆过滤器
*/
@Bean
public RedisBloomFilter cluePhoneBloomFilter(RedisConnectionFactory connectionFactory) {
// 创建并配置RedisTemplate
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.afterPropertiesSet(); // 初始化RedisTemplate
// 创建并返回Redis布隆过滤器
return new RedisBloomFilter(
redisTemplate, // 使用配置好的RedisTemplate
BLOOM_FILTER_KEY, // 布隆过滤器的键名
EXPECTED_INSERTIONS, // 预期插入的元素数量
FALSE_POSITIVE_PROBABILITY, // 误判率
NUM_HASH_FUNCTIONS // 哈希函数数量
);
}
}
创建RedisBloomFilter类来实现基于Redis的布隆过滤器,使用Redis的bitmap数据结构存储位数组
package cn.com.jetour.scrm.clue.core.config;
import org.springframework.data.redis.core.RedisTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 基于Redis的布隆过滤器实现
*/
public class RedisBloomFilter {
private final RedisTemplate<String, Object> redisTemplate;
private final String key;
private final int numHashFunctions;
private final int bitSize;
private final MessageDigest digestFunction;
/**
* 构造方法,初始化布隆过滤器
* @param redisTemplate Redis模板,用于操作Redis
* @param key 布隆过滤器的键名
* @param expectedInsertions 预期插入的元素数量
* @param falsePositiveProbability 误判率
* @param numHashFunctions 哈希函数数量
*/
public RedisBloomFilter(RedisTemplate<String, Object> redisTemplate,
String key,
int expectedInsertions,
double falsePositiveProbability,
int numHashFunctions) {
this.redisTemplate = redisTemplate;
this.key = key;
this.numHashFunctions = numHashFunctions;
// 计算布隆过滤器需要的位数组大小
this.bitSize = optimalNumOfBits(expectedInsertions, falsePositiveProbability);
try {
// 使用MD5算法作为哈希函数
this.digestFunction = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
/**
* 将元素添加到布隆过滤器中
* @param element 要添加的元素
*/
public void put(String element) {
byte[] bytes = element.getBytes(StandardCharsets.UTF_8);
int[] positions = getHashPositions(bytes);
// 设置Redis位图,表示该元素已经存在
for (int position : positions) {
redisTemplate.opsForValue().setBit(key, position, true);
}
}
/**
* 判断元素是否可能存在于布隆过滤器中
* @param element 要检查的元素
* @return 如果可能存在,则返回true;如果一定不存在,则返回false
*/
public boolean mightContain(String element) {
byte[] bytes = element.getBytes(StandardCharsets.UTF_8);
int[] positions = getHashPositions(bytes);
// 检查Redis中的位图,如果任意一位为0,说明该元素一定不存在
for (int position : positions) {
Boolean result = redisTemplate.opsForValue().getBit(key, position);
if (Boolean.FALSE.equals(result)) {
return false;
}
}
return true;
}
/**
* 计算最优的位数组大小
* @param n 预期插入的元素数量
* @param p 误判率
* @return 位数组的大小
*/
private int optimalNumOfBits(long n, double p) {
return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
/**
* 使用双重哈希算法计算一个元素的多个哈希值
* @param bytes 元素的字节数组
* @return 哈希值在位数组中的位置
*/
private int[] getHashPositions(byte[] bytes) {
int[] positions = new int[numHashFunctions];
long hash1 = murmurHash(bytes);
long hash2 = fnvHash(bytes);
// 通过双重哈希计算每个哈希值的位置
for (int i = 0; i < numHashFunctions; i++) {
positions[i] = Math.abs((int) ((hash1 + i * hash2) % bitSize));
}
return positions;
}
/**
* MurmurHash算法实现
* @param bytes 元素的字节数组
* @return 哈希值
*/
private long murmurHash(byte[] bytes) {
long h1 = 0x9747b28cL;
final long c1 = 0x87c37b91114253d5L;
final long c2 = 0x4cf5ad432745937fL;
for (int i = 0; i < bytes.length - 8; i += 8) {
long k1 = getLongFromBytes(bytes, i);
k1 *= c1;
k1 = Long.rotateLeft(k1, 31);
k1 *= c2;
h1 ^= k1;
h1 = Long.rotateLeft(h1, 27);
h1 = h1 * 5 + 0x52dce729;
}
return h1;
}
/**
* FNV Hash算法实现
* @param bytes 元素的字节数组
* @return 哈希值
*/
private long fnvHash(byte[] bytes) {
long hash = 0xcbf29ce484222325L;
for (byte b : bytes) {
hash *= 0x100000001b3L;
hash ^= b;
}
return hash;
}
/**
* 将字节数组转换为long值
* @param bytes 字节数组
* @param offset 偏移量
* @return 转换后的long值
*/
private long getLongFromBytes(byte[] bytes, int offset) {
return ((long) bytes[offset] & 0xff) |
(((long) bytes[offset + 1] & 0xff) << 8) |
(((long) bytes[offset + 2] & 0xff) << 16) |
(((long) bytes[offset + 3] & 0xff) << 24) |
(((long) bytes[offset + 4] & 0xff) << 32) |
(((long) bytes[offset + 5] & 0xff) << 40) |
(((long) bytes[offset + 6] & 0xff) << 48) |
(((long) bytes[offset + 7] & 0xff) << 56);
}
}
3. 综合案例
我来设计一个在线商品秒杀系统的综合案例,使用Redis布隆过滤器来优化性能。这个案例很适合展示分布式布隆过滤器的实际应用价值。
package com.example.seckill;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import java.util.concurrent.TimeUnit;
/**
* 商品秒杀服务
*/
@RestController
@RequestMapping("/api/seckill")
@RequiredArgsConstructor
public class SeckillController {
private final RedisTemplate<String, Object> redisTemplate;
private final RedisBloomFilter userBloomFilter;
private static final String STOCK_KEY_PREFIX = "seckill:stock:";
private static final String ORDER_KEY_PREFIX = "seckill:order:";
private static final String USER_PURCHASE_KEY_PREFIX = "seckill:user:purchase:";
/**
* 初始化秒杀商品
*/
@PostMapping("/init/{productId}")
public void initSeckill(@PathVariable String productId, @RequestParam int stock) {
String stockKey = STOCK_KEY_PREFIX + productId;
redisTemplate.opsForValue().set(stockKey, stock);
}
/**
* 用户预约秒杀
*/
@PostMapping("/reserve/{productId}")
public String reserveSeckill(@PathVariable String productId, @RequestParam String userId) {
// 将用户ID添加到布隆过滤器,标记为预约用户
userBloomFilter.put(userId + ":" + productId);
return "预约成功";
}
/**
* 执行秒杀
*/
@PostMapping("/{productId}")
public String doSeckill(@PathVariable String productId, @RequestParam String userId) {
// 1. 检查用户是否预约
if (!userBloomFilter.mightContain(userId + ":" + productId)) {
return "请先预约";
}
// 2. 检查用户是否重复购买
String purchaseKey = USER_PURCHASE_KEY_PREFIX + userId + ":" + productId;
Boolean hasOrdered = redisTemplate.hasKey(purchaseKey);
if (Boolean.TRUE.equals(hasOrdered)) {
return "您已经购买过了";
}
// 3. 检查并扣减库存
String stockKey = STOCK_KEY_PREFIX + productId;
Long stock = redisTemplate.opsForValue().decrement(stockKey);
if (stock == null || stock < 0) {
// 恢复库存
redisTemplate.opsForValue().increment(stockKey);
return "商品已售罄";
}
// 4. 创建订单
String orderKey = ORDER_KEY_PREFIX + userId + ":" + productId;
redisTemplate.opsForValue().set(orderKey, System.currentTimeMillis());
// 5. 标记用户已购买
redisTemplate.opsForValue().set(purchaseKey, "1", 24, TimeUnit.HOURS);
return "抢购成功";
}
}
/**
* 秒杀服务配置
*/
@Configuration
public class SeckillConfig {
@Bean
public RedisBloomFilter userBloomFilter(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.afterPropertiesSet();
return new RedisBloomFilter(
redisTemplate,
"seckill:user:bloom", // Redis中的key
1000000, // 预期用户数量
0.001, // 误判率
7 // 哈希函数数量
);
}
}
主要特点包括:
-
预约机制:
- 用户必须先预约才能参与秒杀
- 使用布隆过滤器记录预约用户,快速判断用户是否有资格参与秒杀
- 减少无效请求对系统的压力
-
防重复购买:
- 使用Redis记录用户购买记录
- 避免同一用户重复下单
- 购买记录24小时后自动失效
-
库存管理:
- 使用Redis原子操作控制库存
- 预防超卖问题
- 库存不足时快速返回
-
分布式特性:
- 所有服务实例共享同一个布隆过滤器
- 保证分布式环境下的数据一致性
- 可以横向扩展应对高并发
使用方式:
- 首先初始化商品库存:
POST /api/seckill/init/{productId}?stock=100
- 用户预约秒杀:
POST /api/seckill/reserve/{productId}?userId=xxx
- 执行秒杀:
POST /api/seckill/{productId}?userId=xxx