【Java劝退师】Redis 知识脑图 - 分布式缓存

1,378 阅读11分钟

Redis

Redis

分布式缓存

一、使用场景

1. 数据库缓存

缓存 - 原始数据的复制级,用于快速访问

将访问过的内容进行缓存,再次访问先找缓存,缓存命中返回数据,不命中找数据库,回填缓存

2. 提高系统响应

Redis数据是放在内存中,数据库数据大多放在硬盘中,内存的访问速度远大于硬盘,相较于数据库可以瞬间处理大量读/写请求

3. 做 Session 分离

当架构使用 Tomcat 集群,并且使用 Tomcat 做 Session 复制,将产生 (1) 复制时性能损耗 (2) 无法保证实时同步 问题,因此可以采取将 Session 统一放在 Redis 中,这样多个 Tomcat 就可以共享 Session 消息

4. 乐观锁

使用 Redis 的 watch + incr 实现

jedis1.watch(redisKey);
String redisValue = jedis1.get(redisKey);
int valInteger = Integer.valueOf(redisValue);
String userInfo = UUID.randomUUID().toString();

// 没有秒完
if (valInteger < 20) {
    Transaction tx = jedis1.multi();
    tx.incr(redisKey);
    List list = tx.exec();
    // 秒成功 失败返回空list而不是空
    if (list != null && list.size() > 0) {
    	System.out.println("用户:" + userInfo + ",秒杀成功!当前成功人数:" + (valInteger + 1));
    }
    // 版本变化,被别人抢了
    else {
    	System.out.println("用户:" + userInfo + ",秒杀失败");
    }
}
// 秒完了
else {
	System.out.println("已经有20人秒杀成功,秒杀结束");
}

5. 分布式锁 (悲观锁)

若需要控制多个进程( JVM ) 并发的时序性(串型化),可以采用 Redis 的 setnx 实现

当 value 不存在时则赋值,属于原子操作

127.0.0.1:6379> setnx name zhangf # 如果name不存在赋值
(integer) 1
127.0.0.1:6379> setnx name zhaoyun # 再次赋值失败
(integer) 0
127.0.0.1:6379> get name
"zhangf"
127.0.0.1:6379> set age 18 NX PX 10000 # 如果不存在赋值 有效期10秒
OK
127.0.0.1:6379> set age 20 NX # 赋值失败
(nil)
127.0.0.1:6379> get age # age失效
(nil)
127.0.0.1:6379> set age 30 NX PX 10000 # 赋值成功
OK
127.0.0.1:6379> get age
"30"

自己实现分布式锁

/**
* 使用redis的set命令实现获取分布式锁
* @param lockKey 可以就是锁
* @param requestId 请求ID,保证同一性 uuid+threadID
* @param expireTime 过期时间,避免死锁
* @return
*/
public boolean getLock(String lockKey,String requestId,int expireTime) {
    // NX: 保证互斥性
    // hset 原子性操作 只要lockKey有效 则说明有进程在使用分布式锁
    String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
    if("OK".equals(result)) {
    	return true;
    } 
    return false;
}
public static boolean releaseLock(String lockKey, String requestId) {
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Object result = jedis.eval(script, Collections.singletonList(lockKey),Collections.singletonList(requestId));
    if (result.equals(1L)) {
        return true;
    } 
    return false;
}

使用 Redission 实现分布式锁

public class DistributedRedisLock {
    
    //从配置类中获取redisson对象
    private static Redisson redisson = RedissonManager.getRedisson();
    private static final String LOCK_TITLE = "redisLock_";
    
    //加锁
    public static boolean acquire(String lockName){
        //声明key对象
        String key = LOCK_TITLE + lockName;
        //获取锁对象
        RLock mylock = redisson.getLock(key);
        //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId
        mylock.lock(2,3,TimeUtil.SECOND);
        //加锁成功
        return true;
    }
    
    //锁的释放
    public static void release(String lockName){
        //必须是和加锁时的同一个key
        String key = LOCK_TITLE + lockName;
        //获取所对象
        RLock mylock = redisson.getLock(key);
        //释放锁(解锁)
        mylock.unlock();
    }
    
}

6. MyBatis 的二级缓存

二、锁的解释

  1. 悲观锁 - 我认为会出问题,所以先上锁 - 性能差

    • synchronized

    • 数据库中的行锁、表锁

  2. 乐观锁 - 谁都可以来,但是我成功了,你就成功不了 - 性能高

    • 秒杀场景

三、缓存的读写模式

1. Cache Aside Pattern - Redis

  • 读: 先读缓存,缓存没有读数据库,取出数据放数缓存,并返回响应

  • 更: 先更新数据库,再删除缓存

不更新缓存的原因 :

  • 缓存如果是一个复杂结构(hash、list),会需要遍历,增加复杂度

  • 只更新不删除有机率出现脏读情况

2. Read/Write Through Pattern - Guava Cache

应用进程只操作缓存,缓存操作数据库

3. Write Behind Caching Pattern - EVCache

应用进程只更新缓存,缓存"异步"批量更新到数据库 - 不能实时同步,甚至可能会丢数据

