Redis

2019-05-09  本文已影响0人  锅锅与倩倩

什么是Redis

redis是一个单线程高性能的key-value存储系统

Redis的特点和优势

Redis命令

key

[DEL key]
删除键
[EXISTS key]
判断键是否存在
[TTL key]
以秒为单位,返回给定 key 的剩余生存时间
[KEYS pattern]
查找所有符合给定模式( pattern)的 key,

tip

KEYS 操作时间复杂度是O(N),考虑到redis的单线程特性,生产环境慎用,考虑使用时间复杂度是O(1)的DBSIZE操作

String

[GET key]
获取指定 key 的值。
[SET key value]
设置指定 key 的值
[MGET key1 [key2..]]
获取所有(一个或多个)给定 key 的值。
[MSET key value [key value ...]]
同时设置一个或多个 key-value 对。
[INCR key]
将 key 中储存的数字值增一,实现计数器功能
[APPEND key value]
如果 key 已经存在并且是一个字符串, APPEND 命令将指定的 value 追加到该 key 原来值(value)的末尾。如果 key 不存在, APPEND 就简单地将键 key 的值设为 value , 就像执行 SET key value 一样。

Hash

hash 是一个string类型的field和value的映射表,hash特别适合用于存储对象
[HGET key field]
获取存储在哈希表中指定字段的值。时间复杂度O(1)。
[HSET key field value]
将哈希表 key 中的字段 field 的值设为 value 。时间复杂度O(1)。
[HMGET key field1 [field2]]
获取所有给定字段的值
[HMSET key field1 value1 [field2 value2 ]]
同时将多个 field-value (域-值)对设置到哈希表 key 中。

案例

用户对象在redis中的存储

方式 优点 缺点
用户ID为key,序列化内容为value 编程简单,内存消耗不大 1.序列化开销2.设置属性要操作整个数据
用户ID+property为key,字段内容为value 1.可以部分更新2.可以控制字段的ttl 1.内存占用大2.key较为分散
hash 1.可以部分更新2.节省空间 1.编程稍微复杂2.字段值的ttl不好控制

List

Redis列表是string类型的双向链表

[LPUSH key value1 [value2]]/[RPUSH key value1 [value2]]
将一个或多个值插入到列表头部或尾部
[LPOP key]/[RPOP key]
移出并获取列表头部或尾部的第一个元素,如果没有值返回null
[BLPOP key1 [key2 ] timeout]/[BRPOP key1 [key2 ] timeout]
移出并获取列表表头部或尾部的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。 在队列场景中可以替代轮询。

使用场景

1.最新消息排行功能。
2.消息队列,可以利用Lists的PUSH操作,将任务存在Lists中,然后工作线程再用POP操作将任务取出进行执行。

问题?既然Redis是单线程的工作模式,那像BLPOP这样的阻塞操作又是如何实现的呢?

redis实现了一套事件触发模型,主要处理两种事件:IO事件和定时事件。而处理它们的就靠一个EventLoop线程。在IO事件中,redis完成客户端连接应答、命令请求处理和命令处理结果回复等,在定时事件中,redis完成过期key的检测等。

redis在blpop命令处理过程时,首先会去查找key对应的list,如果存在,则pop出数据响应给客户端。否则将对应的key push到blocking_keys数据结构当中,对应的value是被阻塞的client。当下次push命令发出时,服务器检查blocking_keys当中是否存在对应的key,如果存在,则将key添加到ready_keys链表当中,同时将value插入链表当中并响应客户端。

每次处理完客户端命令后都会遍历ready_keys,并通过blocking_keys找到对应的client,依次将对应list的数据pop出来并响应对应的client

这样一来整个流程就清晰了。redis就是通过blocking_keysready_keys两个数据结构来实现的阻塞操作。但整个阻塞并没有阻塞EventLoop本身,从而实现命令的快速响应。算是一个典型的空间换时间的设计思路。

时间 客户端 A blocking_keys ready_keys 客户端 B
T1 BLPOP key key client
T2 BLOCKING key client key value push key value
T3 RETURN key value

Set

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

[SADD key member1 [member2]]
向集合添加一个或多个成员
[SCARD key]
获取集合的成员数,时间复杂度O(1)
[SMEMBERS key]
返回集合中的所有成员
[SISMEMBER key member]
判断 member 元素是否是集合 key 的成员
[SINTER key1 [key2]]/[SUNION key1 [key2]]/[SDIFF key1 [key2]]
返回给定所有集合的交集/并集/差集

