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事务。
总结:更新数据的时候,先更新数据库,然后设置缓存失效(删除缓存)。获取数据的时候,先从缓存读取数据,缓存中无数据的话,从数据库查询到数据后更新缓存,如果数据库中也没有数据的话返回无数据。如果服务里会出现多线程操作数据的情况,为了避免数据库和缓存数据不一致,更新数据和读取数据之前加锁,如果服务是分布式的,更新数据和读取数据之前使用分布式锁。应该使用事务机制来保证更新数据库和更新缓存的结果一致性(数据库操作失败的话不进行缓存操作,缓存操作失败的话还原前面的数据库操作)。
缓存问题
获取缓存数据的时候会存在以下三个问题:
①、缓存击穿:缓存中没有数据但数据库中有数据,比如服务刚起来或者缓存数据刚失效,此时有大量用户并发访问该缓存的话,会引起数据库压力较大。解决方案有两种:①、使用锁来限制只有一个客户操作数据库和缓存,如果服务是集群的话,就需要使用分布式锁 。②、设置热点数据永不过期,所谓的热点数据即访问量很高的数据,比如大量用户同时访问或者单个用户频繁访问的缓存数据,如果这些数据失效的话可能会引起数据库压力过大,对于这种热点数据,我们可以在数据库更新后不执行缓存删除操作,而是直接设置缓存的值。
②、缓存穿透:缓存和数据库中都没有数据,但此时有大量用户并发查询该缓存,或者黑客不断的发起查询缓存的操作,导致数据库压力过大。解决方案有两种:一个是数据库无数据的话设置缓存的值为null,读取数据的时候如果key的值为null,就认为数据库中也无数据,省去了访问数据库操作。一个是接口层增加校验,比如id校验(接口参数增加id,用户传的是错误的ID的话直接返回)、用户鉴权校验等。
③、缓存雪崩:缓存中数据大批量过期,当查询量很大的时候会引起数据库压力过大。解决方案有两种:一个是防止每个键的过期时间相同(不要同时删除大量的访问量很高的键)。一个是设置热点数据永不过期。
分布式锁
前面说过,“缓存击穿”问题可以通过分布式锁来解决,而分布式锁可以通过redis命令SETNX来实现,因为Redis命令是线程安全的,当用户A调用SETNX创建了一个键后,另一个用户B想要创建同名的键的话会失败,除非该键被删除。如下所示,使用SETNX创建键成功就表示加锁成功,然后可以执行相关的操作,操作完以后删除该键来释放锁,使用SETNX创建键失败表示加锁失败,当前该锁正在被使用。
bool lock(String lock_name) { return redis.setnx(lock_name, 1) == 1 ? : true : false; } void unlock(String lock_name) { redis.del(lock_name); } String getValue(String key) { String lock_name = key + "mutex"; if (lock(lock_name)) { //获得了操作权 try { value = db.get(key); //从数据库获取数据 redis.set(key, value); //更新缓存 } finally { unlock(lock_name); //释放操作权 } } else { //当前有其它用户正在操作 Thread.sleep(50); //等待50毫秒后重试 getValue(key); } return value; }
如果程序崩溃或者代码里忘记了释放锁,那么其他用户就会一直获取不到锁,可以通过PX来设置锁的过期时间,超时的话自动删除锁,如下所示,设置锁的TTL为10秒:
bool lock(String lock_name) { return redis.setnxpx(lock_name, 1, 10) == 1 ? : true : false; }
如果A用户执行的时间太长,超过了TTL,锁被自动删除,B用户获得了锁后,A用户执行完毕,然后执行解锁操作,B用户创建的锁被A删除。在加锁的时候可以给锁的value设置一个唯一ID(比如UUID),释放锁的时候先获得value是否与当时设置的一致,这样当锁超时被自动删除后,其它用户再次加锁的时候,锁的value值与上次不同,A用户删除锁失败:
bool lock(String lock_name, String uuid, int ttl) { return redis.setnxpx(lock_name, uuid, ttl) == 1 ? : true : false; }
void unlock(String lock_name, String uuid) { if(redis.get(lock_name) == uuid) redis.del(lock_name); }
前面unlock方法里获得锁的value后再执行删除操作,是两个命令,存在线程安全问题,可以使用lua脚本来解决这个问题(Redis在运行lua脚本时不会运行其他脚本和命令,保证了脚本执行内容的原子性),如下所示,将这两个命令放到脚本里来执行:
if redis.call('get', KEYS[1]) == ARGV[1] then -- value值相同,执行删除操作 return redis.call('del', KEYS[1]) else return 0 end
如果想要实现可重入锁的话,可以将锁保存为哈希类型,哈希名为锁名称,哈希中只有一个元素,该元素的key为uuid,value为重入次数,比如首次加锁重入次数为1,再次加锁的话重入次数+1,解锁的时候重入次数-1,当重入次数为0的时候才删除该锁。
为了解决业务处理时间超过锁的TTL过期时间而导致的锁被提前释放问题,可以在后台启动定时任务(俗称看门狗),看门狗机制是客户端定期向redis发送续期命令来进行key的续期,比如加锁时没有指定TTL的话,默认TTL设为 30 秒,然后每10秒自动向redis服务续期锁的TTL。如果程序崩溃,自动续期停止,该锁到期会被redis释放。当业务处理完成,执行unlock来释放锁,不再续期该锁。仅当用户加锁时未指定锁过期时间的时候开启定时续期,如果加锁的时候指定了过期时间的话,不开启定时续期任务,超时自动释放锁。一般是建议加锁的时候通过不设置过期时间来开启看门狗,防止业务未完成但已失去了锁。若客户端与 Redis 网络断开,看门狗的续期请求会失败,锁将在最后一次续期后的 30 秒被Redis释放。
若在获得了锁之后Redis主服务发生宕机,因为客户端与Redis无长连接,客户端不会得到通知,主服务宕机到从服务升级为主服务这段时间Redis不会对外提供服务,所以正常情况下不用担心另一个客户会获得锁(另一个客户获取锁、或者当前持有锁的客户调用unlock释放锁的话,都会抛出Redis连接异常,主从切换完成后锁数据恢复,原来的客户仍然持有锁)。但是有一个特殊情况:Redis主从复制是异步的,主节点向客户端返回key保存成功时,数据可能尚未同步到从节点,比如客户端 A 在主节点加锁成功后,还未将锁数据同步到从节点时主节点宕机了,然后哨兵将从节点提升为主节点,Redis会将新主节点数据全量同步给新的从节点——老的主节点数据被清空,这就导致了客户A加的锁被清空,客户端 B 向新主节点请求加锁就会成功(因为原来的锁没有同步到新主节点,而且该锁在主从同步的时候被删除了),这就导致了两个客户端同时持有锁。针对主节点宕机导致的多个客户获得同一把锁问题的处理方法是使用RedLock红锁算法:部署最少5个Redis节点(这些节点也可以是主从架构的),各节点之间没有任何关系,客户端加锁的时候是在所有节点之上加锁(因为各节点之间无主从或集群关系,所以客户端是一个一个的向所有的节点加锁,而不是一个节点加锁之后将锁数据同步给其它节点), 当在 ≥N/2+1 个节点上成功加锁,且总耗时 < 锁有效期的时候,加锁才算成功,因为是在多个节点上加锁,所以某一个节点出现宕机或者主从切换的时候锁数据丢失的话,不影响当前客户锁的状态,释放锁的话是向所有节点发送解锁命令。
Redisson
可以直接使用Redisson中的分布式锁,它包含了可重入锁、读写锁、RedLock等多种锁类型,其设计实现自动包含了前面所说的超时释放、Lua脚本保证原子性、可重入设计、看门狗自动续期等。
除了上面说的“缓存击穿”,一些其它常见的需要使用分布式锁的场景:
①、不同用户下单:用户下单,服务1查询库存数还剩1,然后进行扣减操作,但是在进行扣减操作之前,又有用户下单,服务2获得库存数也是1,然后进行扣减操作,最后导致库存数变成-1。
②、同一个用户下单:用户连续点击下单按钮,第一次请求由服务1处理,第二次请求由服务2处理(此时用户第一次请求还未处理或处理未完成,所以会被认为是正常下单),导致重复下单。
③、定时任务:服务需要定时执行一些任务,但因为服务是集群服务,所以会导致服务1执行了定时任务后服务2再次执行定时任务。
redisson还提供分布式集合,这些集合提供了类java标准集合的接口,而且可以很方便的对其使用分布式锁,比如可以直接获取该集合中元素对应的分布式锁来使用,如RMap中提供getLock方法来获得指定键的分布式锁。对于没有getLock方法的集合,可以使用RedissonClient.getLock()方法获得分布式锁来对键进行加锁,调用getLock的时候可以传入"lock:集合名称:键名"以防锁冲突,如果是获得整个集合的分布式锁的话,也是类似,代码如下所示:
public void func() { String listKey = "user_id_list"; //分布式集合名称 RList<Long> userIdList = redissonClient.getList(listKey); //获取分布式集合 String wholeCollectionLockKey = "lock:" + listKey; //锁Key格式:lock:集合名称,避免锁冲突 RLock wholeCollectionLock = redissonClient.getLock(wholeCollectionLockKey); //获得分布式锁 try { boolean locked = wholeCollectionLock.tryLock(10, 30, TimeUnit.SECONDS); //加锁 if (!locked) { throw new RuntimeException("系统繁忙,请稍后重试"); } // 执行业务:比如先判断集合是否为空,为空的话就插入数据 } catch (InterruptedException e) { throw new RuntimeException(e); } finally { //释放锁 if (wholeCollectionLock.isHeldByCurrentThread()) { wholeCollectionLock.unlock(); } } }
Redisson还提供分布式位图RBitSet,位图相当于是一个只包含二进制的数组,数组元素只能是0或1,使用它能够以极小的内存来存储海量数据的状态信息。比如要统计每日活跃用户,可以使用位图来保存当日所有用户的活跃状态(0为未登录,1为已登录),而且通过位运算来合并多日位图,可轻松计算周活、月活。
Redisson还提供分布式数值RAtomicLong、RAtomicDouble,它的功能接口与java中的AtomicLong、AtomicDouble类似,并且提供原子的多步操作功能,如 CAS (比较旧值并设置新值)、incrementAndGet。
Redisson还提供分布式对象RBucket,可以将java对象保存在Redis上。
Redisson还提供分布式限流器,如果要限流的接口部署在集群服务中,可以使用分布式限流器。
浙公网安备 33010602011771号