四、 数据类型

1. String 字符串

  1. 字符串

  2. 数字

  3. 浮点数

  4. 乐观锁 - watch + incr

  5. 分布式锁 - setnx

2. List 列表

存储有序、可重复元素,获取头部、尾部纪录极快

  1. 栈、队列
  2. 用户列表、商品列表、评论列表

3. Set 集合

无序、唯一元素

  1. 关注的用户
  2. 随机抽奖 ( spop命令 )

4. SortedSet 有序集合

元素唯一,可按分数排序

底层实现: 跳跃表

  1. 点击排行榜
  2. 销量排行榜
  3. 关注排行榜

5. Hash 散列表

String 类型的 field、value 映射表

  1. 对象
  2. 表数据映射

6. Bitmap 位图

value 只能是 0 或 1

  1. 用户签到
  2. 统计活跃用户
  3. 查找用户在线状态

7. Geo 地理位置

使用Z阶曲线、Base32编码、GeoHash算法,保存经纬度

  1. 纪录地理位置
  2. 计算距离
  3. 附近的人

8. Stream 数据流

持久化消息队列

五、过期、淘汰策略

1. maxmemory

  1. 【默认】禁止驱逐 - 可作为DB使用 - 数据太多可能导致崩溃
  2. 【推荐】设置为物理内存的 3/4

如果设置了 maxmemory 则 maxmemory-policy 要配置

2. maxmemory-policy (删除策略)

  1. 定时删除

    • 使用定时器删除过期时间的 Key - 不推荐

  2. 惰性删除

    • 访问 Key 发现已过期,则删除

  3. 主动删除 - 随机挑选键值对,使用遍历太耗时

    • no-enviction - 不删除 (默认)

    • allkeys-lru - 使用时间最远 (看使用的时间) - 通常采用

    • volatile-lru - 从已设置过期时间的数据,挑选使用时间最远

    • allkeys-lfu - 最近最少使用 (看使用次数)

    • volatile-lfu - 从已设置过期时间的数据,挑选最近最少使用

    • allkeys-random - 随机 - 希望请求压力平均分布时采用

    • volatile-random - 从已设置过期时间的数据,随机挑选

    • volatile-ttl - 挑选 TTL 值最小

Redis 默认采用 惰性删除 + 主动删除

3. expire (TTL)

数据存活时间

六、持久化

目的是为了快速恢复数据而非存储数据

AOF 记录过程,RDB只管结果

1. RDB 快照 (默认)

流程 : 父进程 fork 子进程 (此时父进程阻塞),子进程创建 RDB 文档,根据父进程内存生成快照文档,并对原有文档进行原子替换

优点:

  • 使用二进制压缩,空间小,方便传输

缺点:

  • 无法保证数据完整,将丢失快照以后的所有数据

  • fork 子进程过程阻塞,若数据太大,将导致短时间无法响应请求 (如需避免须关闭RDB,启用AOF)

2. AOF 操作日志

将运行的命令记录到 AOF 文档中

优点:

  • 数据安全,不丢失数据

缺点:

  • 性能低

保存模式 (硬盘)

  1. AOF_FSYNC_NO : 不保存

    只有当 (1) Redis 关闭 (2) AOF 功能关闭 (3) 操作系统写缓存刷新 才会将AOF存到硬盘

  2. AOF_FSYNC_EVERYSEC : 每秒保存一次【默认】

    最多丢失2秒钟数据

  3. AOF_FSYNC_ALWAYS : 一条命令保存一次 (不推荐)

    最多丢失一条命令数据

重写

将 AOF 文件内命令进行删除与合并,对文件进行瘦身,且整个过程绝对安全

七、Redis 弱事务

开启事务后

(1) 语法错误 - 命令队列清除

(2) 运行错误[类型错误..等] - 正确的命令 运行,不回滚 【性能考量】

1. Redis 的 ACID

  • Atomicity (原子性) : 一个队列中的命令,要么运行,要么不运行

  • Consistency (一致性): 事务运行前、后状态必须是一致的。Redis 是 AP 模型,集群中不能保证实时一致,只能保证最终一致

  • Isolation (隔离性) : 命令是顺序运行,但在一个事务中,有可能运行其他客户端的命令

  • Durability (持久性) : 有持久化,但不保证数据完整性

2. 事务命令

当被监视的字段被其他客户端更动之后,监视后开启的命令队列将被清空

  1. watch : 监视key (客户端内共享)
  2. multi : 开启事务,后续命令将放入命令队列中
  3. exec : 运行命令队列
  4. discard : 清除命令队列
  5. unwatch : 清除监视key (客户端内共享)
127.0.0.1:6379> watch s1 # 监视key(客户端内共享) 当被监视的字段被其他客户端更动之后,监视后开启的命令队列将被清空
OK
127.0.0.1:6379> multi # 开启事务,后续命令将放入命令队列中
OK
127.0.0.1:6379> set s1 555 # 此时在没有exec之前,通过另一个命令窗口对监控的s1字段进行修改
QUEUED
127.0.0.1:6379> exec # 运行命令
(nil)
127.0.0.1:6379> get s1 # 因为命令队列被清空,导致命令运行失败,此处只能查找到其他客户端修改后的结果
222
127.0.0.1:6379> unwatch # 清除监视key
OK