使用场景

1.比如微博应用中,每个人的好友存在一个集合(set)中,这样求两个人的共同好友的操作,可能就只需要用求交集命令即可。同样适用于用户的兴趣等等。

Sort Set

Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数,支持通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。

有序集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。

[ZADD key score1 member1 [score2 member2]]
向有序集合添加一个或多个成员,或者更新已存在成员的分数
[ZCARD key]
获取有序集合的成员数
[ZRANGE key start stop [WITHSCORES]]
通过索引区间返回有序集合成指定区间内的成员

redis 127.0.0.1:6379> ZRANGE salary 0 -1 WITHSCORES             # 显示整个有序集成员
1) "jack"
2) "3500"
3) "tom"
4) "5000"
5) "boss"
6) "10086"

redis 127.0.0.1:6379> ZRANGE salary 1 2 WITHSCORES              # 显示有序集下标区间 1 至 2 的成员
1) "tom"
2) "5000"
3) "boss"
4) "10086"
使用场景

1.排序,比如全班同学成绩的SortedSets,value可以是同学的学号,而score就可以是其考试得分,这样数据插入集合的,就已经进行了天然的排序。
注意List也可以实现排序的功能,但List只能根据插入的位置进行排序,而Sort Set可以根据score属性实现自动排序。
2.带权重的队列,比如普通消息的score为1,重要消息的score为2,然后工作线程可以选择按score的倒序来获取工作任务。让重要的任务优先执行。
3.需要精准设定过期时间的应用,比如监控系统需要查询最近一分钟的数据,这里可以设member为监控数据,score为时间戳,就可以根据score排序取出一分钟的数据

bitmap位图

bitmap本质就是String,redis支持直接读取String每个bit位的值(0或1)
首先来看一个例子,字符串big,

字母b的ASCII码为98,转换成二进制为 01100010
字母i的ASCII码为105,转换成二进制为 01101001
字母g的ASCII码为103,转换成二进制为 01100111

如果在Redis中,设置一个key,其值为big,此时可以get到big这个值,也可以获取到 big的ASCII码每一个位对应的值,也就是0或1

127.0.0.1:6379> set hello big
OK
127.0.0.1:6379> getbit hello 0      # b的二进制形式的第1位,即为0
(integer) 0
127.0.0.1:6379> getbit hello 1      # b的二进制形式的第2位,即为1
(integer) 1
bitmap的常用命令

setbit key offset vlaue 给位图指定索引设置值

127.0.0.1:6379> set hello big       # 设置键值对,key为'hello',value为'big'
OK
127.0.0.1:6379> setbit hello 7 1    # 把hello二进制形式的第8位设置为1,之前的ASCII码为98,现在改为99,即把b改为c
(integer) 0                         # 返回的是之前这个位上的值
127.0.0.1:6379> get hello           # 修改之后,获取'hello'的值,为'cig'
"cig"

getbit key offset 获取位图指定索引的值

127.0.0.1:6379> getbit hello 25
(integer) 0
127.0.0.1:6379> getbit hello 49
(integer) 0
127.0.0.1:6379> getbit hello 50
(integer) 1

bitcount key [start end] 获取位图指定范围(start到end,单位为字节,如果不指定就是获取全部)位值为1的个数

127.0.0.1:6379> bitcount hello
(integer) 14
127.0.0.1:6379> bitcount hello 0 1
(integer) 7
bitmap位图应用

如果一个网站有1亿用户,假如user_id用的是整型,长度为32位,每天有5千万独立用户访问,如何判断是哪5千万用户访问了网站

方式一:用set来保存
使用set来保存数据运行一天需要占用的内存为

32bit * 50000000 = (4 * 50000000) / 1024 /1024 MB,约为200MB
运行一个月需要占用的内存为6G,运行一年占用的内存为72G

30 * 200 = 6G
方式二:使用bitmap的方式
如果user_id访问网站,则在user_id的索引上设置为1,没有访问网站的user_id,其索引设置为0,此种方式运行一天占用的内存为

1 * 100000000 = 100000000 / 1014 /1024/ 8MB,约为12.5MB
运行一个月占用的内存为375MB,一年占用的内存容量为4.5G

由此可见,使用bitmap可以节省大量的内存资源

