Java实现Redis Bitmap的零存整取

2,980 阅读4分钟

项目组的一个同学今天突然找到我求助,让我帮忙看一个Redis的问题。

原来他利用RedisBitmap来实现布隆过滤器,记录用户已读的内容id数据,做已读去重判断,这样比Set去存储内存开销小很多。他对Bitmap主要有两个操作:

写操作

用户读过一篇内容以后,使用的是setBit方法在指定offset处标记一下。

redisTemplate.opsForValue().setBit(key, i, true);

读操作

当客户端请求下一页数据的时候,需要对召回的内容进行已读去重,常规做法是循环调用getBit方法,代码如下:

  for (int i : offset) {
    if (!redisTemplate.opsForValue().getBit(key, i)) {
        return false;
    }
 }

但是这位同学出于性能考虑,为了避免大量频繁循环请求Redis,没有直接使用getBit方法,而是以字符串的形式读取出来,转换成byte数组,然后每一个bit转换byte来存储。

        byte[] bitmapByte = new byte[0];
        String value = Optional.ofNullable(redisTemplate.opsForValue().get(key)).orElse("");
        if (StringUtils.isBlank(value)) {
            return Collections.emptyList();
        }
        try {
            bitmapByte = value.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            log.error("获取byte数组失败");
        }
        List<Byte> bitMap = new ArrayList<>(bitmapByte.length * 8);
        for (byte b : bitmapByte) {
            bitMap.addAll(getByteArray(b));
        }

他的整个方案核心就是Bitmap的零存整取,但是实际效果并没有如他所愿,转换出来的byte数组除了前八位是正确的,后续字节全部是错的。

问题出在哪里?

首先我直接在命令行下使用getBit命令查询,1,2,5,8位的数据是正常的,零存是OK的,命令行整取结果是d\x80,则是unicode编码,也正常。

又回去查代码,乍一看也是没有问题的,一下子不知所措。

回想一下Bitmap的实现。

在Redis内部Bitmap是使用字符串来存储的。一个Bitmap对象就是一个类型为REDIS_STRINGRedisObjectRedisObjectptr指针指向一个SDS。SDS一个C语言实现的增强版的字符数组对象,内容存储在buf数组中。

那么1,2,5,8位设置为1,在Redis中实际存储的数据就是这样的

  • buf[0] = 0b01100100
  • buf[1] = 0b10000000

注意buf中存储的顺序是从高位到低位,与我们常见的低位到高位是相反的。

我注意到buf[0]存储的是一个ASCII字符,转换正常,而buf[1]等于-128,不是ASCII字符,转换失败。

所以我怀疑问题可能出现在编码上。

结果测试发现,-128转换成UTF-8字符,会变成三个字节,-17,-65和-67,-17的补码是11101111,刚好跟第二组8位完全一致。

那是不是把编码格式改成把编码格式改成ASCII就好了,那个同学马上去试了一下。

结果是不行的,因为-128不是ASCII字符,转ASCII以后会变成?,也就是固定的63。

ASCII字符编码只用了7位,UTF-8则是变长的,有8,16,24和32四种,那寻找一种固定使用8位的字符编码就可以了,我们找到了是ISO_8859_1

那把上面的编码替换成ISO_8859_1是不是就好了呢?我们测试的结果依然是63,原来从RedisTemplate返回的字符串就是UTF-8编码的,再转换依然是错的。我们服务跟Redis之间传输的是原始的二进制数据,编码不会错,问题可能出现在RedisTemplate中,因为有个ValueSerializer会对字节数组做转换处理。

我们查了StringRedisTemplate的源码,发现ValueSerializer的实现为StringRedisSerializer。

    public StringRedisTemplate() {
        RedisSerializer<String> stringSerializer = new StringRedisSerializer();
        this.setKeySerializer(stringSerializer);
        this.setValueSerializer(stringSerializer);
        this.setHashKeySerializer(stringSerializer);
        this.setHashValueSerializer(stringSerializer);
    }

而StringRedisSerializer的实现中,默认的字符编码是UTF-8

    private final Charset charset;

    public StringRedisSerializer() {
        this(StandardCharsets.UTF_8);
    }

    public StringRedisSerializer(Charset charset) {
        Assert.notNull(charset, "Charset must not be null!");
        this.charset = charset;
    }

现在明白了,我们把StringRedisSerializer中的字符编码改掉就可以了。但是新的问题来了,StringRedisTemplate已经有实例化了,而且是单例,直接修改会影响其他的地方使用。

于是,我们就仿照StringRedisTemplate重新写了一个BitRedisTemplate,唯一的区别是StringRedisSerializer的默认字符集是ISO_8859_1,并且在RedisConfiguration中配置BitRedisTemplate。

    @Bean
    @ConditionalOnMissingBean
    public BitRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        BitRedisTemplate template = new BitRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

然后把上面代码中的redisTemplate替换掉,一切正常了。

最后总结一下,零存整取,关键的地方是整取,如果每个字节存储的都是ASCII字符,没有任何问题。但是只要在每个字节的最高位有存储数据,那就会出现字符编码问题,是ISO_8859_1是一个不错的选择,但是要保证每个环节都是是ISO_8859_1