3. Lua 脚本

具备强原子性,脚本运行过程,不允许插入新的命令,故运行时间应尽量短

八、优化

  1. 避免大key ( value > 100K ) → 拆为小 key
  2. 避免使用 key*、hgetall ... 等全量操作
  3. RDB改为AOF,甚至关闭
  4. 添加多条数据时使用管道 pipeline
  5. 使用 Hash 存储
  6. 限制内存大小,避免出现 swap 或 OOM 错误

九、高可用

1. 主从复制 (读写分离)

  1. 主可写,从不可写

  2. 主挂了,从不能为主

  3. 从服务器使用 replicaof 命令开启

2. 哨兵

当主服务器下线,Sentinel 将从服务器升级为主服务器

初始化

  1. Sentinel 向 Master 发送 info 命令,取得 Slave 服务器地址,之后向 Master、Slave 发送 info 命令,获得 Redis 状态

  2. Sentinel 向 Master、Slave 订阅 :hello 频道,并向频道发送自身消息,让 Sentinel 之间可以互相感知

Sentinel Leader 选举

  1. Sentinel 每秒向 Redis 发送心跳连接,若无回应则视为【主观下线】,并向其他 Sentinel 发送查找命令,若有 quorum 数量的 Sentinel 都认为该 Redis 下线,将被判定为【客观下线】

  2. 当 Master 客观下线,将使用 Raft 协议选出 Leader Sentinel 运行 Redis 的【故障转移】

Raft

选举开始,所有节点都是 Follower。如果收到 RequestVote (投票给我) 、AppendEntries (已选出Leader) 的请求,则保持 Follower 状态

一段时间(随机)内没收到请求,则将身分转换为 Candidate 开始竞选 Leader,如果获得过半票数则成为 Leader。

如果最后未选出 Leader,则 Term + 1,开启下一轮选举

故障转移

  1. 选出 Slave 取代原 Master ,并让其他 Slave 复制新 Master

    选择标准 (1) slave-priority 最高 (2) 复制偏移量最大 (3) run_id 最小[重启最少次]

  2. 向客户端返回新 Master 地址

  3. 更新所有 Redis 的 redis.conf、sentinel.conf

3. Codis (Proxy)

优点:

  • 客户端透明,和 Codis 交互与和 Redis 交互相同

  • 支持在线数据迁移

  • 支持高可用 (Redis、Proxy)

  • 数据自动均衡分配

  • 支持 1024 个 Redis 实例

缺点:

  • 某些命令不支持

  • 只有一个 Codis,性能将下降20%

  • 采用自有的 Redis 分支,与原版不同步

4. Redis Cluster

优势

  1. 高性能

    • 多主节点、负载均衡、读写分离
  2. 高可用

    • 主从复制、Raft选举
  3. 易扩展

    • 添加、移除节点,不须停机

    • 数据分片

  4. 原生

    • 不需要其他代理或工具,和单机 Redis 完全兼容

失效判定

  • 半数以上主节点当机 (无法投票)

  • 某个分区的主、从节点同时当机 (slot槽不连续)

副本飘移

集群中拥有最多从机的节点组,漂移到单点的主从节点组

十、高并发问题

1. 缓存穿透

查找缓存中 Key 不存在的数据,会穿透缓存去查数据库,导致DB压力过大

解决方案:

  1. 结果为空也进行缓存,TTL 设短一些,且在对数据库 insert 数据后清除缓存

    问题: 缓存空值将占用空间

  2. 使用布隆过滤器先使用布隆过滤器查找 Key 是否存在,不存在则返回,存在再查缓存与DB

2. 缓存雪崩

大量缓存在同一时刻失效,导致客户端直接查找DB,造成DB压力过大

解决方案:

  1. 让 Key 的失效期分散
  2. 设置二级缓存(本地缓存) - 可能存在数据不一致问题
  3. 高可用(读写分离)

3. 缓存击穿

热Key失效,导致DB某个纪录瞬间被大量访问

解决方案:

  1. 使用分布式锁 setnx,让其他线程处于等待状态,来保证DB安全
  2. Key 不设置超时时间,过期策略使用 validate-lru

4. 数据一致性 - 延迟双删

  1. 更新DB后删除缓存,读数据再填充缓存
  2. 2 秒后再删除一次缓存
  3. 设置缓存过期时间 10秒 or 1小时
  4. 若缓存删除失败,则记录到日志,并用脚本提取后删除 (1天)

5. 大Key

  1. Value 是 String 类型,可以存到 MongoDB 或 CDN 上,如果必须用 Redis,则单独存储,并且采一主多从架构
  2. hash、set、zset、list 类型,元素过多,可以将 Key 进行 Hash 取模后生成新 Key,将 Key 进行分拆
  3. 删除使用 unlink 而非 del 命令