发布订阅

Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。
Redis 客户端可以订阅任意数量的频道。

以下实例演示了发布订阅是如何工作的。在我们实例中我们创建了订阅频道名为 redisChat:

redis 127.0.0.1:6379> SUBSCRIBE redisChat

Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "redisChat"
3) (integer) 1

现在,我们先重新开启个 redis 客户端,然后在同一个频道 redisChat 发布两次消息,订阅者就能接收到消息。

redis 127.0.0.1:6379> PUBLISH redisChat "Redis is a great caching technique"

(integer) 1

redis 127.0.0.1:6379> PUBLISH redisChat "Learn redis by runoob.com"

(integer) 1

# 订阅者的客户端会显示如下消息
1) "message"
2) "redisChat"
3) "Redis is a great caching technique"
1) "message"
2) "redisChat"
3) "Learn redis by runoob.com"
使用场景

1.这一功能最明显的用法就是用作实时消息系统,比如普通的即时聊天,群聊等功能。

事务

事务的ACID特性:

Redis 通过 [MULTI] [DISCARD] [EXEC][WATCH]实现事务。Redis事务提供了一种“将多个命令打包, 然后一次性、按顺序地执行”的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。(避免了关系型数据库中常见的脏读,不可重复读,幻读等问题)

redis> MULTI
OK

redis> SET book-name "Mastering C++ in 21 days"
QUEUED

redis> GET book-name
QUEUED

redis> SADD tag "C++" "Programming" "Mastering Series"
QUEUED

redis> SMEMBERS tag
QUEUED

redis> EXEC
1) OK
2) "Mastering C++ in 21 days"
3) (integer) 3
4) 1) "Mastering Series"
   2) "C++"
   3) "Programming"

一个事务从开始到执行会经历以下三个阶段:
1.开始事务。
2.命令入队。
3.执行事务。
下文将分别介绍事务的这三个阶段。

开始事务

MULTI命令的执行标记着事务的开始,这个命令唯一做的就是, 将客户端的 REDIS_MULTI 选项打开, 让客户端从非事务状态切换到事务状态。

命令入队

当客户端处于非事务状态下时, 所有发送给服务器端的命令都会立即被服务器执行。

但是, 当客户端进入事务状态之后, 服务器在收到来自客户端的命令时, 不会立即执行命令, 而是将这些命令全部放进一个事务队列里, 然后返回 QUEUED , 表示命令已入队


redis.png
执行事务

前面说到, 当客户端进入事务状态之后, 客户端发送的命令就会被放进事务队列里。

但其实并不是所有的命令都会被放进事务队列, 其中的例外就是 EXECDISCARDMULTIWATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时, 它们会像客户端处于非事务状态一样, 直接被服务器执行

redis2.png

事务状态下的 DISCARD 、 MULTI 和 WATCH 命令:
DISCARD命令用于取消一个事务, 它清空客户端的整个事务队列, 然后将客户端从事务状态调整回非事务状态, 最后返回字符串 OK 给客户端, 说明事务已被取消。
Redis 的事务是不可嵌套的, 当客户端已经处于事务状态, 而客户端又再向服务器发送 MULTI, 服务器只是简单地向客户端发送一个错误,命令的发送不会造成整个事务失败, 也不会修改事务队列中已有的数据。
WATCH只能在客户端进入事务状态之前执行, 在事务状态下发送 WATCH命令会引发一个错误, 但它不会造成整个事务失败, 也不会修改事务队列中已有的数据(和前面处理 MULTI) 的情况一样)。

事务中的错误

使用事务时可能会遇上以下两种错误:

入队错误
在命令入队的过程中,如果客户端向服务器发送了错误的命令,比如命令的参数数量不对,等等, 那么服务器将向客户端返回一个出错信息, 并且将客户端的事务状态设为 REDIS_DIRTY_EXEC
当客户端执行EXEC命令时, Redis 会拒绝执行状态为 REDIS_DIRTY_EXEC 的事务, 并返回失败信息。

redis 127.0.0.1:6379> MULTI
OK

redis 127.0.0.1:6379> set key
(error) ERR wrong number of arguments for 'set' command

redis 127.0.0.1:6379> EXISTS key
QUEUED

redis 127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

