基础数据结构
Redis 可以存储 键 和 不同类型数据结构值 之间的映射关系。键的类型只能是字符串,而值除了支持最 基础的五种数据类型 外,还支持一些 高级数据类型:
Jedis代码示例
@Test
public void testStart() throws InterruptedException {
Jedis jedis = new Jedis("localhost",6379);
System.out.println("##########Redis数据结构操作###########");
System.out.println("\n####字符串(String)#####");
jedis.set("name", "alice");
System.out.println("getValue: " + jedis.get("name"));
System.out.println("批量Set字符串");
jedis.mset(new String[]{"name1", "Jck", "name2", "bob"});
System.out.println("getMultiValue: " + jedis.mget(new String[]{"name1", "name2"}));
System.out.println("一个命令设置NX+过期时间");
jedis.set("expiredKey", "100", SetParams.setParams().nx().ex(1));
System.out.println("getExpiredKey: " + jedis.get("expiredKey"));
Thread.sleep(1000);
System.out.println("getExpiredKey: " + jedis.get("expiredKey"));
System.out.println("计数器命令");
jedis.set("age", "20");
jedis.incr("age");
jedis.incrBy("age", 5);
System.out.println("getAge: " + jedis.get("age"));
System.out.println("\n####list(列表)####");
jedis.rpush("books", "python", "java", "golang");
System.out.println("lpop: " + jedis.lpop("books"));
System.out.println("rpop: " + jedis.rpop("books"));
System.out.println("listLength: " + jedis.llen("books"));
System.out.println("\n####hash(字典)####");
jedis.hset("fantasticBooks", "java", "effective");
jedis.hset("fantasticBooks", "redis", "redis in action");
Map<String, String> map = Maps.newHashMap("spring", "spring in action");
map.put("special", "100");
jedis.hmset("fantasticBooks", map);
jedis.hincrBy("fantasticBooks", "special", 1);
System.out.println("hgetAll: " + jedis.hgetAll("fantasticBooks"));
System.out.println("hgetValue: " + jedis.hget("fantasticBooks", "java"));
System.out.println("\n####set(集合,去重)####");
jedis.sadd("hashset", "a");
jedis.sadd("hashset", "b");
jedis.sadd("hashset", "c", "d");
System.out.println("getSetValue: " + jedis.smembers("hashset"));
System.out.println("\n####zset(有序列表)####");
jedis.zadd("sortedSet", 9.0, "think in java");
jedis.zadd("sortedSet", 8.0, "think in concurrency");
System.out.println("zrange有序输出: " + jedis.zrange("sortedSet", 0, -1));
System.out.println("zscore获取分数: " + jedis.zscore("sortedSet", "think in java"));
jedis.close();
System.out.println("\n\n关闭Redis连接");
}
Redis数据结构实现
首先在 Redis 内部会使用一个 RedisObject 对象来表示所有的 key
和 value
:
其次 Redis 为了 平衡空间和时间效率,针对 value
的具体类型在底层会采用不同的数据结构来实现,下图展示了他们之间的映射关系:
常见使用场景
- 使用set结构用来存储活动中奖的用户ID,登录用户ID, 计算UV
- 分布式锁,setnx+expired time
- 记录帖子的点赞数,评论数,点击数, hash
- 微博热榜排名,zset
- 缓存数据库主键ID
- 缓存用户行为历史,过滤恶意行为(zset,hash)
- 业务数量自增场景
-
Redis为什么高性能
纯内存操作:读取不需要进行磁盘 I/O,所以比传统数据库要快上不少;(但不要有误区说磁盘就一定慢,例如 Kafka 就是使用磁盘顺序读取但仍然较快)
- 单线程,无锁竞争:这保证了没有线程的上下文切换,不会因为多线程的一些操作而降低性能;
- 多路 I/O 复用模型,非阻塞 I/O:采用多路 I/O 复用技术可以让单个线程高效的处理多个网络连接请求(尽量减少网络 IO 的时间消耗);
- 高效的数据结构,加上底层做了大量优化:Redis 对于底层的数据结构和内存占用做了大量的优化,例如不同长度的字符串使用不同的结构体表示,HyperLogLog 的密集型存储结构等等..
使用Redis带来的一些问题
缓存雪崩
另外对于 “Redis 挂掉了,请求全部走数据库” 这样的情况,我们还可以有如下的思路:
- 事发前:实现 Redis 的高可用(主从架构 + Sentinel 或者 Redis Cluster),尽量避免 Redis 挂掉这种情况发生。
- 事发中:万一 Redis 真的挂了,我们可以设置本地缓存(ehcache) + 限流(hystrix),尽量避免我们的数据库被干掉(起码能保证我们的服务还是能正常工作的)
- 事发后:Redis 持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。
缓存穿透
另外,还有作入参检查,拦截无效参数缓存击穿
这种情况一般是:cache里有该key,但是过期了。
与缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿不同的是缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。
缓存和数据库双写一致性问题
先更新DB再删除缓存或删除缓存+更新DB+删除缓存(双删)
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存?
原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。
比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
另外更新缓存的代价有时候是很高的。是不是说,每次修改数据库的时候,都一定要将其对应的缓存更新一份?也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会被频繁访问到?
举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。
其实删除缓存,而不是更新缓存,就是一个 lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。像 mybatis,hibernate,都有懒加载思想。查询一个部门,部门带了一个员工的 list,没有必要说每次查询部门,都把里面的 1000 个员工的数据也同时查出来啊。80% 的情况,查这个部门,就只是要访问这个部门的信息就可以了。先查部门,同时要访问里面的员工,那么这个时候只有在你要访问里面的员工的时候,才会去数据库里面查询 1000 个员工。
详细见: 这里
消息队列
Redis持久化
快照(RDB)
Redis 快照 是最简单的 Redis 持久性模式。当满足特定条件时,它将生成数据集的时间点快照,例如,如果先前的快照是在 2 分钟前创建的,并且现在已经至少有 100
次新写入,则将创建一个新的快照。此条件可以由用户配置 Redis 实例来控制,也可以在运行时修改而无需重新启动服务器。快照作为包含整个数据集的单个 .rdb
文件生成。
AOF(Append Only File)
快照不是很持久。如果运行 Redis 的计算机停止运行,电源线出现故障或者您 kill -9
的实例意外发生,则写入 Redis 的最新数据将丢失。尽管这对于某些应用程序可能不是什么大问题,但有些使用案例具有充分的耐用性,在这些情况下,快照并不是可行的选择。
AOF(Append Only File - 仅追加文件) 它的工作方式非常简单:每次执行 修改内存 中数据集的写操作时,都会 记录 该操作。假设 AOF 日志记录了自 Redis 实例创建以来 所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例 顺序执行所有的指令,也就是 「重放」,来恢复 Redis 当前实例的内存数据结构的状态。
混合持久化
重启 Redis 时,我们很少使用 rdb
来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb
来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb
文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是 自持久化开始到持久化结束 的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。
于是在 Redis 重启的时候,可以先加载 rdb
的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。
两种方式对比
RDB | 优点
- 只有一个文件
dump.rdb
,方便持久化。 - 容灾性好,一个文件可以保存到安全的磁盘。
- 性能最大化,
fork
子进程来完成写操作,让主进程继续处理命令,所以使 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能 -
RDB | 缺点
数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候;
AOF | 优点
数据安全,aof 持久化可以配置
appendfsync
属性,有always
,每进行一次命令操作就记录到 aof 文件中一次。- 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall)
AOF | 缺点
AOF 文件比 RDB 文件大,且 恢复速度慢。
- 数据集大 的时候,比 rdb 启动效率低。
集群
主从同步
主从复制,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。且数据的复制是 单向 的,只能由主节点到从节点。Redis 主从复制支持 主从同步 和 从从同步 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。主从复制主要的作用
- 数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 (实际上是一种服务的冗余)。
- 负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 (即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的 基础,因此说主从复制是 Redis 高可用的基础。
实现原理
哨兵(Sentinel)
上图 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点:哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据;
- 数据节点: 主节点和从节点都是数据节点;
在复制的基础上,哨兵实现了 自动化的故障恢复 功能,下方是官方对于哨兵功能的描述:
- 监控(Monitoring): 哨兵会不断地检查主节点和从节点是否运作正常。
- 自动故障转移(Automatic failover): 当 主节点 不能正常工作时,哨兵会开始 自动故障转移操作,它会将失效主节点的其中一个 从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
- 配置提供者(Configuration provider): 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
- 通知(Notification): 哨兵可以将故障转移的结果发送给客户端。
其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。
Jedis实现分布式锁
分布式锁需要考虑的问题
需要考虑锁超时
解决方案,用原子命令执行
set + nx + expired jedis.set(“expiredKey”, “100”, SetParams.setParams().nx().ex(1));
但是另一个问题随即而来:如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。
解决方案:
- 业务规避,执行较短业务
- 用hset 每个线程加锁自己的Key
- 用Redisson 客户端,针对每个Key作监听续期
分布式锁实现要点
为了实现分布式锁,需要确保锁同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁
- 不会发送死锁。即使一个客户端持有锁的期间崩溃而没有主动释放锁,也需要保证后续其他客户端能够加锁成功
- 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给释放了。
- 容错性。只要大部分的Redis节点正常运行,客户端就可以进行加锁和解锁操作。
加锁操作的正确姿势
- 使用setnx命令保证互斥性
- 需要设置锁的过期时间,避免死锁
- setnx和设置过期时间需要保持原子性,避免在设置setnx成功之后在设置过期时间客户端崩溃导致死锁
加锁的Value 值为一个唯一标示。可以采用UUID作为唯一标示。加锁成功后需要把唯一标示返回给客户端来用来客户端进行解锁操作
解锁的正确姿势
需要拿加锁成功的唯一标示要进行解锁,从而保证加锁和解锁的是同一个客户端
- 解锁操作需要比较唯一标示是否相等,相等再执行删除操作。这2个操作可以采用Lua脚本方式使2个命令的原子性。
单机Redis分布式锁实现
static int n = 500;
// 秒杀
public static void killInSecond() {
System.out.println("Left N = " + --n);
}
@Test
public void testRedisLock() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
Runnable runnable = () -> {
SimpleRedisDistributedLock lock = null;
String unLockIdentify = null;
try {
Jedis conn = new Jedis("127.0.0.1",6379);
lock = new SimpleRedisDistributedLock(conn, "testLock");
unLockIdentify = lock.acquire();
System.out.println(Thread.currentThread().getName() + "正在运行,identifier:" + unLockIdentify);
killInSecond();
} finally {
if (lock != null) {
lock.release(unLockIdentify);
}
latch.countDown();
}
};
for (int i = 0; i < 10; i++) {
Thread t = new Thread(runnable);
t.start();
}
System.out.println("main wait");
latch.await();
System.out.println("main end");
}
private static class SimpleRedisDistributedLock {
// 默认的获取锁超时时间
private static final int DEFAULT_ACQUIRE_TIME = 1000;
private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private Jedis jedis;
private String lockKey;
private long acquireTimeout = DEFAULT_ACQUIRE_TIME;
private long expireTime = 2000;
public SimpleRedisDistributedLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = lockKey;
}
public String acquire() {
try {
// 获取锁的超时时间,超过这个时间则放弃获取锁
long end = System.currentTimeMillis() + acquireTimeout;
// 随机生成一个value
String requireToken = UUID.randomUUID().toString();
while (System.currentTimeMillis() < end) {
String result = jedis.set(lockKey, requireToken,
SetParams.setParams().nx().px(expireTime));
if (LOCK_SUCCESS.equals(result)) {
return requireToken;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public boolean release(String identify) {
if (identify == null) {
return false;
}
String script
= "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result;
try {
result = jedis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(identify));
if (RELEASE_SUCCESS.equals(result)) {
System.out.println("release lock success, requestToken:" + identify);
return true;
}
} catch (Exception e) {
System.out.println("release lock due to error" + e);
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
}
执行日志:
main wait
Thread-6正在运行,identifier:06e386da-b95f-49a6-976a-ad2773be4fe6
Left N = 499
release lock success, requestToken:06e386da-b95f-49a6-976a-ad2773be4fe6
Thread-8正在运行,identifier:7cf1d743-de89-4dcc-903e-eb70032cb428
Left N = 498
release lock success, requestToken:7cf1d743-de89-4dcc-903e-eb70032cb428
Thread-1正在运行,identifier:8e6152b1-bef5-4134-af59-cbe66d840381
Left N = 497
release lock success, requestToken:8e6152b1-bef5-4134-af59-cbe66d840381
Thread-4正在运行,identifier:2b49d6a6-3e3d-4071-9988-fbb6fb62d1ae
Left N = 496
release lock success, requestToken:2b49d6a6-3e3d-4071-9988-fbb6fb62d1ae
Thread-0正在运行,identifier:a70d1990-c8a2-453c-b213-fc80f757b916
Left N = 495
release lock success, requestToken:a70d1990-c8a2-453c-b213-fc80f757b916
Thread-5正在运行,identifier:532cb4bd-16ac-493c-9672-048c53221078
Left N = 494
release lock success, requestToken:532cb4bd-16ac-493c-9672-048c53221078
Thread-2正在运行,identifier:c102ba41-3bef-4caf-a71b-3b31f8666075
Left N = 493
release lock success, requestToken:c102ba41-3bef-4caf-a71b-3b31f8666075
Thread-9正在运行,identifier:a97c1f55-a4d2-4d32-a024-9fa61d08899f
Left N = 492
release lock success, requestToken:a97c1f55-a4d2-4d32-a024-9fa61d08899f
Thread-3正在运行,identifier:e5def05d-96c8-490f-9fcf-1018bffcbaa7
Left N = 491
release lock success, requestToken:e5def05d-96c8-490f-9fcf-1018bffcbaa7
Thread-7正在运行,identifier:d4751241-e388-481c-932d-d7ea24f8825b
Left N = 490
release lock success, requestToken:d4751241-e388-481c-932d-d7ea24f8825b
main end