大家好,又见面了。
本文是笔者作为掘金技术社区签约作者的身份输出的缓存专栏系列内容,将会通过系列专题,讲清楚缓存的方方面面。如果感兴趣,欢迎关注以获取后续更新。
通过前面的文章,我们一起剖析了Guava Cache
、Caffeine
、Ehcache
等本地缓存框架的原理与使用场景,也一同领略了以Redis
为代表的集中式缓存在分布式高并发场景下无可替代的价值。
现在的很多大型高并发系统都是采用的分布式部署方式,而作为高并发系统的基石,缓存是不可或缺的重要环节。项目中使用缓存的目的是为了提升整体的运算处理效率、降低对外的IO请求,而集中式缓存是独立于进程之外部署的远端服务,需要基于网络IO的方式交互。如果一个业务逻辑中涉及到非常频繁的缓存操作,势必会导致引入大量的网络IO交互,造成过大的性能损耗、加剧缓存服务器的压力。另外,对于现在互联网系统的海量用户数据,如何压缩缓存数据占用容量,也是需要面临的一个问题。
本篇文章,我们就一起聊一聊如何来更好的使用缓存,探寻下如何降低缓存交互过程的性能损耗、如何压缩缓存的存储空间占用、如何保证多个操作命令原子性等问题的解决策略,让缓存在项目中可以发挥出更佳的效果。
通过BitMap降低Reids存储容量压力
在一些互联网类的项目中,经常会有一些签到相关功能。如果使用Redis来缓存用户的签到信息,我们一般而言会怎么存储呢?常见的会有下面2种思路:
- 使用Set类型,每天生层1个Set,然后将签到用户添加到对应的Set中;
- 还是使用Set类型,每个用户一个Set,然后将签到的日期添加到Set中。
对于海量用户的系统而言,按照上述的策略,那么每天仅签到信息这一项,就可能会有上千万的记录,一年累积下来的数据量更大 —— 这对Redis的存储而言是笔不小的开销。对于签到这种简单场景,只有签到和没签到两种情况,也即0/1
的场景,我们也可以通过BitMap来进行存储以大大降低内存占用。
BitMap(位图)
可以理解为一个bit数组,对应bit位可以存放0或者1,最终这个bit数组被转换为一个字符串的形式存储在Redis中。比如签到这个场景,我们可以每天设定一个key,然后存储的时候,我们可以将数字格式的userId表示在BitMap中具体的位置信息,而BitMap中此位置对应的bit值为1则表示该用户已签到。
Redis其实也提供了对BitMap存储的支持。前面我们提过Redis支持String、Set、List、ZSet、Hash等数据结构,而BitMap能力的支持,其实是对String数据结构的一种扩展,使用String数据类型来支持BitMap的能力实现。比如下面的代码逻辑:
public void userSignIn(long userId) {
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
String redisKey = "UserSginIn_" + today;
Boolean hasSigned = stringRedisTemplate.opsForValue().getBit(redisKey, userId);
if (Boolean.TRUE.equals(hasSigned)) {
System.out.println("今日已签过到!");
} else {
stringRedisTemplate.opsForValue().setBit("TodayUserSign", userId, true);
System.out.println("签到成功!");
}
}
对于Redis而言,每天就只有一条key-value
数据。下面对比下使用BitMap与使用普通key-value模式的数据占用情况对比。模拟构造10亿用户数据量进行压测统计,结果如下:
- BitMap格式: 150M
- key-value格式: 41G
可以看出,在存储容量占用方面,BitMap完胜。
关于pipeline管道批处理与multi事务原子性
使用Pipeline降低与Reids的IO交互频率
在很多的业务场景中,我们可能会涉及到同时去执行好多条redis命令的操作,比如系统启动的时候需要将DB中存量的数据全部加载到Redis中重建缓存的时候。如果业务流程需要频繁的与Redis交互并提交命令,可能会导致在网络IO交互层面消耗太大,导致整体的性能降低。
这种情况下,可以使用pipeline
将各个具体的请求分批次提交到Redis服务器进行处理。
private void redisPipelineInsert() {
stringRedisTemplate.executePipelined(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
try {
// 具体的redis操作,多条操作都在此处理,最后会一起提交到Redis远端去执行
} catch (Exception e) {
log.error("failed to execute pipelined...", e);
}
return null;
}
});
}
使用pipeline的方式,可以减少客户端与redis服务端之间的网络交互频次,但是pipeline也只是负责将原本需要多次网络交互的请求封装一起提交到redis上,在redis层面其执行命令的时候依旧是逐个去执行,并不会保证这一批次的所有请求一定是连贯被执行,其中可能会被插入其余的执行请求。
也就是说,pipeline的操作是不具备原子性的。
使用multi实现请求的事务
前面介绍pipeline的时候强调了其仅仅只是将多个命令打包一起提交给了服务器,然后服务器依旧是等同于逐个提交上来的策略进行处理,无法保证原子性。对于一些需要保证多个操作命令原子性的场景下,可以使用multi
来实现。
当客户端请求执行了multi命令之后,也即开启了事务,服务端会将这个客户端记录为一个特殊的状态,之后这个客户端发送到服务器上的命令,都会被临时缓存起来而不会执行。只有当收到此客户端发送exec
命令的时候,redis才会将缓存的所有命令一起逐条的执行并且保证这一批命令被按照发送的顺序执行、执行期间不会被其他命令插入打断。
代码示例如下:
private void redisMulti() {
stringRedisTemplate.multi();
stringRedisTemplate.opsForValue().set("key1", "value1");
stringRedisTemplate.opsForValue().set("key2", "value2");
stringRedisTemplate.exec();
}
需要注意的一点是,redi