执行错误
如果命令在事务执行的过程中发生错误,比如说,对一个不同类型的 key 执行了错误的操作, 那么 Redis 只会将错误包含在事务的结果中, 这不会引起事务中断或整个失败,不会影响已执行事务命令的结果,也不会影响后面要执行的事务命令
Redis 进程被终结
如果 Redis 服务器进程在执行事务的过程中被其他进程终结,或者被管理员强制杀死,那么数据丢失情况取决于redis使用的持久化方式

为什么 Redis 不支持回滚?

如果你有使用关系式数据库的经验, 那么 “Redis 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。

带 WATCH 的事务

原子操作:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。

悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

WATCH 命令用于在事务开始之前监视任意数量的键: 当调用 EXEC命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。

redis> WATCH name
OK

redis> MULTI
OK

redis> SET name peter
QUEUED

redis> EXEC
(nil)
时间 客户端 A 客户端 B
T1 WATCH name
T2 MULTI
T3 SET name peter
T4 SET name john
T5 EXEC

在时间 T4 ,客户端 B 修改了 name 键的值, 当客户端 A 在 T5 执行 EXEC时,Redis 会发现 name 这个被监视的键已经被修改, 因此客户端 A 的事务不会被执行,而是直接返回失败。

使用场景

redis使用watch秒杀抢购思路
1.使用watch,采用乐观锁
2.不使用悲观锁,因为等待时间非常长,响应慢
3.不使用队列,因为并发量会让队列内存瞬间升高

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import redis.clients.jedis.Jedis;

/** redis测试抢购 * */
public class RedisTest {
    public static void main(String[] args) {
        final String watchkeys = "watchkeys";
        ExecutorService executor = Executors.newFixedThreadPool(20);

        final Jedis jedis = new Jedis("192.168.3.202", 6379);
        jedis.set(watchkeys, "0");// 重置watchkeys为0
        jedis.del("setsucc", "setfail");// 清空抢成功的,与没有成功的
        jedis.close();

        for (int i = 0; i < 10000; i++) {// 测试一万人同时访问
            executor.execute(new MyRunnable());
        }
        executor.shutdown();
    }
}


import java.util.List;
import java.util.UUID;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

public class MyRunnable implements Runnable {

    String watchkeys = "watchkeys";// 监视keys
    Jedis jedis = new Jedis("192.168.3.202", 6379);

    public MyRunnable() {
    }

    @Override
    public void run() {
        try {
            jedis.watch(watchkeys);// watchkeys

            String val = jedis.get(watchkeys);
            int valint = Integer.valueOf(val);
            String userifo = UUID.randomUUID().toString();
            if (valint < 10) {
                Transaction tx = jedis.multi();// 开启事务

                tx.incr("watchkeys");

                List<Object> list = tx.exec();// 提交事务,如果此时watchkeys被改动了,则返回null
                if (list != null) {
                    System.out.println("用户:" + userifo + "抢购成功,当前抢购成功人数:"
                            + (valint + 1));
                    /* 抢购成功业务逻辑 */
                    jedis.sadd("setsucc", userifo);
                } else {
                    System.out.println("用户:" + userifo + "抢购失败");
                    /* 抢购失败业务逻辑 */
                    jedis.sadd("setfail", userifo);
                }

            } else {
                System.out.println("用户:" + userifo + "抢购失败");
                jedis.sadd("setfail", userifo);
                // Thread.sleep(500);
                return;
            }

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        }

    }

}

Pipeline

Redis客户端执行一条命令分为以下四个步骤:

1.发送命令
2.命令排队
3.命令执行
4.返回结果


redis.png

其中,第一步+第四步称为Round Trip Time(RTT,往返时间)。

Redis提供了批量操作命令(例如mget,mset等),有效的节约RTT,但大部分命令是不支持批量操作的,而且Redis的客户端和服务端可能不是在不同的机器上。例如客户端在北京,Redis服务端在上海,两地直线距离为1300公里,那么1次RTT时间=1300×2/(300000×2/3)=13毫秒(光在真空中传输速度为每秒30万公里,这里假设光纤的速度为光速的2/3),那么客户端在1秒内大约只能执行80次左右的命令,这个和Redis的高并发高吞吐背道而驰。

Pipeline(流水线)机制能改善上面这类问题,它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令按照顺序执行并装填结果返回给客户端。

原生批量命令Multi与Pipeline对比

Redis持久化

Redis虽然是一种内存型数据库,一旦服务器进程退出,数据库的数据就会丢失,为了解决这个问题Redis提供了两种持久化的方案,将内存中的数据保存到磁盘中,避免数据的丢失。

