Redis持久化
Redis持久化有两种方式:
RDB:根据指定规则将内存中的数据保存到硬盘上,这个过程称为“快照”,下次Redis启动的时候加载快照文件到内存。快照文件保存在Redis工作目录中的dump.rdb文件中,可以通过配置文件中的dir和dbfilename设置路径和文件名,如dbfilename newname.rdb。RDB是Redis的默认持久化方式。
AOF:将每次执行的命令追加保存到硬盘上(所以推荐使用高速的硬盘),下次Redis启动的时候加载硬盘上的命令来恢复数据。 命令文件保存在Redis工作目录中的appendonly.aof文件中(可以通过配置文件中的dir和appendfilename设置路径和文件名,如appendfilename newname.aof。
Redis通过持久化功能保证了服务在重启的情况下也不会丢失原来的数据。
RDB:
快照的过程:调用fork()复制当前进程的内存副本到子进程,子进程将内存中的数据写入到硬盘中的临时文件,写完所有数据后将临时文件替换掉.rdb文件。
Redis会在以下几种情况下进行快照:
①、根据配置文件中sava参数配置进行快照
如下所示的配置文件,可以存在多个条件,每个条件互不影响,满足的话都会执行:
save 60 1000 //在60秒内有1000个或1000个以上的键被修改,则进行快照 save 300 10 //在300秒内有10个或10个以上的键被修改,则进行快照 save 900 1 //在900秒内有1个或1个以上的键被更改,则进行快照
②、执行SAVE或BGSAVE命令
SAVA会同步的进行快照,所以其它命令就不会被执行,直到快照结束。
BGSAVE会异步的进行快照,所以期间Redis可以照样执行其它命令,执行BGSAVE命令会立即返回OK,可以通过LASTSAVE命令来获取最后一次成功执行快照的具体时间(返回UNIX时间戳)。
③、执行FLUSHALL命令
FLUSHALL命令会清空Redis中所有的数据,如果配置文件中配置了sava参数的话,那么在清空数据前会进行一次快照。
④、执行复制时
当从数据库首次连接主数据库的时候,主数据库会自动生成快照文件来发给从数据库。
fork()会使用copy-on-write写时复制技术,即fork()后并不是直接复制父进程的内存到子进程,而是父子进程公用一份内存,只有当父进程或子进程中某一片数据需要修改的时候(比如父进程有更新数据的请求)才会将该片数据复制一份出来。 fork()后实际上是将父子进程的公用内存设置为read-only,当某一进程写内存时会触发页异常中断,在中断中kernel会将触发异常的页复制一份,所以现在有两份内存,父进程一份,子进程一份。fork()使用cow的缺点:如果fork()后有大量的写操作的话,会产生大量的分页错误(页异常中断page - fault),这样就得不偿失。
写时复制策略保证了fork()后Redis内存占用不会增加一倍,所以当只有2G内存,但Redis占用了1.5G的话,执行fork()也不会超出内存限制(但此时需要确保系统允许申请超过可用内存的空间,linux中为在/etc/sy sctl.conf文件中加入vm.overcommit_memory = 1或执行sy sctl vm.overcommit_memory=1命令)。虽然使用了写时复制技术,但如果fork()的时候Redis就已经占用了很大的内存,fork()后又有大量的写请求命令的话(写操作会复制数据),那么很有可能就会超出内存限制。
AOF
当Redis异常退出后,就会丢失最近一次快照之后进行的操作数据,所以保险的方法还是使用AOF模式。实际上,默认情况下Redis是每隔一秒才将命令写入到硬盘文件的,可以通过配置文件来调整为每次命令都写入,如下所示。默认AOF功能没有开启,可以通过配置文件的appendonly参数来开启,如appendonly yes。
appendfsync everysec //每秒写入 appendfsync always //每次命令就写入 appendfsync no //每30秒写入一次
aof文件中也许有一些重复的数据,比如进行了三条A数据更新的命令,实际上只保存最后一条就行。Redis提供了对aof文件的重写命令BGREWRITEAOF,执行后其会将这种重复的命令进行剔除和优化操作,以减小文件大小。也可以通过配置文件来设置自动重写aof文件:
auto-aof-rewrite-min-size 64mb //设置文件超过这个大小后才可以进行重写操作 auto-aof-rewrite-percentage 10 //当aof文件大小超过上一次重写时大小的10%的时候进行重写(之前没重写过则以启动时aof文件大小为标准依据)
缓存更新策略
缓存更新有三种策略,一是将数据只写到Redis中,当内存使用达到默认或设置的上限的时候Redis会使用默认或设置的淘汰策略来淘汰一些数据。二是超时剔除,即给缓存数据加上TTL,到期自动删除,查询的时候Redis中无数据的话就从数据库查询数据后更新缓存。三是主动更新,即在修改数据库的时候同时更新Redis。一般我们选择最后一种,即主动更新策略。
Redis所在主机内存不足的时候会导致内存swap,即操作系统里将内存数据在内存和磁盘间来回换入和换出的机制,Redis触发swap后会影响Redis的主IO线程,大大增加Redis的响应时间,所以应该设置Redis的最大使用内存。可以通过命令(config get maxmemory)来查询最大内存设置,通过配置文件(maxmemory 100MB)或者命令(config set maxmemory 100MB)设置最大内存。如下为Redis的内存淘汰策略(Redis达到最大内存后的策略),可以通过命令(config get maxmemory-policy)来查询当前内存淘汰策略,通过配置文件(maxmemory-policy allkeys-lru)或命令(config set maxmemory-policy allkeys-lru)来设置内存淘汰策略:
noeviction(默认策略)
对于写请求不再提供服务,直接返回错误(DEL或部分特殊请求除外)。
allkeys-lru
从所有key中使用LRU算法进行淘汰,如果没有key可以被淘汰,则和默认策略一样返回错误。LRU(Least Recently Used),即最近最少使用的缓存置换算法。
volatile-lru
从设置了过期时间的key中使用LRU算法进行淘汰。
allkeys-random
从所有key中随机淘汰数据。
volatile-random
从设置了过期时间的key中随机淘汰。
volatile-ttl
在设置了过期时间的key中,根据key的过期时间进行淘汰,越早过期越优先淘汰。
选择主动更新策略的话,又有三种方式,一个是最普通的,我们在更新数据库的同时更新Redis。第二个是将Redis和数据库整合为一个服务,我们使用该服务来进行更新,该服务维护数据一致性。第三个是调用者只更新缓存,由其他线程异步的将缓存数据持久化到数据库,最终保持一致。
我们一般选择主动更新策略的第一种方式,即自己编码来更新缓存和数据库,以及保证数据的一致性。选择自己编码来更新缓存和数据库的话,有三个问题:一是更新数据库的时候是直接更新缓还是删除缓存(删除缓存即让缓存失效,下次查询的时候再更新缓存),因为第一种方式的无效写操作(比如更新了100次数据库和缓存后才有一次读)可能会比较多,所以推荐第二种方式。二是先删除缓存还是先更新数据库,我们一般选择先更新数据库再删除缓存,因为如果我们先删除缓存的话,假设此时另一个线程得到CPU,然后查询缓存不存在,那么会去查询数据库后并更新缓存,此时第一个线程获得CPU,然后去更新数据库,此时就会产生数据库和缓存数据不一致问题。其实先更新数据库也会有问题,比如现在我们查询的时候缓存失效(被删除),然后我们去查询数据库然后拿到数据,在准备写入缓存的时候,CPU资源被另一个线程抢走,在这个线程里开始更新数据库,然后删除缓存,这时候我们的线程再次获取到CPU,执行写入缓存操作,此时写入的实际上 是旧数据,与现在缓存中的数据不同,产生数据不一致问题。可以看到,出现问题的情况都是多线程操作的情况,所以在服务中进行多线程数据操作的话(比如Servlet中会使用多线程来执行doGet()或doPost()方法),应该使用锁将数据库和缓存的操作作为一个原子操作,如果我们的服务是分布式的话,可以使用分布式锁。三是怎样保证缓存与数据库的操作同时成功或失败,可以将缓存和数据库操作放到一个事务中(即数据库操作失败的话不进行缓存操作,缓存操作失败的话还原前面的数据库操作),我们可以自己实现代码来实现事务,也可以使用TCC事务。
总结:更新数据的时候,先更新数据库,然后设置缓存失效(删除缓存)。获取数据的时候,先从缓存读取数据,缓存中无数据的话,从数据库查询到数据后更新缓存,如果数据库中也没有数据的话返回无数据。如果服务里会出现多线程操作数据的情况,为了避免数据库和缓存数据不一致,更新数据和读取数据之前加锁,如果服务是分布式的,更新数据和读取数据之前使用分布式锁。应该使用事务机制来保证更新数据库和更新缓存的结果一致性(数据库操作失败的话不进行缓存操作,缓存操作失败的话还原前面的数据库操作)。
缓存问题
获取缓存数据的时候会存在以下三个问题:
①、缓存击穿:缓存中没有数据但数据库中有数据,比如服务刚起来或者缓存数据刚失效,此时有大量用户并发访问该缓存的话,会引起数据库压力较大。解决方案有两种:一个是使用锁,如下所示,当缓存不存在或已经过期,对以后的操作加锁执行以防止并发操作数据库。一个是设置热点数据永不过期,所谓的热点数据即访问量很高的数据,比如大量用户同时访问或者单个用户频繁访问的缓存数据,如果这些数据失效的话可能会引起数据库压力过大,对于这种热点数据,我们可以在数据库更新后不执行缓存删除操作,而是直接设置缓存的值。
String getValue(String key) { String value = redis.get(key); //从Redis读取数据 if (value = null) { //缓存不存在或已经过期 if (redis.setnx(mutexKey, 1, 3 * 60) == 1) { //获得了锁 try { value = db.get(key); //从数据库获取数据 redis.set(key, value); //更新缓存 } finally { redis.del(mutexKey); //释放锁 } } else { //等待50毫秒后重试 Thread.sleep(50); getValue(key); } } return value; }
②、缓存穿透:缓存和数据库中都没有数据,但此时有大量用户并发查询该缓存,或者黑客不断的发起查询缓存的操作,导致数据库压力过大。解决方案有两种:一个是数据库无数据的话设置缓存的值为null,读取数据的时候如果key的值为null,就认为数据库中也无数据,省去了访问数据库操作。一个是接口层增加校验,比如id校验(接口参数增加id,用户传的是错误的ID的话直接返回)、用户鉴权校验等。
③、缓存雪崩:缓存中数据大批量过期,当查询量很大的时候会引起数据库压力过大。解决方案有两种:一个是防止每个键的过期时间相同(不要同时删除大量的访问量很高的键)。一个是设置热点数据永不过期。
分布式锁
可以使用Redis来实现分布式锁,比如前面“缓存击穿”中示例代码使用的分布式锁就是通过setnx来实现的,其原理就是Redis命令是线程安全的,用户A调用setnx设置键mutexKey的值为1后,其它用户再次调用setnx会返回失败,直到用户A将mutexKey键删除或者键的TTL到期被自动删除后其他用户才能调用setnx成功。可以看到这里我们设置了键的TTL为3*60秒即3分钟,设置键的TTL是为了防止用户忘记或者程序崩溃等原因导致键一直未删除,其他用户就一直获得不到锁。
上面的代码其实还隐含一个问题,就是如果用户A获得锁之后,进行的操作耗时太长超过了TTL时间,键被自动删除,其它用户就可以获得锁,而等到A用户操作完成后会执行删除键的操作(相当于没有获得锁然后去解锁),这个时候如果其它用户已经获得了锁的话就会出现问题。我们可以在设置mutexKey的值的时候将其设置为用户ID或者UUID,解锁的时候先获取value判断是否为本用户ID或UUID,然后再解锁:
String getValue(String key, String userID) { String value = redis.get(key); //从Redis读取数据 if (value = null) { //缓存不存在或已经过期 if (redis.setnx(mutexKey, userID, 3 * 60) == 1) { //获得了锁 try { value = db.get(key); //从数据库获取数据 redis.set(key, value); //更新缓存 } finally { if(redis.get(mutexKey) == userID) redis.del(mutexKey); //释放锁 } } else { //等待50毫秒后重试 Thread.sleep(50); getValue(key); } } return value; }
上面的代码还有问题,那就是当操作时间过长超过了TTL时间以后,实际上就已经失去了锁(因为mutexKey到期被自动删除),而此时执行到finally中代码,其中的获得mutexKey键的值后再对其删除是两个命令,存在线程安全问题。我们可以使用lua脚本来解决这个问题,因为lua脚本会保证里面操作的原子性(Redis在运行lua脚本时不会运行其他脚步和命令,类似于给执行lua脚本这段代码加了锁 ,所以保证原子性)。如下所示为lua脚本的内容,其中KEYS和ARGV分别是以集合方式传入的参数,KEYS[1]即为mutexKey,ARGV[1]即为userID:
if redis.call('get', KEYS[1]) == ARGV[1] then -- 执行删除操作 return redis.call('del', KEYS[1]) else -- 不成功,返回0 return 0 end
除了自己实现分布式锁之外,也可以直接使用Redisson中的分布式锁(Lock、FairLock、MultiLock、RedLock、ReadWriteLock),并且Redisson还提供一些数据类型的分布式版本,比如RedissonAtomicLong相当于是java的AtomicLong。