项目组的一个同学今天突然找到我求助,让我帮忙看一个Redis的问题。
原来他利用Redis
的Bitmap
来实现布隆过滤器,记录用户已读的内容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_STRING
的RedisObject
。RedisObject
的ptr
指针指向一个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
。