RDB的配置
# 时间策略
save 900 1
save 300 10
save 60 10000

# 文件名称
dbfilename dump.rdb

# 文件保存路径
dir /home/work/app/redis/data/

# 这是当备份进程出错时,主进程就停止接受新的写入操作
stop-writes-on-bgsave-error yes

# 是否压缩,消耗CPU资源,硬盘不紧张的情况下不建议开启
rdbcompression yes

# 导入时是否检查
rdbchecksum yes

当然如果你想要禁用RDB配置,也是非常容易的,只需要在save的最后一行写上:save ""

针对RDB方式的持久化,手动触发可以使用:
save:会阻塞当前Redis服务器,直到持久化完成,线上应该禁止使用
bgsave:该触发方式会fork一个子进程,由子进程负责持久化过程,因此阻塞只会发生在fork子进程的时候

由于 save 基本不会被使用到,我们重点看看 bgsave 这个命令是如何完成RDB的持久化的。

bgsave.jpg
这里注意的是 fork操作会阻塞,导致Redis读写性能下降。我们可以控制单个Redis实例的最大内存,来尽可能降低Redis在fork时的事件消耗。以及上面提到的自动触发的频率减少fork次数,或者使用手动触发,根据自己的机制来完成持久化。
AOF的配置
# 是否开启aof
appendonly yes

# 文件名称
appendfilename "appendonly.aof"

# 同步方式
appendfsync everysec

# aof重写期间是否同步
no-appendfsync-on-rewrite no

# 重写触发配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 加载aof时如果有错如何处理
aof-load-truncated yes

# 文件重写策略
aof-rewrite-incremental-fsync yes

appendfsync everysec 它其实有三种模式:

always:把每个写命令都立即同步到aof,很慢,但是很安全
everysec:每秒同步一次,是折中方案
no:redis不处理交给OS来处理,非常快,但是也最不安全
一般情况下都采用 everysec 配置,这样可以兼顾速度与安全,最多损失1s的数据。

aof-load-truncated yes:如果该配置启用,在加载时发现aof尾部不正确是,会向客户端写入一个log,但是会继续执行,如果设置为 no ,发现错误就会停止,必须修复后才能重新加载。

AOF的整个流程大体来看可以分为两步
1.命令的实时写入,命令写入=》追加到aof_buf =》同步到aof磁盘(如果是 appendfsync everysec 配置,会有1s损耗),
2.对aof文件的重写,减少aof文件的大小,可以手动或者自动触发,关于自动触发的规则请看上面配置部分。fork的操作也是发生在重写这一步,也是这里会对主进程产生阻塞。

手动触发: bgrewriteaof

下面来看看重写的一个流程图:


rewrite.png

对于上图有四个关键点补充一下:

1.在重写期间,由于主进程依然在响应命令,为了保证最终备份的完整性;因此它依然会写入旧的AOF file中,如果重写失败,能够保证数据不丢失。
2.为了把重写期间响应的写入信息也写入到新的文件中,因此也会为子进程保留一个buf,防止新写的file丢失数据。
3.重写是直接把当前内存的数据生成对应命令,并不需要读取老的AOF文件进行分析、命令合并。
4.AOF文件直接采用的文本协议,主要是兼容性好、追加方便、可读性高可认为修改修复。

从持久化中恢复数据

数据的备份、持久化做完了,我们如何从这些持久化文件中恢复数据呢?如果一台服务器上有既有RDB文件,又有AOF文件,该加载谁呢?

其实想要从这些文件中恢复数据,只需要重新启动Redis即可。我们还是通过图来了解这个流程:


init.png

启动时会先检查AOF文件是否存在,如果不存在就尝试加载RDB。那么为什么会优先加载AOF呢?因为AOF保存的数据更完整,通过上面的分析我们知道AOF基本上最多损失1s的数据。

对比
性能与实践

通过上面的分析,我们都知道RDB的快照、AOF的重写都需要fork,这是一个重量级操作,会对Redis造成阻塞。因此为了不影响Redis主进程响应,我们需要尽可能降低阻塞。

1.降低fork的频率,比如可以手动来触发RDB生成快照、与AOF重写;
2.控制Redis最大使用内存,防止fork耗时过长;
3.使用更牛逼的硬件;

在线上我们到底该怎么做?我提供一些自己的实践经验。

