Redis基本特性
- 诞生于2009年,基于C语言编写,全称是Remote Dictionary Server
- Redis作者本来只发布了linux版本,是微软团队编译了windows版本的redis
- 单线程,每个命令具备原子性(虽然Redis说是多线程,但是核心部分还是单线程,多线程只是为了处理网络请求)
- 虽然单线程,但是性能高的原因:基于内存,IO可以多复用
- 支持数据持久化、主从集群、分片集群(分片集群:数据可以拆成几块存储,实现水平扩展)。
- 关系型数据库和非关系型数据库对比:
- 关系型数据库满足事务性ACID,非关系型数据库不满足事务性要求,或者仅满足部分事务性要求
- 关系型数据库数据之间是关联的,非关系型数据库数据之间不是关联的。
- 关系型数据库多储存在磁盘,非关系型数据库多存储在内存。
启动
- redis配置文件方式启动:redis-server redis.conf
- redis.conf内容:
- 默认端口:port 6379
- 数据库数量,默认有16个库,编号0-15:databases 1
- redis能够使用的最大内存:默认512M
- 守护进程:daemonize yes,设置为yes后redis就可以后台运行
数据结构
- String:
- list:链表结构,有序,元素可重复
- set:和java中的set一样,可以保存多个字符串元素,集合中的元素不能重复,并且结合无序。
- hash:链表+数组(红黑树)的数据结构存储
- 根据key值进行hash算法取模,例如得到3,则在链表第三个位置插入数据。
- 如果多个key进行hash算法取模得到的值相同,则称为hash冲突
- 怎么解决hash冲突?利用数组,如果有冲突,元素往数组后插入。
- 如果超过hash冲突的个数超过一定数量,则使用红黑树代替数组进行存储。
- 关于hash数据结构,参考这个文章,讲的不错:文章地址
- zset(sortedSet):内部使用了两种数据结构hash表和跳表(skipList)
- 跳表:加入了几级索引
- Geo:存储地理位置信息,并对存储的信息进行操作,例如存储地理位置坐标信息,计算两个坐标之间的距离等。
- bitmaps:相当于位数组,每个单元只能存0和1,数组的下标在Bitmaps中叫做偏移量。
- BitMaps使用场景:统计用户的用户在线状态,例如把用户的ID作为偏移量,如果在线就为1,不在线就为0
- 用户签到统计,根据日期开辟一个BitMaps变量空间,用户的ID作为偏移量,如果已签到就为1,未签到就为0
- HyperLogLog:基数统计
- 先说什么是基数:比如数据集 {1, 3, 5, 7, 5, 7, 8}, 那么这个数据集的基数集为 {1, 3, 5 ,7, 8}, 基数(不重复元素)为5。
- 关于HeyerLoglog的原理和应用,这篇文章讲的很不错:点击这里
- 几种数据结构应用场景,可参考这篇文章:
数据结构 | 应用场景 |
---|---|
String | 存储用户信息、点赞统计、分布式锁、缓存对象、数据统计、时间内限制请求次数、订单号(全局唯一)、分布式session |
List | 数据字典、消息队列 |
Set | 文章关联用户点赞、浏览信息、抽奖活动、好友人脉 |
Zset | 排行榜、新闻热搜 |
Hash | Redisson分布式锁、购物车列表、缓存对象 |
Geo | 存储地理位置信息 |
Bitmaps | 统计用户是否签到、统计用户在线状态 |
HyperLogLog | 大数据量的统计,比如页面访问量统计或者用户访问量:统计统计注册 IP 数,统计每日访问 IP 数,统计页面实时 UV 数,统计在线用户数,统计用户每天搜索不同词条的个数 |
持久化
持久化这一部分参考了如下文章:文章1 和 文章2
先说结论:我认为的RDB和AOF分别相当于ORCLE数据库的全量备份和增量备份。但是又有区别:
- 在Redis4.0之前,Redis重启后恢复数据时,如果检测到有AOF存在,就不会再恢复RDB,那么AOF就不是真正意义上的增量备份,因为
增量备份至少要有个最近时间点的全量备份作为数据基础
。 - 在Redis4.0之后,Redis有了
混合持久化
的概念,这个概念就接近于关系型数据中的全量备份你和增量备份,在文章1 中最后一部分讲到了混合持久化的概念,引用下原话:- Redis 4.0 提供了混合持久化方案,将 RDB 文件的内容和增量的 AOF 日志文件存在一起。
- 这里的 AOF 日志不再是全量的日志,而是自 RDB 持久化开始到持久化结束这段时间发生的增量 AOF 日志。
- 通常这部分日志很小。
再看看RDB和AOF的具体区别吧:
- RDB:记录快照,将数据全量保存,备份数据时性能耗费较大,但是恢复数据时会比AOF性能要好,但是可能会丢失在两次执行RDB备份时间间隔过程的数据。RDB机制触发的三种方法:
- save方法会阻塞主线程,大量IO操作会影响功能使用,所以在生产环境强烈不推荐使用
- bgsvae方法会fork出新的子线程,不会影响主线程使用
- redis.config配置自动化备份,
save 100 1
表示100秒内如果有一次或者一次以上的写入或修改操作,那么就会执行RDB备份,相同道理save 200 10
表示200秒内如果有10次及以上的写入或者修改操作,那么就执行RDB备份。在redis.config中,还有几个其他的重要参数:- stop-writes-on-bgsave-error:默认值为yes,即当最后一次 RDB 持久化保存文件失败后,拒绝接收数据。这样做的好处是可以让用户意识到数据并没有被成功地持久化,避免后续更严重的业务问题的发生;
- rdbcompression:默认值为yes,即代表将存储到磁盘中的快照进行压缩处理;
- rdbchecksum:默认值为yes,在快照存储完成后,我们还可以通过CRC64算法来对数据进行校验,这会提升一定的性能消耗;
- dbfilename:默认值为dump.rdb,即将快照存储命名为dump.rdb;
- dir:设置快照的存储路径。
- AOF:记录Redis的修改命令,形成日志文件,通过重放日志文件进行恢复,虽然不会数据丢失但是,效率远不如RDB。AOF记录的东西有点像MYSQL的binlog日志或者Oracle的归档日志。AOF默认不开启,AOF机制有三种配置:
- always:每次修改数据就会记录到文件中,虽然能保存完整的数据,但是性能很差。
- everysec:每一秒进行同步,速度有提升,但是也无法解决1s的数据间隙问题。
- no:默认配置,即不使用 AOF 持久化方案。
AOF优化
问题1:如果每一次修改数据就记录文件,是不是太耗费性能了,并且并发量高的时候,是不是容易丢失数据?并且在记录日志的过程,有可能会有写入数据的操作,那么数据就不一致了,怎么解决?
- 针对这个问题,就引入
aof_buf
缓冲区进行解决,记录一段写入日志后,择机写入文件。
问题2:AOF在写入磁盘的过程中,可能会存在多条无用的数据,例如多次set某个key的值,那理论上是不是我只记录最后一次key对应的值就可以了?又或者incr自增,执行了好多次之后,我是不是也可以只记录最后一次的值?
- 可以对aof文件进行精简,那么对于AOF文件精简的过程,就叫重写(rewrite)
- 重写一般会fork一个子进程进行操作,但是在重写期间,主进程仍然可能出现数据不一致情况,所以就要引入
aof_rewrite_buf
缓冲区的概念。 - 当fork一个子进程的同时,创建一个
aof_rewrite_buf
重写缓冲区,记录主进程的写入记录,等重写结束后,将主进程的写入记录,写入新的aof文件中。
插一句题外话:mysql的binlog日志和redo日志
刚刚在持久化这章节,提到mysql的binlog日志,仔细查阅资料后,我意识到自己一直把mysql的binlog日志和redo日志搞混了,既然讲到这里,那就从这篇文章中摘抄下两者区别:
- binlog 是 MySQL 的逻辑日志,也叫归档日志、二进制日志,由 MySQL Server 来记录。用于记录用户对数据库操作的SQL语句(除了查询语句)信息,以二进制的形式保存在磁盘中。
- redolog 是 MySQL 的物理日志,也叫重做日志,记录存储引擎 InnoDB 的事务日志,储存了数据更新后的值。
- MySQL 每执行一条 SQL 更新语句,不是每次数据更改都立刻写到磁盘,而是先将记录写到 redo log 里面,并更新内存(这时内存与磁盘的数据不一致,将这种有差异的数据称为脏页),一段时间后,再一次性将多个操作记录写到到磁盘上,这样可以减少磁盘 io 成本,提高操作速度。
- 先写日志,再写磁盘,这就是 MySQL 里经常说到的 WAL 技术,即 Write-Ahead Logging,又叫预写日志。MySQL 通过 WAL 技术保证事务的持久性。
集群(集群往后内容实在太多了,有点总结不过来,还是采用总结备忘+引用链接的方式进行记录吧)
有些关于redis集群方案的博客,上来就开始落落什么Redis Cluster、Redis Sharding之类的,倒也没什么毛病,但是不管是Cluster还是Sharding,都只能算是分片集群的实现方式,并不能体现Redis集群的所有模式,那么集群的模式我认为有一下几种:
- 主从集群(master/slave),可以演变为读写分离。
- 哨兵模式,基于主从集群,引入哨兵进行监控和故障处理,Redis Sentinel 的节点数量要满足 2n+1(n>=1) 的奇数个。
- 主观下线
- 客观下线
- 分片集群,根据不同的key进行hash算法,存储在不同的redis实例中,这部分不过多记录了,网上很多博客讲的都很好,推荐一个文章:Redis集群。另外,在分片集群中,根据HASH路由算法所在位置不同,大致分为三种实现方式:
- 服务端Sharding:Redis官方集群方案 Redis Cluster,技术,3.0版本开始正式提供,采用slot槽的概念,共分为16384个槽位,对键值对进行CRC16后除以16384取模,然后放在对应槽位中。
- 客户端Sharding:Redis Sharding
- 代理Sharding(中间件第三方路由):twemproxy
Redis分片存储的缺点:
- 跨实例批量操作多个key,涉及多个key并且key不在同一个实例。
- 跨实例批量操作多个key的事务问题。
- 当使用分区的时候,数据处理会非常复杂,例如为了备份你必须从不同的 Redis 实例和主机同时收集 RDB / AOF 文件。
- 分区时动态扩容或缩容可能非常复杂。Redis 集群在运行时增加或者删除 Redis 节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而,有一种预分片的技术也可以较好的解决这个问题。
节点扩容问题
关于一致性算法和HASH环,有两个比较不错的文章:文章1 和 文章2
缓存穿透、缓存击穿、缓存雪崩
- 缓存穿透
- 场景一:由于多并发情况下,前几个请求可能同时执行到查询数据库的操作时,会产生短暂的缓存穿透。
- 场景二:查询的数据在数据库中不存在,并且没有被记录在缓存中,会造成缓存穿透。
- 解决方案一:增加空对象的缓存,即即使查询的数据在数据库中不存在,也要在缓存中存入
id:null
数据,但是这样操作一定要加入一个短暂的过期时间用于及时刷新缓存,否则等到这个数据在数据库中真实存在了,也会被缓存给返回个空对象。 - 解决方案二:如果主键是整形,那么可以对请求的ID进行数据类型校验,发现不是整形的就直接return null。
- 解决方案三:使用布隆过滤器,在数据库插入操作时,对数据进行白名单维护,数据请求的时候先在白名单中进行比对,如果不在白名单就直接return null。
- 缓存雪崩
- 场景一:由于设置的过期时间差不多,大量的热key同时过期,那下个请求波峰肯定会打到数据库中。
- 场景二:由于高并发情况下,前几个请求同时执行到查询数据库的代码行,就会导致这几个请求都查询一遍数据库。
- 解决方案一:错开过期时间,使用随机数的方式给过期时间进行增加或者减少,以使key均匀过期。
- 解决方案二:针对场景二,在查询数据库代码块上增加同步锁,保证只有一个线程去查询数据库,然后写入缓存(进行代码块加同步锁的时候,一定要进行缓存二次校验,不然其他线程也一样会打到数据库中,代码见下方)。
- 解决方案三:缓存预热。
- 缓存击穿
- 缓存击穿是某个热Key过期时,大量并发打到数据库上,缓存击穿和缓存雪崩有点类似,大量的缓存击穿造成的现象就是缓存雪崩。
public Object getObject(Long id){
// 先从缓存中查询
String jsonString = (String) redisTemplate.opsForValue().get("xxx:" + id);
if(StringUtil.isNotEmpty(jsonString)){
// 如果存在,从缓存中直接返回
Object object = JSON.parseObject(jsonString, Object.class);
return object;
}else{
// 如果不存在,加上同步锁之后从数据库中查询并放入缓存中,但是一定要进行缓存二次校验
synchronized(this){
jsonString = (String) redisTemplate.opsForValue().get("xxx:" + id);
if(StringUtil.isNotEmpty(jsonString)){
// 如果二次校验存在,从缓存中直接返回
Object object = JSON.parseObject(jsonString, Object.class);
return object;
}else{
// 如果二次校验缓存不存在,再查询数据库后放入缓存
Object object = objectMapper.getById(id);
redisTemplate.opsForValue().set("xxx:" + id, JSON.toJSONString(object), 300, TimeUnit.SECONDS);
}
}
}
}
大key和热key的问题及解决方案
参考这个文章
分布式锁
- 在使用
redisTimplate.opsForVaue().selfAbsent(key, value)
时,如果不在finally中释放锁,或者不设置过期时间,那么其他线程无法获取到这个锁,所以执行set nx的时候,要指定过期时间:set resource-name anystring NX EX max-lock-time
,调用的方法是:redisTemplate.opsForValue().selfAbsent(K var1, V var2, long var3, java.util.concurrent.TimeUnit var4)
,var3是过期时间 - 上述解决方案中redis是怎么释放的锁?分为两种,但是每一种都有问题:
- key到期时候自动释放
- 锁会根据设置的过期时间自动释放,但也有问题,例如遇到耗时较长的查询,在查询结束之前锁自动释放了,就很容易造成重复查询
- 针对这个问题,当然可以把锁的时间设置长一些,但是很容易影响查询效率,而且这个过期时间也不好评估,没办法设置个很合理的值。
- 手动删除
- 手动删除很容易和锁自动删除有所冲突,容易造成删除了其他线程的锁。例如,一笔查询业务A执行时间较长,还没有执行完key自动过期了,但是这时候恰巧另一个查询业务B进来新加两个一个锁,最后A执行完毕后又手动删除掉了B加锁的这个key,就相当于A删除掉了B的锁。所以要解决这个问题,要判断这个锁到底是不是自己加的锁。怎么解决呢?用LUA脚本解决原子性,但是很麻烦:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
- 在调用setnx命令的时候,设置key和value,每个线程的key和value都不一样,最终在释放锁的时候,根据key和value来判断是不是自己加的锁,防止误删别人家的锁。
- key到期时候自动释放
另外推荐阅读如何防止重复提交
Redisson
- 解决上述分布式锁使用lua脚本比较麻烦的问题:watchlog看门狗机制,给加锁线程A默认30s处理时间,如果超过30s没有执行完,那么启动一个watchdog线程监控这个线程A,并且自动续期30s,而其他线程一直处于自旋状态(循环获取锁资源)。
- Redisson可以把JAVA中常用的数据结构搬到redis中去,比如List、Map、Queue等,然后java程序中像操作java自己的数据结构一样去操作redis
- Redisson支持java中常用的并发工具类:CountDownLatch计数器、AtomicLong原子整数类、Semphore信号量类等
如何保存缓存一致性
根据需求来定:
- 如果要保证强一致性(缓存数据更新和数据库必须同时更新),就要使用分布式锁来控制,修改数据库数据和写入缓存数据都用这同一个分布式锁。
- 如果要实现最终一致性(缓存数据更新相比于数据库更新可以有短暂滞留),缓存要加过期时间,即使出现数据不一致,过期之后也会从数据库中查询后写入缓存。
- 对于实时性要求很强,又要强一致性的,就不要用缓存了,直接从数据库中查询。
先删缓存再更新数据库、先更新数据库再删除缓存、延迟双删,这三种方案在高并发下都无法保证数据库和缓存数据的强一致性。
使用工具:
- 使用任务表加任务调度的方法进行同步
- 使用Canal基于Mysql的binlog进行同步
- 使用分布式锁进行控制,修改数据和缓存数据的操作要加分布式锁。
内存回收机制
- 过期删除策略,redis的key生命到期了不会立即执行删除,而是使用以下两种方式进行删除:
- 定时删除:每个key设置一个定时器,key过期就删除,如果key过多设置的定时器也就过多,耗费cpu,也可以设置一个定时器扫描多个key的删除。
- 惰性删除:查询getkey的时候再去删除,好处是节省了cpu但是浪费了内存,而且如果一直不getkey就一直删除不了
- 内存淘汰策略
- LRU:Least Recently Used,最近最长时间未使用。
- LFU:Least Frequently Used,最近最少频率使用。