关于大数据量ID进行去重的几种想法
场景描述
现在有一个接口,每天有上千万的数据量进来,要求你对这过千万的数据量进行去重,然后转发给下游,其中千万数据可能是分散到一天,也可能是在几个小时内全部发送给你,每天数据都带有一个唯一标识ID,这些数据中可能存在重复的ID(Long型),你要做的就是将这些数据转发给下游,转发过程中已经发过的不能再进行发送。这样的接口目前可能有5个,后面可能更多,每个接口都要做一次过滤。
不要问为啥下游不做幂等处理,上游不做去重处理,你就是中间,这些工作你要做。
版本: redis这里用的是6.0.20版本。
想一想:直接简单粗暴,把接收到的id存到redis中,转发的时候判断下是否在redis中是否存在不就可以了!
正常情况下,很多去重逻辑我们都是这样做的,可这个需求你再想想,过千万的数据,也就是过千万的Long类型ID,你确定直接存到redis中么?
简单算一下:按照5000w的ID进行存储,简单测试一下,在redis中存储一个Long型数字占用多少内存
usage
set tt 1234567891011
OK
memory usage tt
48
也就是说一个Long型占用48个字节,5000W * 48 = 2,400,000,000字节 = 2.23G,是的你没看错,要存储5000w的Long型的ID,需要2.23G,也就是说你一个接口去重要占用的内存是2.23G,想一想,5个接口,10个接口…你的redis应该是要被撑爆了。当然你可能说只是过千万,也没说到5000w啊,如果是1000w,1个接口也就是500M,这点内存还是可以的,我只想说 To yong to simple!后面万一数据量上去,直接把redis打爆,你说到时候锅是谁的!!!
思路:怎么用更少的内存存储更多的ID,而且更快的进行去重判断?
不考虑分布式环境,直接存单机内存中是否可行
粗略估算下1000wLong型要占用多少内存
1000w个long型,每个long型 对象占用 16 个字节(包括对象头和 long 值),那么这些 Long 对象总共占用的内存空间为:
1千万 * 16 字节 = 160,000,000 字节 = 152.587890625 MB,好像也不是太多,但是单纯放内存中,项目肯定要迭代重启啊,然后数据没了,这怎么行!想想把去重数据放第三方,放redis中?
wait~~
单机重启问题,这个其实好解决啊, 我直接存文件,项目启动读文件,停止服务写文件,test一下!!!
1000wLong类型数据生成txt文件,大小为133 MB。
public static void main(String[] args) throws Exception{
String fileName = "ttest.txt";
List<Long> list = new ArrayList<>();
for(long i = 1231619274;i<1241619274;i++){
list.add(i);
}
FileOutputStream fos = new FileOutputStream(fileName);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(list);
oos.close();
cn.hutool.core.io.FileUtil.appendLines(list,"ttest.txt","UTF-8");
}
redis - bitMap
其实很容易联想到redis中的bitMap类型,bigMap底层使用二进制进行存储,所以更节省空间!
测试一下bitMap存储一亿要占用多少内存
setbit ttt 100000000 1
0
memory usage ttt
14680112
14680112字节= 14M
卧槽!好像可以啊,一个亿存储也就才14M,这下不是随便存?
然后你试了试ID进行存储
setbit ttt 77428663093811 1
ERR bit offset is not an integer or out of range
恭喜你!你踩坑了!
看下这个错误, is not an integer or out of range,是的,bitMap 的offset最大值是Integer的最大值,具体bitMap的存储和实现原理这里不再讲解。
那怎么办呢?Long型不能直接塞到bitMap中,那能不能做一下转换,将Long型转为Integer类型?
想一想: 如果Long型转为Integer类型,使用什么映射算法?那么多的Long型你的映射算法会不会产生碰撞?碰撞概率是多少?
redis -bloomFilter
好吧,既然bitMap需要一个映射算法,这个映射算法,好像也不是那么好找,而且想一想,Long型转int类型,超过Integer最大值的那些数字,碰撞概率好像也不小!
既然这样,试试布隆过滤器吧!这个东西保证元素肯定不存在的!
springboot项目pom文件
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.1</version>
</dependency>
redis准备工作:redis要提前安装布隆过滤器插件,可以先用BF命令测试一下redis是否已经安装!
我就没有测试,然后各种测,一直报错,提示脚本错误,搞了快2个小时,想起来测试一下redis是否支持布隆过滤器,一测试,发现BF命令不支持!!!
BF.EXISTS myfilter element1
0
Test测试
bloomFilterExists
@Test
public void testBloo(){
String key = "test2";
RBloomFilter<Object> tet = redisUtil.tryInitBloomFilter(key);
List<Long> list= new ArrayList<>();
for (long i = 14294967295L; i < 14294977295L; i++) {
list.add(i);
}
System.out.println("-------------------");
long l = System.currentTimeMillis();
//不存在的返回
List<Long> longs = redisUtil.bloomFilterExists(key, list);
long l2 = System.currentTimeMillis();
System.out.println(longs.size());
System.out.println("耗时::"+(l2-l));
List<Long> list2= new ArrayList<>();
for (long i = 14294976295L; i < 14294978295L; i++) {
list2.add(i);
}
long l3 = System.currentTimeMillis();
List<Long> longs2 = redisUtil.bloomFilterExists(key, list2);
System.out.println(longs2.size());
long l4 = System.currentTimeMillis();
System.out.println("耗时::"+(l4-l3));
}
整体逻辑就是先初始化布隆过滤器,然后模拟插入1000个Long型,然后交叉2000个数字,判断这2000个数字已经存在多少个了。
//初始化2000w容量,误判率0.0001
private final static long expectedInsertions = 2_000_0000;
private final static double falseProbability = 0.0001d;
public RBloomFilter<Object> tryInitBloomFilter(String key) {
RBloomFilter<Object> bloomFilter = redissonClient.getBloomFilter(key);
if (bloomFilter.isExists()) {
return bloomFilter;
}
bloomFilter.tryInit(expectedInsertions, falseProbability);
//定时任务删除
return bloomFilter;
}
public List<Long> bloomFilterExists(String key, List<Long> itemIds) {
RScript scriptObj = redissonClient.getScript();
RFuture<List<Long>> future = scriptObj.evalAsync(
RScript.Mode.READ_WRITE,
bloom_lua_exists,
RScript.ReturnType.MAPVALUELIST,
Collections.singletonList(key),
itemIds.toArray()
);
try {
return future.get();
} catch (Exception e) {
e.printStackTrace();
}
return new ArrayList<>();
}
private static String bloom_lua_exists =
"local newElements = {}\n"
+ "for i, element in ipairs(ARGV) do\n"
+ " if redis.call('BF.EXISTS', KEYS[1], element) == 0 then\n"
+ " table.insert(newElements, element)\n"
+ " redis.call('BF.ADD', KEYS[1], element)\n"
+ " end\n"
+ "end\n"
+ "return newElements";
lua脚本的意思很简单,设置到布隆过滤器中,如果该值不存在,进行返回。
测试运行
9851
耗时::563
983
耗时::60
可以看到,我们初始化了2000w的布隆过滤器容量,允许0.0001的误判率,这样在我们第一次塞入1w个ID(14294977295 - 14294967295 = 10000)的时候,bloomFilterExists返回了9851个,也就是说我们第一次塞入有149个数字被误判已经存在布隆过滤器中了,交叉判断如果全部正常的话,我们应该是有1000个元素不在布隆过滤器中(14294978295 - 14294977295 =1000),但实际只判别出983个元素不在。
好吧,布隆过滤器本身就存在一定误判性,所以初始化的时候才让我们设置误判率,误判先不管,看看实际在redis中占用了多少内存!
先看下在redis中布隆过期长什么样子!
expectedInsertions:预期要插入的元素数量
falseProbability: 误判率
hashIterations:表示哈希函数的迭代次数。布隆过滤器使用多个哈希函数来将一个元素映射到位数组中的多个位置,以实现元素的插入和查询操作。通过增加哈希函数的迭代次数,可以增加布隆过滤器的准确性和稳定性,减少误判率。通常情况下,哈希函数的迭代次数越多,误判率会越低,但同时也会增加计算成本。
**size:**指的是布隆过滤器的大小,即内部位数组的长度。这个size参数决定了布隆过滤器可以存储的元素数量以及误判率等性能指标。在初始化布隆过滤器时,可以通过设定size参数来指定布隆过滤器的大小,通常会根据预期插入的元素数量、误判率等因素来确定size的值。更大的size通常意味着能够存储更多的元素,但也可能会增加内存消耗和查询时间。
而我们插入了1w个元素,实际占用空间:171KB
memory usage test2
175168
也就是1w个元素大概0.16M,1000w也就是160M,这里只是大概,我们实际测试一下1000w的数据占用多少内存!
在这里插入代码片
@Test
public void testBool(){
String key = "test2";
RBloomFilter<Object> tet = redisUtil.tryInitBloomFilter(key);
List<Long> list= new ArrayList<>();
long start= 14284976295L;
long l = System.currentTimeMillis();
for (long i = 14284976295L; i < 14294978295L; i++) {
list.add(i);
if(i - start >= 10000){
List<Long> longs = redisUtil.bloomFilterExists(key, list);
System.out.println("10000个不存在:"+longs.size());
list.clear();
start = i;
}
}
long l2 = System.currentTimeMillis();
//不存在的返回
System.out.println("耗时::"+(l2-l));
List<Long> list2= new ArrayList<>();
for (long i = 14294978296L; i < 14294988295L; i++) {
list2.add(i);
}
long l3 = System.currentTimeMillis();
List<Long> longs2 = redisUtil.bloomFilterExists(key, list2);
System.out.println(longs2.size());
long l4 = System.currentTimeMillis();
System.out.println("耗时::"+(l4-l3));
}
…
10000个不存在:9842
10000个不存在:9834
10000个不存在:9844
10000个不存在:9847
耗时::569753
9859
耗时::737
直接看redis占用
memory usage test2
178957696
170M! 好像可以接收哈,然后每个接口每天生成一个布隆过滤器,删除前一天的,一个接口才100多M,嗯,可以接收的,毕竟比刚开始的2个多G少了很多很多了不是??!!!
你就说节省内存没有吧!
内存省了,但是误判了,其实就这次的需求来说,误判点也可以接受,但是。。。。。。。。。。。
想一想:
还有没有别的方法提升一下去重的准确性?又占用较少的内存?
redis-bitMap分割版
bitMap好像特别省内存,问题是怎么将Long型映射成Integer类型,各种Hash?怎么减少碰撞呢?
对Long型数字进行拆分
怎么拆分呢?先测试一下bitmap 100w的容量占用多少内存
setbit tt 1000000 1
0
memory usage tt
131120
0.12M!
那对Long型转成字符串,进行每6位分割,多存几个bitMap是不是可以?!!!
也就是说假如一个ID = 774286_630938,也就是需要2个bitMap进行存储,第一个bitMap存774286,第二个bitMap存630938,这样判断是否存在的时候用2次setBit,如果两次都返回setBit成功,说明这个ID之前不存在,如果两次都成功,说明之前这个ID存在,这样是不是可以?!!!!!一个ID有多少位,就动态生成多少个BitMap!!!
好像这样的思路可以?
wait,wait,wait。。。再想想,这样映射是有问题的!
LOOK!200000开头的2,3,4,5,6,7,8,9全部被误判了。。。。。。这怎么行?
难道每个前6位对应一个bitMap,那100w个前缀key不是要对应100w个key?key太多了,虽然可能最后用不上100w个key,但是架不住万一啊,造成key的数量坍塌,还玩不玩了???!!!
还有其他办法么?其实核心逻辑就是把接收过的ID存起来,然后判断是否已经接收过,主要是Long型,数据量还多,好像内存直接存储也不是不行?
再回头看下内存去重逻辑,其实问题就在于数据持久化,那解决持久化问题是不是就可以了?单机情况是可以的,那分布式呢?每台机器停机的时候持久化,开机读取?那多台机器轮番重启,怎么持久化,持久化肯定是要顺序读写(速度快点),关键是如果纯粹存储long类型也要150M左右,有没有更少的占用内存方法?
RoaringBitmap
上面已经说了,bitmap存储成本极小,但是如果是稀疏数据,总是会按照最大的offset去申请内存,就造成了如果只存100—0000这一个数字,也要申请100_0000字节的内存,这就造成了极大的内存浪费,这里就要说到RoaringBitmap(RBM)了,它可以对bitmap进行压缩,具体底层怎么实现这里就不说了, 大概就是底层高低16位两部分,其中高16位会被作为key存储到short[] keys中(short类型为2个字节即16位),低16位作为value,存储到Container[] values中。而且RoaringBitmap 是支持直接存储Long型的!不需要自己去做转换!
直接 上test!!!
pom文件
<dependency>
<groupId>org.roaringbitmap</groupId>
<artifactId>RoaringBitmap</artifactId>
<version>1.0.5</version> <!-- Check for the latest version on Maven Central -->
</dependency>
test
@Test
public void testRomap(){
try {
Roaring64NavigableMap bitmap = new Roaring64NavigableMap();
for(long i = 1231532847767L;i<1231542847767L;i++){
bitmap.add(i);
}
long ll1 = System.currentTimeMillis();
// 序列化到文件
FileOutputStream fos = new FileOutputStream("roaring_map.dat");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(bitmap);
oos.close();
long ll2 = System.currentTimeMillis();
System.out.println("写入耗时::"+ (ll2-ll1) +",占用字节:"+bitmap.getLongSizeInBytes()+",,cardinality=" + bitmap.getLongCardinality());
long l = System.currentTimeMillis();
// 重启后或需要恢复时,从文件中反序列化
FileInputStream fis = new FileInputStream("roaring_map.dat");
ObjectInputStream ois = new ObjectInputStream(fis);
Roaring64NavigableMap restoredBitmap = (Roaring64NavigableMap) ois.readObject();
ois.close();
long l2 = System.currentTimeMillis();
System.out.println("读取耗时:"+(l2 - l)+ ",占用字节:"+bitmap.getLongSizeInBytes()+",,cardinality=" + bitmap.getLongCardinality());
List<Long> wlist= new ArrayList<>();
for(long i = 1231533847667L;i<1231533847867L;i++){
wlist.add(i);
}
long l1 = System.currentTimeMillis();
List<Long> collect = wlist.stream()
.filter(w -> !restoredBitmap.contains(w))
.collect(Collectors.toList());
long l11 = System.currentTimeMillis();
System.out.println("去重判断"+wlist.size()+"个,耗时:" + (l11-l1));
} catch (Exception e) {
e.printStackTrace();
}
}
写入耗时::26,占用字节:1260634,cardinality=10000000
读取耗时:9,占用字节:1260646,cardinality=10000000
去重判断200个,耗时:0
速度飞起!!!
可以看到1000w的数字占用1260634Bytes = 1.2M!!!误判率0 !!!
当然项目启动读取,停机写入的逻辑也要加上!
停机逻辑
@Component
public class ShutdownListener implements ApplicationListener<ContextClosedEvent> {
@Override
public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
logger.info("开始停机处理。。。。。。");
for(long i = 1231619274;i<1241619274;i++){
GlobalCache.id_map.add(i);
}
for(int i =1;i<5;i++){
FileUtil.writeFile(i);
}
}
}
全局roaringbitmap
import org.roaringbitmap.longlong.Roaring64NavigableMap;
public final class GlobalCache {
public static Roaring64NavigableMap id_map = new Roaring64NavigableMap();
}
文件工具类
public final class FileUtil {
public static void writeFile(Integer type) {
try {
String fileName = initFileName(type);
if (StringUtils.isBlank(fileName)) {
return;
}
Roaring64NavigableMap bitMap = null;
switch (type) {
case 1:
bitMap = GlobalCache.id_map;
break;
default:
break;
}
if (bitMap == null) {
return;
}
FileOutputStream fos = new FileOutputStream(fileName);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(bitMap);
oos.close();
logger.info("持久化文件结束:{}",fileName);
} catch (Exception e) {
e.printStackTrace();
}
}
private static String initFileName(Integer type) {
String suffix = LocalDate.now().format(DateFormatCons.YYYYMMDD_FORMATTER2);
switch (type) {
case 1:
return "id_map";
default:
return null;
}
}
public static void readFile(Integer type) {
try {
String fileName = initFileName(type);
if (StringUtils.isBlank(fileName)) {
logger.error("文件读取错误 ,无对应文件,type={}",type);
return;
}
FileInputStream fis = new FileInputStream(fileName);
ObjectInputStream ois = new ObjectInputStream(fis);
switch (type) {
case 1:
GlobalCache.id_map = (Roaring64NavigableMap) ois.readObject();
break;
default:
break;
}
ois.close();
logger.info("读取文件结束:{}",fileName);
} catch (Exception e) {
e.printStackTrace();
}
}
}
初始化操作
//全局Roaring64NavigableMap
import com.test.global.GlobalCache;
//自定义文件读取
import com.test.FileUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
/**
* 初始化操作
*/
@Component
public class AppStartOverRun implements CommandLineRunner {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private ApplicationContext context;
@Override
public void run(String... args) throws Exception {
readFiles();
logger.info("task start over something over");
}
private void readFiles(){
FileUtil.readFile(i);
logger.info("id_map:::sizeInBytes={},cardinality={}",
GlobalCache.item_detail_map.getLongSizeInBytes(),
GlobalCache.item_detail_map.getLongCardinality()
);
}
}
注意
线上项目重启发布请用kill -15或者自己项目封一个停机接口!!!不能用kill -9 !!!
后续
因为我这边暂时是一个单机项目,所以最后还是采用了roaringBitMap的做法,当然也想到后续项目扩展,比如说增加部署机器,这样就相当于是做成一个分布式了, 那roaringBitMap数据怎么共享?无非是将这块数据放到一个公共区域进行共享,不过又涉及到多台重启时,谁的数据最准?而且一般多台机器,一般都是重启一台,发布一台,然后再去重启另一台,那这样就造成一个数据同时读取和持久化的先后问题。
数据共享
可以将roaringBitMap数据存入redis,roaringBitMap也提供了序列化方法:
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
restoredBitmap.serialize(dos);
byte[] bytes = bos.toByteArray();
redisTemplate.opsForValue().set("tt",bytes);
memory usage tt
1835056 = 1.75M
数据先后问题
后面再说吧,暂时想到的是给停机就一直写入文件,因为可以一直覆盖,这样其实持久化可以保证,读取的话,等最后项目全部重启成功,不行就再封一个接口,提供刷新全局roaringBitMap。