什么是Redis
redis是一个单线程、高性能的key-value存储系统
Redis的特点和优势
- 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。
- Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用。
- Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,sorted set,hash等数据结构的存储。
- 原子 – Redis的所有操作都是原子性的,同时Redis还支持对几个操作全并后的原子性执行。
- 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性
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_keys和ready_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特性:
- 原子性(Atomic):事务中各项操作,要么全做要么全不做,任何一项操作的失败都会导致整个事务的失败;
- 一致性(Consistent):事务结束后系统状态是一致的;
- 隔离性(Isolated):并发执行的事务彼此无法看到对方的中间状态;
- 持久性(Durable):事务完成后所做的改动都会被持久化,即使发生灾难性的失败。通过日志和同步备份可以在故障发生后重建数据。
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
执行事务
前面说到, 当客户端进入事务状态之后, 客户端发送的命令就会被放进事务队列里。
但其实并不是所有的命令都会被放进事务队列, 其中的例外就是 EXEC 、 DISCARD 、 MULTI 和 WATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时, 它们会像客户端处于非事务状态一样, 直接被服务器执行
事务状态下的 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 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。
- 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服务端支持实现的,而Pipeline需要服务端与客户端的共同实现。
- Multi方式会将命令逐条发给redis服务端,Pipeline方式,可以将一系列命令打包发给redis服务端,性能更高。
Redis持久化
Redis虽然是一种内存型数据库,一旦服务器进程退出,数据库的数据就会丢失,为了解决这个问题Redis提供了两种持久化的方案,将内存中的数据保存到磁盘中,避免数据的丢失。
- RDB:在指定的时间间隔能对你的数据进行快照存储。
- AOF:记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据。
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
- save 900 1 表示900s内如果有1条是写入命令,就触发产生一次快照,可以理解为就进行一次备份
- save 300 10 表示300s内有10条写入,就产生快照
当然如果你想要禁用RDB配置,也是非常容易的,只需要在save的最后一行写上:save ""
针对RDB方式的持久化,手动触发可以使用:
save:会阻塞当前Redis服务器,直到持久化完成,线上应该禁止使用。
bgsave:该触发方式会fork一个子进程,由子进程负责持久化过程,因此阻塞只会发生在fork子进程的时候。
由于 save 基本不会被使用到,我们重点看看 bgsave 这个命令是如何完成RDB的持久化的。
这里注意的是
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的数据。
对比
- AOF更安全,可将数据及时同步到文件中,但需要较多的磁盘IO,AOF文件尺寸较大,文件内容恢复相对较慢, 也更完整。
- RDB持久化,安全性较差,它是正常时期数据备份及 master-slave数据同步的最佳手段,文件尺寸较小,恢复数度较快。
性能与实践
通过上面的分析,我们都知道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。
解决方案
使用互斥锁(参考下部分内容)
丰富的数据格式性能更高,应用场景丰富
- string——适合最简单的k-v存储,类似于memcached的存储结构,短信验证码,配置信息等,就用这种类型来存储。
- hash——一般key为ID或者唯一标示,value对应的就是详情了。如商品详情,个人信息详情,新闻详情等。
- list——因为list是有序的,比较适合存储一些有序且数据相对固定的数据。如省市区表、字典表等。因为list是有序的,适合根据写入的时间来排序,如:最新的***,消息队列等。
- set——可以简单的理解为ID-List的模式,如微博中一个人有哪些好友,set最牛的地方在于,可以对两个set提供交集、并集、差集操作。例如:查找两个人共同的好友等。
- Sorted Set——是set的增强版本,增加了一个score参数,自动会根据score的值进行排序。比较适合类似于top 10等不根据插入的时间来排序的数据。
单线程可以作为分布式锁
分布式环境下,数据一致性问题一直是一个比较重要的话题。分布式与单机情况下最大的不同在于其不是多线程而是多进程。多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而分布式下进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
常见的是秒杀场景,订单服务部署了多个实例。如秒杀商品有4个,第一个用户购买3个,第二个用户购买2个,理想状态下第一个用户能购买成功,第二个用户提示购买失败,反之亦可。而实际可能出现的情况是,两个用户都得到库存为4,第一个用户买到了3个,更新库存之前,第二个用户下了2个商品的订单,更新库存为2,导致出错。
在上面的场景中,商品的库存是共享变量,面对高并发情形,需要保证对资源的访问互斥。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。分布式系统中,由于分布式系统的分布性,即多线程和多进程并且分布在不同机器中,synchronized和lock这两种锁将失去原有锁的效果,需要我们自己实现分布式锁。
常见的锁方案如下:
- 基于数据库实现分布式锁
- 基于缓存,实现分布式锁,如redis
基于数据库
基于数据库表增删是最简单的方式,首先创建一张锁的表主要包含下列字段:方法名(唯一约束),时间戳等字段。
当需要锁住某个方法时,往该表中插入一条相关的记录。如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
执行完毕,需要delete该记录。
基于redis的分布式锁实现
使用redis的SETNX实现分布式锁
SETNX是将 key 的值设为 value,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。
- 返回1,说明该进程获得锁。
- 返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 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版本时候官方加入了分布式的支持,主要是两个方面:
- Redis服务器主从热备,确保系统稳定性
- Redis分片应对海量数据和高并发
网友评论