一点一滴成长

导航

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;
}
View Code

     ②、缓存穿透:缓存和数据库中都没有数据,但此时有大量用户并发查询该缓存,或者黑客不断的发起查询缓存的操作,导致数据库压力过大。解决方案有两种:一个是数据库无数据的话设置缓存的值为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。

 

posted on 2021-12-30 17:28  整鬼专家  阅读(60)  评论(0编辑  收藏  举报