简介
Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
使用场景
-
Redis命令
-
string
- 字符缓存
作为KV型的内存数据库,Redis 最先会被想到的应用场景便是作为数据缓存。合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。Redis提供了键过期功能,也提供了灵活的键淘汰策略,所以Redis用在缓存的场合非常多。
- 计数器
如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都得给+1,并发量高时如果每次都请求数据库操作无疑是种挑战和压力。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。
- 分布式会话
集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。
- 分布式锁
在很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。可以利用Redis的setnx功能来编写分布式的锁,如果设置返回1说明获取锁成功,否则获取锁失败,实际应用中要考虑的细节要更多
-
hash
对于一些对象类型除了按string序列化转换后存储,还可以按Hash 类型进行存储。例如我们存储一些网站用户的基本信息, 我们可以使用hmset user:1 name "小明" phone 123456 age 25。类似场景还非常多, 比如存储订单的数据,产品的数据,商家基本信息等;以淘宝购物车为例:
实现对象存储优缺点
- string存储
- set user:1:name xiaoming
- set user:1:age 24
- set user:1:phone 123456
优点: 简单直观,每个键对应一个值
缺点: 键数过多,占用内存多,用户信息过于分散
- 对象json序列号存储
set user:1 serialize(userInfo)
优点: 编程简单,若使用序列化合理内存使用率高
缺点: 序列化与反序列化有一定开销
- hash存储
hmset user:1 name xiaoming age 24 phone 123456
优点: 简单直观,使用合理可减少内存空间消耗
缺点: 要控制ziplist 与hashtable两种编码转换,hashtable会消耗更多内存
-
list
- 消息队列实现
消息队列是大型网站必用中间件,如ActiveMQ、RabbitMQ、Kafka等流行的消息队列中间件,主要用于业务解耦、流量削峰及异步处理实时性低的业务。Redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统,大流量吞吐的场景请使用公司Talos等消息队列产品
- 最新上架商品
在交易网站首页经常会有新上架产品推荐的模块, 这个模块是存储了最新上架前 100 名。
这时候使用 Redis 的 list 数据结构,来进行 TOP 100 新上架产品的存储。
Redis ltrim 指令对一个列表进行修剪(trim),这样 list 就会只包含指定范围的指定元素。
-
set
set 也是存储了一个集合列表功能。和 list 不同,set 具备去重功能。当需要存储一个列表信息,同时要求列表内的元素不能有重复,这时候使用 set 比较合适。与此同时,set 还提供的交集、并集、差集。
例如在交易网站,我们会存储用户感兴趣的商品信息,在进行相似用户分析的时候, 可以通过计算两个不同用户之间感兴趣商品的数量来提供一些依据。
获取到两个用户相似的产品, 然后确定相似产品的类目就可以进行用户分析。
类似的应用场景还有, 社交场景下共同关注好友, 相似兴趣 tag 等场景的支持。
-
zset
常用于排行榜,如视频网站需要对用户上传视频做排行榜,或点赞数与集合有联系,不能有重复的成员
-
bitmap
许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内 存使用率和开发效率。Redis提供了Bitmaps可以实现对位的 操作。
例如每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。设置键的第offset个位的值(从0算起),假设现在有20个用户, userid=0,5,11,15,19的用户对网站进行了访问
- setbit unique:users:2021-07-20 0 1
- setbit unique:users:2021-07-20 5 1
- setbit unique:users:2021-07-20 11 1
- setbit unique:users:2021-07-20 15 1
- setbit unique:users:2021-07-20 19 1
获取id=8的用户是否在2021-07-20这天访问过,返回0说明没有访问
- getbit unique:users:2021-07-20 8
计算2021-07-20这天的独立访问用户数量
- bitcount unique:users:2021-07-20
假设网站有1亿用户,每天独立访问的用户有5千万,如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表
很明显这种情况下使用Bitmaps能节省很多的内存空间,尤其是随着时间推移节省的内存还是非常可观的。
进阶
-
分布式锁
-
案例1
加锁
// lockKey 锁key
// requestId 锁value
// ttlms 锁过期时间
public boolean tryGetDistributedLock(String lockKey, String requestId, int ttlms) {
JedisCommands jedis = getJedis();
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", ttlms);
if (StringUtils.isBlank(result)) {
return false;
}
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
} finally {
close(jedis);
}
}
释放锁
public boolean unLock(String lockKey) {
JedisCommands jedis = getJedis();
try {
return jedis.del(key);
} finally {
close(jedis);
}
}
存在问题:
释放其他客户端的锁,添加锁判断
-
案例2
加锁
// lockKey 锁key
// requestId 锁value
// ttlms 锁过期时间
public boolean tryGetDistributedLock(String lockKey, String requestId, int ttlms) {
JedisCommands jedis = getJedis();
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", ttlms);
if (StringUtils.isBlank(result)) {
return false;
}
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
} finally {
close(jedis);
}
}
释放锁
public boolean unLock(String lockKey, String requestId) {
JedisCommands jedis = getJedis();
try {
if(StringUtils.equals(requestId, jedis.get(lockKey))){
return jedis.del(key);
}
} finally {
close(jedis);
}
}
存在问题:
非原子操作,释放其他客户端的锁,lua脚本,互斥操作
-
案例3
加锁
// lockKey 锁key
// requestId 锁value
// ttlms 锁过期时间
public boolean tryGetDistributedLock(String lockKey, String requestId, int ttlms) {
JedisCommands jedis = getJedis();
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", ttlms);
if (StringUtils.isBlank(result)) {
return false;
}
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
} finally {
close(jedis);
}
}
释放锁
/**
* 释放分布式锁,用到了lua脚本,保证操作原子性
* @param lockKey
* @param requestId
* @return
*/
public boolean releaseDistributedLock(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";
JedisCluster jedisCluster = getJedisCluster();
if (jedisCluster == null) {
return false;
}
try {
if (!lockKey.startsWith(prefix)) {
lockKey = prefix + lockKey;
}
Object result = jedisCluster.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (result == null) {
return false;
}
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
} finally {
close(jedisCluster);
}
}
存在问题:
代码未执行完,锁过期,其他线程拿到锁,重复执行
Redission
-
Redis集群使用pipeline功能
pipeline的使用场景
- 命令量大;
- 命令结果前后无依赖;
redis中slot概念
redis有固定16384个槽(slot),数据就存储在槽中。 (为什么固定16384,不能更多?)
redis的key通过CRC计算并与16383取模,可以计算出key所在的槽。
int slot = getCRC16(key) & (16384 - 1)
而槽又根据均衡原则分配在不同的数据节点上,每个数据节点上维护一定数量的槽。
redis集群命令路由
首先我们要明确几个知识点:
- redis集群有多个数据节点,每个数据节点除了维护自己所属的一批槽点外,还知道集群内其它节点负责的槽点;
- redis服务端只负责返回自己槽点内的数据,不会跨节点代理请求;
- redis客户端发送的命令,只落在某一个node,不能给多个node发送;
以上3点说明,当一个命令落在了不适合的数据节点上,是不会获得数据。
例如,一条命令get redis-test, 键redis-test的槽点是10000,所属节点是node-3。
如果这个命令发送给node-1的节点,由于node-1不对槽点是10000负责,所以是不会返回期望数据的。
redis 集群 smart client
在上小节,redis集群主要是应对大量请求,如果如上所述,大量的命令都会发生重定向,发送两次请求,显然会存在网络I/O时间问题。
而使用redis的业务多是时间敏感的。
针对以上问题,诞生了smart_client, smart含义体现在,该client将每一条命令发送到正确的node上。
接下来看看smart_client是如何工作的:
- client创建了与每个节点的连接池;
- client维护了集群中所有 (slot, node) 的映射关系;
private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig,
int connectionTimeout, int soTimeout, String password, String clientName) {
for (HostAndPort hostAndPort : startNodes) {
Jedis jedis = null;
try {
// 初始化redis连接
jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout, soTimeout);
if (password != null) {
jedis.auth(password);
}
if (clientName != null) {
jedis.clientSetname(clientName);
}
// 缓存slot和节点的对应关系
cache.discoverClusterNodesAndSlots(jedis);
break;
} catch (JedisConnectionException e) {
// try next nodes
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
- client计算每次命令key的slot,根据slot获取目标node的链接;
private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, boolean asking) {
if (attempts <= 0) {
throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections?");
}
Jedis connection = null;
try {
if (asking) {
// TODO: Pipeline asking with the original command to make it
// faster....
connection = askConnection.get();
connection.asking();
// if asking success, reset asking flag
asking = false;
} else {
if (tryRandomNode) {
connection = connectionHandler.getConnection();
} else {
// 从缓存获取key对应的节点
connection = connectionHandler.getConnectionFromSlot(slot);
}
}
return execute(connection);
} catch (JedisNoReachableClusterNodeException jnrcne) {
throw jnrcne;
} catch (JedisConnectionException jce) {
// release current connection before recursion
releaseConnection(connection);
connection = null;
if (attempts <= 1) {
//We need this because if node is not reachable anymore - we need to finally initiate slots renewing,
//or we can stuck with cluster state without one node in opposite case.
//But now if maxAttempts = 1 or 2 we will do it too often. For each time-outed request.
//TODO make tracking of successful/unsuccessful operations for node - do renewing only
//if there were no successful responses from this node last few seconds
this.connectionHandler.renewSlotCache();
}
return runWithRetries(slot, attempts - 1, tryRandomNode, asking);
} catch (JedisRedirectionException jre) {
// if MOVED redirection occurred,
if (jre instanceof JedisMovedDataException) {
// it rebuilds cluster's slot cache
// recommended by Redis cluster specification
this.connectionHandler.renewSlotCache(connection);
}
// release current connection before recursion or renewing
releaseConnection(connection);
connection = null;
if (jre instanceof JedisAskDataException) {
asking = true;
askConnection.set(this.connectionHandler.getConnectionFromNode(jre.getTargetNode()));
} else if (jre instanceof JedisMovedDataException) {
} else {
throw new JedisClusterException(jre);
}
return runWithRetries(slot, attempts - 1, false, asking);
} finally {
releaseConnection(connection);
}
}
- client使用正确的连接发送命令;
- 当slot发生了迁移,client要重定向到新的node,已经更新本地的(slot, node) 映射;
redis集群不支持pipeline的原因
综上,我们可以总结如下:
- pipeline将多个命令只发给一个node;但是这些命令的槽点可能对应其它node;
- node间不负责代理转发请求;
- 如果key不在接受到命令的node上,会没有正确结果;
所以对于redis集群来说,简单的使用pipeline并不能达到我们的目的。
解决方案
方案1
结合smart_client的特性,保存有slot -> node的映射关系,方案如下:
- 对一批命令里的key分别计算槽点,获得对应的node链接;
- 对同属同一个node的key进行归档,得到每个node需要执行的命令列表;
- 之后对每个node链接分别执行pipeline;
实现:
方案2
使用hash_tag,这个方案是在写入key的时候,将多个key写入同一个node。{adc}>1,{adc}>2
缺点:数据分散不均匀,集群流量不均。
-
缓存穿透,击穿,雪崩
缓存穿透
- 缓存穿透是指查询一个不存在的数据,由于缓存是不命中时被动写的,如果从DB查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到DB去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了。
- 怎么解决?
- 缓存空值,不会查数据库。
- 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的
bitmap中,查询不存在的数据会被这个bitmap拦截掉,从而避免了对DB的查询压力。
- 布隆过滤器的原理:当一个元素被加入集合时,通过K个哈希函数将这个元素映射成一个位数组中的K个点,把它们置为1。查询时,将元素通过哈希函数映射之后会得到k个点,如果这些点有任何一个0,则被检元素一定不在,直接返回;如果都是1,则查询元素很可能存在,就会去查询Redis和数据库。
- 布隆过滤器一般用于在大数据量的集合中判定某元素是否存在。
缓存击穿
-
缓存击穿:大量的请求同时查询一个 key 时,此时这个 key 正好失效了,就会导致大量的请求都落到数据库。缓存击穿是查询缓存中失效的 key,而缓存穿透是查询不存在的 key。
-
解决方法:
- 加互斥锁。在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。
- 热点数据不过期。直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,保证缓存可以定时刷新。
缓存雪崩
- 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重挂掉。
- 解决方法:
- 在原有的失效时间基础上增加一个随机值,使得过期时间分散一些。这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
- 加锁排队可以起到缓冲的作用,防止大量的请求同时操作数据库,但它的缺点是增加了系统的响应时间,降低了系统的 吞吐量,牺牲了一部分用户体验。当缓存未查询到时,对要请求的 key 进行加锁,只允许一个线程去数据库中查,其他线程等候排队。
- 设置二级缓存。二级缓存指的是除了 Redis 本身的缓存,再设置一层缓存,当 Redis 失效之后,先去查询二级缓存。例如可以设置一个本地缓存,在 Redis 缓存失效的时候先去查询本地缓存而非查询数据库。
-
热点Key,大Key
热点Key
-
在Redis中,我们把访问频率高的Key,称为热Key。比如突然有几十万的请求去访问redis中某个特定的Key,那么这样会造成redis服务器短时间流量过于集中,很可能导致redis的服务器宕机。那么接下来对这个Key的请求,都会直接请求到我们的后端数据库中,数据库性能本来就不高,这样就可能直接压垮数据库,进而导致后端服务不可用。
-
如何识别热点Key?
- 结合业务场景,判断哪些是热Key,热卖商品、热点新闻、热点评论、明星直播
- 在客户端写程序统计上报,对我们的业务代码有一定的侵入性。
- 服务代理层上报。
- JD-HotKey
-
解决
- 使用二级缓存,即JVM本地缓存,减少Redis的读请求。
大Key
-
定义:
- 单个简单的key存储的value很大
- hash, set,zset,list 中存储过多的元素
-
问题:
- 读写bigkey会导致超时严重,甚至阻塞服务。redis单线程
- 大key相关的删除或者自动过期时,会出现qps突降或者突升的情况,极端情况下,会造成主从复制异常,Redis服务阻塞无法响应请求。
-
解决方案
- 设计时避免大key
- 大key拆分