1.如果Redis中的数据并不是特别敏感或者可以通过其它方式重写生成数据,可以关闭持久化,如果丢失数据可以通过其它途径补回;
2.自己制定策略定期检查Redis的情况,然后可以手动触发备份、重写数据;
3.单机如果部署多个实例,要防止多个机器同时运行持久化、重写操作,防止出现内存、CPU、IO资源竞争,让持久化变为串行;
4.可以加入主从机器,利用一台从机器进行备份处理,其它机器正常响应客户端的命令;
5.RDB持久化与AOF持久化可以同时存在,配合使用。

Redis使用场景总结

缓存

作为缓存使用时,根据保存数据的时间,一般有两种方式:
1.读取前,先去读Redis,如果没有数据,读取数据库,将数据拉入Redis。
2.插入数据时,同时写入Redis。
方案一:实施起来简单,但是有两个需要注意的地方:
1.避免缓存击穿。(数据库没有就需要命中的数据,导致Redis一直没有数据,而一直命中数据库。)
2.数据的实时性相对会差一点。

方案二:数据实时性强,但是开发时不便于统一处理,适合改动比较少的数据

当然,两种方式根据实际情况来适用。如:方案一适用于对于数据实时性要求不是特别高的场景。方案二适用于字典表、数据量不大的数据存储。

tip

缓存穿透
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
解决方案
有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
缓存雪崩
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
解决方案
我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
缓存击穿
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
解决方案
使用互斥锁(参考下部分内容)

丰富的数据格式性能更高,应用场景丰富

单线程可以作为分布式锁

分布式环境下,数据一致性问题一直是一个比较重要的话题。分布式与单机情况下最大的不同在于其不是多线程而是多进程。多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而分布式下进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方

常见的是秒杀场景,订单服务部署了多个实例。如秒杀商品有4个,第一个用户购买3个,第二个用户购买2个,理想状态下第一个用户能购买成功,第二个用户提示购买失败,反之亦可。而实际可能出现的情况是,两个用户都得到库存为4,第一个用户买到了3个,更新库存之前,第二个用户下了2个商品的订单,更新库存为2,导致出错。

在上面的场景中,商品的库存是共享变量,面对高并发情形,需要保证对资源的访问互斥。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。分布式系统中,由于分布式系统的分布性,即多线程和多进程并且分布在不同机器中,synchronized和lock这两种锁将失去原有锁的效果,需要我们自己实现分布式锁。

常见的锁方案如下:

基于数据库

基于数据库表增删是最简单的方式,首先创建一张锁的表主要包含下列字段:方法名(唯一约束),时间戳等字段。

当需要锁住某个方法时,往该表中插入一条相关的记录。如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

执行完毕,需要delete该记录。

基于redis的分布式锁实现

使用redis的SETNX实现分布式锁
SETNX是将 key 的值设为 value,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。

存在死锁的问题
某个线程获取了锁之后,断开了与Redis 的连接,锁没有及时释放,竞争该锁的其他线程都会hung,产生死锁的情况。
解决办法
设置3min的超时,防止redis断连或del操作失败的时候,造成死锁。

public String get(key) {
    String value = redis.get(key);
    if (value == null) { //代表缓存值过期
        //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
        if(redis.setnx(key_mutex, 1, 3 * 60) == 1) {  //代表设置成功
            value = db.get(key);
            redis.set(key, value, expire_secs);
            redis.del(key_mutex);
        } else {  //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
            sleep(50);
            get(key);  //重试
        }
    } else {
        return value;
    }
}

开发环境中分布式锁也非常实用,举个例子,智能推荐系统中,活动日推定时器每分钟会检测需要日推的活动,但开发环境多个启动的服务类似分布式系统,会导致重复日推,可以使用基于redis的分布式锁解决这个问题。

自动过期能有效提升开发效率

Redis针对数据都可以设置过期时间,这个特点也是大家应用比较多的,过期的数据清理无需使用方去关注,所以开发效率也比较高,当然,性能也比较高。最常见的就是:短信验证码、具有时间性的商品展示等。

分布式和持久化有效应对海量数据和高并发

Redis初期的版本官方只是支持单机或者简单的主从,但是随着应用越来越广泛,用户关于分布式的呼声越来越高,所以Redis 3.0版本时候官方加入了分布式的支持,主要是两个方面:

上一篇下一篇

猜你喜欢

热点阅读