目录
3.2、Read/Write Through Pattern
3.3、Write Behind Caching Pattern
(3)若在事务队列中存在命令性错误,则执行EXEC时,所有命令都不会执行
(4)若在事务队列中存在语法错误,则执行EXEC时,其它正确命令会被执行,错误命令抛出异常
一、缓存原理&设计
1、缓存基本思想
1.1、缓存的使用场景
(1)数据库缓存,减轻服务压力
(2)提高系统响应
数据库的数据存储在文件里,也就是硬盘,查询时与内存做交换,当大量瞬时访问时(高并发)数据库会因为频繁IO而造成无法响应。将数据存储在Redis中,也就是内存,可以处理瞬时大量请求,内存天然支持高并发访问。
(3)做session分离
传统的session是由tomcat自己进行维护和管理的,集群或分布式环境不同tomcat管理各自的session,只能在各个tomcat之间通过网络和IO进行session的复制,极大的影响系统性能。登录成功后将session信息放入redis,可以实现多服务器共享session信息。
(4)做分布式锁
Java中的锁是多线程的锁,当多节点多进程并发处理时也会产生问题,可以采用分布式锁,使用Redis的setnx实现。
1.2、缓存的分类
(1)客户端缓存
传统互联网:页面缓存和浏览器缓存;
移动互联网:APP缓存。
页面缓存
页面自身对某些元素或全部元素进行存储,并保存成文件。
浏览器缓存
当客户端向服务器请求资源时,会先抵达浏览器缓存,如果浏览器有“要请求资源”的副本时,就可以直接从浏览器缓存中提取而不是从原始服务器中提取资源。浏览器缓存氛围强制缓存和协商缓存:
1、强制缓存:直接使用浏览器的缓存数据,条件:Cache-Control的max-age没有过期或Expires的缓存时间没有过期;
2、协商缓存:服务器资源未修改,使用浏览器的缓存(304),反之使用服务器资源(200)。
APP缓存
原生APP中把数据缓存在内存、文件或本地数据库(SQLite)中。
(2)网络端缓存
通过代理的方式响应客户端请求,对重复的请求返回缓存中的数据资源。
web代理缓存
可以缓存原生服务器的静态资源,比如图片、js、css等。常用的就是Nginx。
边缘缓存
边缘缓存中典型的商业化服务就是CDN了,即内容分发网络。CDN通过部署在各地的边缘服务,使用户就近获取所需的内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术,现在一般的公有云服务商都提供CND服务。
(3)服务端缓存
服务端缓存是整个缓存体系的核心,包括数据库级缓存、平台级缓存和应用级缓存。
数据库级缓存
数据库是用来存储和管理数据的。MySQL在Server层使用查询缓存机制,K/V结构,key:select语句的hash值,value:查询结果。InnoDB存储引擎中的buffer-pool用于缓存InnoDB索引及数据块。
平台级缓存
平台级缓存指的是带有缓存特性的应用框架,如GuavaCache、EhCache、OSCache等,部署在应用服务器上,也称为服务器本地缓存。
应用级缓存
具有缓存功能的中间件:Redis、Memcached、EVCache、Tair等。采用K/V形式存储,利用集群支持高可用、高性能、高并发、高扩展,分布式缓存。
2、缓存的利弊
2.1、优势
-
提升用户体验:缓存的使用可以提升系统的响应能力,大大提升用户体验。
-
减轻服务压力:客户端缓存、网络端缓存减轻应用服务器压力;服务端缓存减轻数据库的压力。
-
提升系统性能:缩短响应时间、减少网络传输和应用延迟时间、提高吞吐量、增加服务并发数、提高数据库资源的利用率。
2.2、代价
- 额外的硬件支出:缓存是一种软件系统中以空间换时间的技术,需要额外的磁盘空间和内存空间来存储数据,搭建缓存服务器集群需要额外的服务器等。
- 高并发缓存失效:在高并发场景下会出现缓存失效(缓存穿透、缓存雪崩、缓存击穿等),造成瞬时数据库访问量增大,甚至崩溃。
- 缓存与数据库数据同步:缓存与数据库无法做到数据的时时同步,Redis无法做到主从时时数据同步。
- 缓存并发竞争:多个Redis的客户端同时对一个key进行set时,由于执行顺序引起的并发问题。
3、缓存的读写模式
缓存有三种读写模式。
3.1、Cache Aside Patterrn(常用)
读的时候先读缓存,缓存没有再读数据库,取出数据放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存呢?
- 如果缓存的值是一个结构:hash、list,则需要遍历数据;
- 懒加载,使用的时候再更新缓存。
为什么采用先更新数据再删除缓存呢?
首先,了解高并发引发脏读的三种情况:
- 先更新数据,再更新缓存:update与commit之间,更新缓存commit失败,则缓存与DB中的数据不一致;
- 先删除缓存,再更新数据库:update与commit之间,有新的读,缓存空,读DB数据到缓存,数据依旧是旧的数据,commit后DB为新数据,则缓存与DB数据不一致。
- 先更新数据库,再删除缓存(推荐): update与commit之间,有新的读,缓存空,读DB数据到缓存,数据依旧是旧的数据,commit后DB为新数据,则缓存与DB数据不一致。这里可以采用延时双删策略来解决。
3.2、Read/Write Through Pattern
应用程序只操作缓存,缓存操作数据库。
Read-Through(穿透读模式/直读模式):应用程序读缓存,缓存没有,由缓存回源到数据库,并写入缓存;
Write-Through(穿透写模式/直写模式):应用程序写缓存,缓存写数据库。
这种模式需要提供数据库的handler,开发较为复杂。
3.3、Write Behind Caching Pattern
应用程序只更新缓存,缓存通过异步的方式将数据批量或合并后更新到DB中,不能时时同步,甚至会丢数据。
二、Redis的数据类型和数据结构
1、数据类型和应用场景
1.1、string字符串
Redis的string能表达3种类型的值:字符串、整数、浮点数。
常用命令:
set key value | 赋值 |
get key | 取值 |
getset key value | 取值并赋值 |
setnx key value | 当value不存在时采用赋值 set key value NX PX 3000 原子操作,PX设置毫秒数 |
append key value | 向尾部追加值 |
strlen key | 获取字符串长度 |
incr key | 递增数字 |
incrby key increment | 增加指定的整数 |
decr key | 递减数字 |
decrby key decrement | 减少指定的整数 |
应用场景:
- key和value是字符串;
- incr用于乐观锁,incr递增数字,可用于实现乐观锁watch(事务);
- setnx用于分布式锁,当key不存在时赋值;
1.2、list列表
list列表可以存储有序、可重复的元素,获取头部或尾部附近的记录是极快的,list中元素个数最多为2^32-1(40亿)个。
常用命令:
lpush key v1 v2 v3 ... | 从左侧插入列表 |
lpop key | 从列表的左侧取出 |
rpush key v1 v2 v3 ... | 从右侧插入列表 |
rpop key | 从列表的右侧取出 |
llen key | 获取列表中的元素个数 |
lindex key index | 获取列表中下标为index的元素,index从0开始 |
lset key index value | 将列表index位置的元素设置成value的值 |
应用场景:
- 作为栈或队列使用:列表有序可作为栈和队列使用;
- 可用于各种列表:用户列表、商品列表、评论列表;
1.3、set集合
set集合,元素无序、唯一,集合最大成员数2^32-1(40亿)个。
常用命令:
sadd key mem1 mem2 ... | 为集合添加成员 |
srem key mem1 mem2 ... | 删除集合中的指定成员 |
smembers key | 获取集合中的所有元素 |
spop key | 随机返回集合中的一个元素,并删除 |
srandmember key | 随机返回集合中的一个元素,不删除 |
scard key | 获取集合中的元素个数 |
sismember key member | 判断元素是否在集合内 |
sinter key1 key2 key3 ... | 求多集合交集 |
sdiff key1 key2 key3 ... | 求多集合差集 |
sunion key1 key2 key3 ... | 求多集合并集 |
应用场景:
适用于不能重复的且不需要排序的数据结构。
1.4、sortedset(zset)有序集合
zset:元素本身是无序不重复的,每个元素关联一个分数(score),可按分数排序,分数可重复。
常用命令:
zadd key score1 mem1 score2 mem2 ... | 为有序集合添加成员 |
zrem key mem1 mem2 ... | 删除有序集合中指定的成员 |
zcard key | 获取集合中的元素个数 |
zcount key min max | 返回集合中score在[min, max]区间内的元素数量 |
zincrby key increment member | 在集合元素member的分值上加increment |
zscore key member | 获取集合中member的分值 |
zrank key member | 获取集合中member的排名(分值从小到大) |
zrevrank key member | 获取集合中member的排名(分值从大到小) |
zrange key start end | 获取集合中指定区间成员,按分数递增排序 |
zrevrange key start end | 获取集合中指定区间成员,按分数递减排序 |
应用场景:
由于可以按照分值排序,所以适用于各种排行榜,比如:点击排行榜、销量排行榜、关注排行榜等。
1.5、Hash散列表
Redis Hash是一个string类型的field和value的映射表,它提供了字段和字段值的映射。每个hash可以存储2^32-1(40亿)个键值对。
常用命令:
hset key field value | 赋值,不区分新增或修改 |
hmset key field1 value1 field2 value2 ... | 批量赋值 |
hsetnx key field value | 赋值,如果field不存在则不操作 |
hexists key field | 查看某个field是否存在 |
hget key field | 获取一个字段值 |
hmget key field1 field2 ... | 获取多个字段值 |
hgetall key | 获取所有的字段和值 |
hdel key field1 field2 ... | 删除指定字段 |
hincrby key field increment | 指定字段自增increment |
hlen key | 获取字段数量 |
应用场景:
对象的存储,数据表的映射。
1.6、bitmap位图
bitmap是进行位操作的,通过一个bit位来表示某个元素对应的值或者状态,其中的key就是元素本身。bitmap本身极大的节省存储空间。
setbit key offset value | 设置key在offset处的bit值(只能是0或1) |
getbit key offset | 获得key在offset处的bit值 |
bitcount key | 获得key的bit位为1的个数 |
bitpos key value | 返回第一个被设置为bit值的索引值 |
bitop and [or/xor/not] destkey key [key ...] | 多个key进行逻辑运算后存入destkey中 |
应用场景:
- 用户每月签到,用户id为key,日期作为偏移量,1表示签到;
- 统计活跃用户,日期为key,用户ID为偏移量,1表示活跃;
- 查询用户在线状态,日期为key,用户ID为偏移量,1表示在线。
1.7、geo地理位置
geo是Redis用来处理地理位置信息的。在Redis3.2中正式使用。
常用命令:
geoadd key 经度1 纬度1 成员名称1 经度2 纬度2 成员名称2 ... | 添加地理坐标 |
geohash key 成员名称1 成员名称2 ... | 返回标准的gethash串 |
geopos key 成员名称1 成员名称2 ... | 返回成员经纬度 |
geolist key 成员1 成员2 单位 | 计算成员间距离 |
georadiusbymember key 成员 值单位 count 数 asc [desc] | 根据成员查找附近的成员 |
应用场景:
- 记录地理位置;
- 计算距离;
- 查找“附近的人”。
1.8、stream数据流
stream是Redis 5.0后新增的数据结构,用于可持久化的消息队列。几乎满足了消息队列具备的全部内容:
- 消息ID的序列化生成;
- 消息遍历;
- 消息的阻塞和非阻塞读取;
- 消息的分组消费;
- 未完成消息的处理;
- 消息队列监控。
每个stream都有唯一的名称,他就是redis的key,首次使用xadd指令追加消息时自动创建。
xadd key id <*> field1 value1 ... | 将指定消息追加到指定队列(key)中,*表示最新生成的id(当前时间+序列号) |
xrerad [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...] | 从消息队列中读取,COUNT:读取条数,BLOCK:阻塞读(默认不阻塞),key:队列名称;ID:消息ID |
xrange key start end [COUNT] | 读取队列中给定ID范围的消息COUNT:返回消息条数(消息ID从小到大) |
xrevrange key start end [COUNT] | 读取队列中给定ID范围的消息COUNT:返回消息条数(消息ID从大到小) |
xdel key id | 删除队列的消息 |
xgroup create key groupname id | 创建一个新的消费组 |
xgroup destory key groupname | 删除指定消费组 |
xgroup delconsumer key groupname cname | 删除指定消费组中的某个消费者 |
xgroup setid key id | 修改指定消息的最大ID |
2、底层数据结构
Redis作为key-value存储系统,数据结构如下:
2.1、RedisDB
Redis中存在“数据库”的概念,该结构由redis.h中的redisDb定义。当redis服务器初始化时,会预先分配16 个数据库所有数据库保存到结构redisServer的一个成员redisServer.db数组中redisClient中存在一个名叫db的指针指向当前使用的数据库。
RedisDB结构体源码:
2.2、RedisObject
value是一个对象,包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象。
RedisObject结构体源码:
(1)4位type
type字段表示对象的类型,占4位,分别为:REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。
我们可以通过type命令读取 RedisObject的type字段获得对象的类型:
127.0.0.1:6379> type a1
string
跳跃表
跳跃表是有序集合(zset)的底层实现,效率高,实现简单。
其基本思想是:将有序链表的部分节点分层,每一层都是一个有序链表。
I、查找
在查找时优先从高层开始向后查找,当到达某个节点时,如果next节点值大于要查找的值或next指针指向null时,则从当前节点下降一层继续向后查找。
举例:
查找元素9,我们需要从头开始遍历,遍历8个节点才能找到9。
第一次分层,遍历5次找到9:
第二次分层,遍历4次找到9:
第三次分层,遍历4次找到9:
这种数据结构就是跳跃表,它具有二分查找的功能。
II、删除
找到指定元素并删除每层的该元素即可。
跳跃表的特点:
- 每层都是一个有序链表;
- 查找次数近似于层数(1/2);
- 底层包含所有元素;
- 空间复杂度O(n)扩充了一倍。
III、完整的跳跃表结构体
跳跃表的优势:
- 可以快速查找到需要的节点;
- 可以在O(1)的时间复杂度下,快速获取到跳跃表的头节点、为节点、长度和高度。
应用场景:有序集合的实现。
(2)4位encoding
encoding表示对象的内部编码,占4位,每个对象有不同的实现编码。Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率。 通过object encoding命令,可以查看对象采用的编码方式:
127.0.0.1:6379> object encoding a1
"int"
(3)24位LRU
LRU记录的是对象最后一次被命令程序访问的时间,(4.0版本占24位,2.6版本占22位)。 高16位存储一个分钟数级别的时间戳,低8位存储访问计数(LFU:最近访问次数)。
(4)refcount
refcount记录的是该对象被引用的次数,类型为整型。 refcount的作用,主要在于对象的引用计数和内存回收。当对象的refcount>1时,称为共享对象。Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。
(5)ptr
ptr指针指向具体的数据,比如:set hello world,ptr指向包含字符串world的SDS。
3、缓存过期和淘汰策略
3.1、maxmemory
(1)不设置的场景
Redis的key是固定的,不会增加,作为DB使用,保证数据的完整性,不能淘汰,可以做集群,横向扩展。
淘汰策略:禁止驱逐(默认)
(2)设置的场景
Redis作为缓存使用,不断增加key。maxmemory默认为0,不限制。
Redis的maxmemory设置多少合适?
理论上留下保证系统运行的内存,其余都可以设置给redis,一般3/4。
在redis.conf中设置:
maxmemory 1024mb
查看命令:
CONFIG GET maxmemory
设置maxmemory后,当趋近maxmemory后,通过缓存淘汰策略,从内存中删除对象。
3.2、expire
在Redis中expire设置一个key的存活时间(ttl:time to live),过了这段时间该key就会自动被删除。
(1)使用
127.0.0.1:6379> expire name 2 #2秒失效
(integer) 1
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> set name zhangfei
OK
127.0.0.1:6379> ttl name #永久有效
(integer) -1
127.0.0.1:6379> expire name 30 #30秒失效
(integer) 1
127.0.0.1:6379> ttl name #还有24秒失效
(integer) 24
127.0.0.1:6379> ttl name #失效
(integer) -2
(2)原理
在RedisDB的结构定义中,expires用于维护数据库中设置了失效时间的key(即key与失效时间的映射)。当我们使用expire设置一个key的失效时间时,redis首先到dict这个字典表中查找要设置的key是否存在,如果存在就将这个key和失效时间添加到expires这个字典表中。
当我们使用setex命令向系统插入数据时,redis首先将key和value添加到dict这个字典表中,然后将key和失效时间添加到expires这个字典表中。
简单的说,设置了失效时间的key和具体的失效时间全部都维护在expires这个字典表中。
3.3、删除策略
Redis的数据删除有定时删除、惰性删除和主动删除三种方式,目前Redis采用的是惰性删除+主动删除的方式。
(1)定时删除
在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。需要创建定时器,消耗资源,不推荐使用。
(2)惰性删除
在key被访问时如果发现它已经失效,就删除它。调用expireIFNeeded函数,该函数的意义是:读取数据之前先检查一下它有没有失效,如果失效了就删除它。
(3)主动删除
在redis.conf文件中可以配置主动删除策略,默认是no-enviction(不删除)
maxmemory-policy allkeys-lru
主动删除的淘汰策略:
LRU(最近最少使用)
最近最少使用算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
LRU最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:
- 新数据插入到链表头部;
- 每当缓存命中(数据被访问),则将数据移到链表头部;
- 当链表满时,将链表尾部的数据丢弃;
- 在Java中可以使用LinkHashMap(哈希链表)来实现。
Redis的LRU,是在服务器配置中保存了LRU计数器,会定时更新,每一个redis对象都会设置相应的LRU,可以想像每一次数据访问都会更新redisObject.lru。Redis的LRU数据淘汰机制是在数据集中随机挑选几个键值对,取出其中LRU最大的键值对淘汰。不可能遍历key,volatile-lru从已设置过期时间的数据集中挑选最近最少使用的数据淘汰;allkeys-lru从数据集中挑选最近最少使用的数据淘汰。
LFU(最不经常使用)
最不经常使用算法根据数据在最近一段时间的使用次数进行淘汰,核心思想是“如果数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小”。volatile-lfu从已设置过期时间的数据集中挑选最不经常使用的数据淘汰;allkeys-lfu从数据集中挑选最不经常使用的数据淘汰。
RANDOM(随机)
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰;
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰;
ttl(最短时间优先)
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。
redis数据集数据结构中保存了键值对过期时间的表,即redisDb.expires。
TTL数据淘汰机制:从过期时间的表中随机挑选几个键值对,取出其中ttl最小的键值对淘汰。
noenviction(从不删除)
禁止驱逐数据,不删除(默认)。
(4)缓存淘汰策略的选择
- allkeys-lru:在不确定时一般采用的策略;
- volatile-lru:比allkeys-lru性能差,要存过期时间;
- allkeys-random:希望请求符合平均分布(每个元素以相同的概率被访问);
- 自己控制:volatile-ttl缓存穿透。
三、Redis持久化
1、为什么要持久化?
Redis是内存数据库,宕机后数据会丢失。Redis重启后快速恢复数据,需要提供持久化机制。Redis有两种持久化方式:RDB和AOF。注意:Redis持久化不保证数据的完整性。
info命令可以查看持久化的相关信息:
# Persistence
loading:0
rdb_changes_since_last_save:1
rdb_bgsave_in_progress:0
rdb_last_save_time:1589363051
rdb_last_bgsave_status:ok
rdb_last_bgsave_time_sec:-1
rdb_current_bgsave_time_sec:-1
rdb_last_cow_size:0
aof_enabled:1
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0
aof_last_rewrite_time_sec:-1
aof_current_rewrite_time_sec:-1
aof_last_bgrewrite_status:ok
aof_last_write_status:ok
aof_last_cow_size:0
aof_current_size:58
aof_base_size:0
aof_pending_rewrite:0
aof_buffer_length:0
aof_rewrite_buffer_length:0
aof_pending_bio_fsync:0
aof_delayed_fsync:0
2、RDB
RDB(Redis DataBase),是redis默认的持久化方式,是通过快照的方式实现的,这一刻的数据,不关注过程。
2.1、触发快照的方式
- 符合自定义配置的快照规则;
- 执行save或者bgsave命令;
- 执行flushall命令;
- 执行主从复制操作(第一次)。
(1)配置参数定期执行
在redis.conf中配置:save 多少秒内 数据改变了多少
save "" # 不使用RDB存储 不能主从
save 900 1 # 表示15分钟(900秒钟)内至少1个键被更改则进行快照。
save 300 10 # 表示5分钟(300秒)内至少10个键被更改则进行快照。
save 60 10000 # 表示1分钟内至少10000个键被更改则进行快照。
(2)命令显示触发
127.0.0.1:6379> bgsave
Background saving started
2.2、执行流程(原理)
- Redis父进程首先判断:当前是否在执行save、bgsave、bgrewriteaof(aof文件重写)的子进程,如果在执行则bgsave命令直接返回;
- 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个过程中父进程是阻塞的,Redis不能执行来自客户端的任何命令;
- 父进程fork后,返回“Background saving started”信息,并不再阻塞父进程,可以响应其它命令;
- 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换;
- 子进程发送信号给父进程表示完成,父进程跟新统计信息;
- 父进程fork子进程后,继续工作。
2.3、RDB文件结构
- 头部5字节固定为“REDIS”字符串;
- 4字节“RDB”版本号(不是redis版本号),如:0009;
- 辅助字段,为key-value的形式;
字段名
字段值
字段名
字段值
redis-ver
5.0.5
aof-preamble
是否开启aof
redis-bits
64/32
repl-stream-db
主从复制
ctime
当前时间戳
repl-id
主从复制
used-mem
使用内存
repl-offset
主从复制
- 存储数据库的号码;
- 字典大小;
- 过期key;
- 主要数据,以key-value形式存储;
- 结束标志;
- 校验和,就是看文件是否损坏,或者是否被修改。
2.4、RDB的优缺点
- 优点:
- RDB是二进制压缩文件,占用空间小,便于传输(传给slaver);
- 主进程fork子进程,可以最大化redis性能,主进程不能太大,复制过程中主进程阻塞;
- 缺点:不保证数据完整性,会丢失最后一次快照以后更改的所有数据。
3、AOF
AOF(append only file)是redis的另一种持久化方式。redis默认情况下是不开启的,开启后redis将所有对数据库进行过写入命令(及参数)记录到AOF文件中,以此达到记录数据库状态的目的,这样当redis重启后只需要顺序执行这些命令就可以恢复到原始状态了。
AOF记录过程,RDB只关注结果。
3.1、AOF持久化开启
配置redis.conf:
# 可以通过修改redis.conf配置文件中的appendonly参数开启
appendonly yes
# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的。
dir ./
# 默认的文件名是appendonly.aof,可以通过appendfilename参数修改
appendfilename appendonly.aof
3.2、AOF原理
(1)命令同步AOF文件
AOF文件中存储的是redis的命令,同步命令到AOF文件的整个过程可分为三个阶段:
- 命令传播:redis将执行完的命令、命令的参数、命令的参数个数等信息发送到AOF程序中;
- 缓存追加:AOF程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加到服务器的AOF缓存中;
- 文件写入和保护:AOF缓存中的内容被写入到AOF文件末尾,如果设定的AOF保存条件被满足的话,fsync函数或者fdatasync函数会被调用,将写入的内容真正的保存到磁盘中。
(2)AOF保存模式
redis目前支持三种AOF保存模式:
- AOF_FSYNC_NO:不保存;
- AOF_FSYNC_EVERYSEC:每一秒钟保存一次(默认);
- 在这种模式中,save原则上每个一秒钟就会执行一次,save是由后台fork调用,所以他不会引起服务器主进程阻塞;
- AOF_FSYNC_ALWAYS:每执行一个命令保存一次(不推荐);
- 在这种模式下,每执行完一个命令后,write和save都会被执行,因为save是redis主进程执行的,所以在save执行期间,主进程会被阻塞,不能接收其它命令。
三种AOF模式对服务器主进程的阻塞情况:
模式 | write是否阻塞? | save是否阻塞? | 停机时丢失的数据量 |
AOF_FSYNC_NO | 阻塞 | 阻塞 | 操作系统最后一次对aof文件触发save操作之后的数据 |
AOF_FSYNC_EVERYSEC | 阻塞 | 非阻塞 | 一般情况下不超过2秒钟的数据 |
AOF_FSYNC_ALWAYS | 阻塞 | 阻塞 | 最多丢失一个命令的数据 |
3.3、AOF重写
AOF记录数据的变化过程,会越来越大,需要重写“瘦身”,redis可以在AOF体积变的过大时,自动的在后台(fork子进程)对AOF进行重写,重写后的新AOF文件包含了恢复当前数据集所需的最小命令集合。所谓的“重写”其实是一个有歧义的词语,实际上,AOF重写并不需要对原有的AOF文件进行任何写入和读取,他针对的是数据库中键的当前值。
举例如下:
重写前:
set s1 11
set s1 22
set s1 33
重写后:
set s1 33
重写前:
lpush list1 1 2 3
lpush list1 4 5 6
重写后:
lpush list1 1 2 3 4 5 6
Redis不希望AOF重写造成服务器无法处理请求,所以Redis将AOF重写程序放到(后台)子进程里执行,这样的好处是:
- 子进程进行AOF重写期间,主进程可以继续处理命令请求;
- 子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。
不过也存在一个问题:子进程在进行AOF重写期间,主进程还需要继续处理命令,而新的命令可能对现有数据进行修改,这会让当前数据库中的数据和重写后AOF文件中的数据不一致。为了解决这个问题,redis增加了一个AOF缓存,这个缓存在fork出子进程之后开始启用,redis主进程在接到新的写命令之后,除了会将这个写命令的协议内容追加到现有的AOF文件之外,还会追加到这个缓存中。
重写过程分析(这个操作是安全的):
redis在创建新AOF文件的过程中,会继续将命令追加到现有的AOF文件里面,即使重写过程中发生停机,现有的AOF文件也不会丢失。而一旦新AOF文件创建完毕,redis就会从旧AOF文件切换到新AOF文件,并开始对新AOF文件进行追加操作。
当子进程在执行AOF重写时,主进程需要执行以下三个工作:
- 处理命令请求;
- 将写命令追加到现有AOF文件中;
- 将写命令追加到AOF重写缓存中。
这样可以保证:现有的AOF功能会继续执行,即使AOF重写期间发生停机,也不会丢失任何数据。所有对数据库进行修改的命令都会被记录到AOF缓存中。当子进程完成AOF重写之后,会向父进程发送一个完成信号,父进程在接到完成信号之后,会调用一个信号处理函数,并完成以下工作:
- 将AOF重写缓存中的内容全部写入到新的AOF文件中;
- 对新的AOF文件进行改名,覆盖原有的AOF文件。
这个信号处理函数执行完毕之后,主进程就可以继续像往常一样接收命令请求了。在整个AOF后台重写过程中,只有最后的写入缓存和改名覆盖操作会造成主进程阻塞,其他时候,AOF后台重写都不会对主进程造成阻塞,这将AOF重写对性能造成的影响降到了最低。
触发方式
(1)配置触发
在redis.conf中配置:
# 表示当前aof文件大小超过上一次aof文件大小的百分之多少的时候会进行重写。
# 如果之前没有重写过,以启动时aof文件大小为准
auto-aof-rewrite-percentage 100
# 限制允许重写最小aof文件大小,也就是文件大小小于64mb的时候,不需要进行优化
auto-aof-rewrite-min-size 64mb
(2)执行bgrewriteaof命令
127.0.0.1:6379> bgrewriteaof
Background append only file rewriting started
3.4、混合持久化
RDB和AOF各有优缺点,Redis 4.0开始支持rdb和aof的混合持久化,如果把混合持久化打开,aofrewrite的时候就直接把rdb的内容写到aof文件开头。
开启混合持久化:
aof-use-rdb-preamble yes
可以看到该AOF文件是RDB文件的头和AOF格式的内容,在加载时,首先会识别AOF文件是否以“REDIS”字符串开头,如果是就按RDB格式加载,加载完RDB后继续按AOF格式加载剩余部分。
混合持久化生成的是扩展名为 .rdb 的文件,但这个文件与纯 rdb文件不同,因为它包含了aof日志的一部分。混合持久化策略的设计是为了避免数据的重复存储,rbd部分存储了快照时的数据状态,而aof部分仅存储了此后的变更操作(在混合持久化模式下,当aof文件被重写时,redis会先创建一个当前状态的rdb快照,然后追加自快照创建以来的所有aof日志)。恢复数据时,他会先读取rdb部分来重建数据状态,然后再应用aof部分的操作日志来重放自快找以来的所有写操作。
- 优点:
- 恢复速度:由于rdb部分允许快速加载数据,aof部分则确保来数据的完整性和一致性,混合持久化在恢复时比纯aof更快。
- 数据安全:相对于单独使用rdb,混合持久化提供了更好的数据安全性,因为它包含了自上次快照以来的所有写操作。
- 缺点:
- 文件大小:混合持久化文件可能比纯rdb文件大,因为它还包含了aof日志的部分。
- 配置复杂性:需要适当的配置和管理rdb和aof的各项参数,以充分利用混合持久化的优势。
3.5、AOF文件的载入与数据还原
AOF文件里包含了重建数据库所需的所有写命令,所以服务器只需要读入并重新执行一遍即可还原服务器关闭之前的数据库状态。redis读取AOF文件并还原数据的步骤如下:
- 创建一个不带网络连接的伪客户端(fake client):因为redis的命令只能在客户端中执行,而载入AOF文件时所使用的命令来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行的效果完全一样;
- 从AOF文件中分析并读取出一条写命令;
- 使用伪客户端执行被读出的写命令;
- 一直执行步骤2和3,直到AOF文件中的所有命令都被处理完毕为止。
4、RDB与AOF对比
- RDB存某个时刻的数据快照,采用二进制压缩存储;AOF存储操作命令,采用文本存储;
- RDB性能高,AOF性能较低;
- RDB在配置触发状态会丢失最后一次快照以后更改的所有数据;AOF设置每秒保存一次,最多丢失2秒的数据;
- Redis以主服务器模式运行,RDB不会保存过期键值对数据,Redis以从服务器模式运行,RDB会保存过期键值对,当主服务器向从服务器同步时,再清空过期键值对。AOF写入文件时,对过期的key会追加一条del命令,当执行AOF重写时,会忽略过期key和del命令。
5、应用场景
- 使用方式:
- 内存数据库:rdb+aof,数据不容易丢失;
- 缓存服务器:rdb,性能高;
- 数据还原时:
- rdb+aof,还原aof,aof相对数据要完整;
- rdb,还原rdb
四、发布与订阅
Redis提供了发布/订阅功能,可以用于消息的传输。Redis的发布订阅机制包括三个部分:publisher、subscribe、channel。
发布者与订阅者都是Redis客户端,channel则为Redis服务端。
发布者将消息发送到某个频道,订阅了这个频道的订阅者就能接收到这条消息。
1、频道、模式、订阅与退订
1.1、subscribe(订阅)
# 客户端订阅 频道1 频道2 ...
subscribe channel1 channel2 ...
1.2、publish(发布消息)
# 客户端将消息发布到某频道
publish channel message
1.3、unsubscribe(退订)
# 客户端退订某频道
unsubscribe channel
1.4、psubscribe(模式订阅)
# 客户端订阅所有ch开头的频道
psubscribe ch*
1.5、punsubscribe(模式退订)
# 客户端退订所有ch开头的频道
punsubscribe ch*
2、发布订阅机制
订阅某个频道或模式:
- 客户端
- pubsub_channels:该属性表明了客户端订阅的所有频道;
- pubsub_patterns:该属性表示该客户端订阅的所有模式;
- 服务端
- pubsub_channels:服务器中的所有频道,以及订阅了这个频道的客户端;
- pubsub_patterns:服务器中所有的模式,已经订阅了这些模式的客户端。
当客户端向某个频道发送消息时,redis首先在redisServer中的pubsub_channels中找出键为该频道的节点,遍历该节点的值,即遍历订阅了该频道的所有客户端,将消息发送给这些客户端。然后,遍历结构体redisServer中的pubsub_patterns,找出包含该频道的模式的节点,将消息发送给订阅了该模式的客户端。
3、使用场景
3.1、哨兵模式
在Redis哨兵模式中,哨兵通过发布与订阅的方式与Redis主服务器和Redis从服务器进行通信。
3.2、Redisson框架使用
Redisson是一个分布式锁框架,在Redisson分布式锁释放的时候,是使用发布与订阅的方式通知的。
五、事务
- Redis的事务是通过multi、exec、discard和watch这四个命令来完成的;
- Redis的单个命令都是原子性的,所以这里需要确保事务性的对象是命令集合;
- Redis将命令集合序列化并确保处于同一事务的命令集合连续且不被打断的执行;
- Redis事务不支持回滚操作。
1、事务处理
redis事务提供了一种“将多个命令打包,然后一次性、按顺序的执行”的机制,并且事务在执行的期间不会主动中断——服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令。redis中的一个事务从开始到执行会经历“开始事务、命令入队和执行事务”三个阶段,如下简单示例:
2、执行过程
2.1、开始事务
MULTI命令标记着事务的开始:
这个命令的作用就是将客户端的REDIS_MULTI选项打开,让客户端从非事务状态切换到事务状态。
2.2、命令入队
当客户端处于非事务状态时,所有发送给服务端的命令都会被立即执行:
当客户端进入事务状态后,服务端在收到来自客户端的命令时,不会立即执行,而是将这些命令放进一个事务队列里,然后返回QUEUE,表示已经入队:
2.3、执行事务
客户端进入事务状态后,客户端发送的命令就会被放进事务队列里。但是有一些命令是例外的,如:EXEC、DISCARD、MULTI和WATCH这四个命令,当这四个命令被发送到服务端之后,他们会想客户端处于非事务状态一样,被服务端直接执行:
如果客户端正处于事务状态,那么当EXEC命令执行时,服务器根据客户端所保存的事务队列,以先进先出(FIFO)的方式执行事务队列中的命令:最先入队的命令最先执行,而最后入队的命令最后执行。执行事务中的命令所得的结果会以FIFO的顺序保存到一个回复队列中。当事务队列里的所有命令被执行完之后,EXEC命令会将回复队列作为自己的执行结果返回给客户端,客户端从事务状态返回到非事务状态,至此,事务执行完毕。
2.4、redis事务命令
举例:
(1)正常事务执行
(2)放弃事务
(3)若在事务队列中存在命令性错误,则执行EXEC时,所有命令都不会执行
(4)若在事务队列中存在语法错误,则执行EXEC时,其它正确命令会被执行,错误命令抛出异常
(5)使用watch
WATCH命令用于在事务开始之前监视任意数量的键:当调用EXEC命令执行事务时,如果任意一个被监视的键已经被其它客户端修改了,那么整个事务不再执行,直接返回失败。
Redis使用WATCH实现乐观锁
示例:
通过两个终端连接redis,修改用户资金。
终端一:
终端二:
此时,终端一提交事务:
当执行失败后,先执行unwatch取消监视,再重复之前的操作开启一个事务即可。
六、高可用
1、主从复制
Redis支持主从复制,可以通过执行slaveof(Redis5之后改为replicaof)或者在配置文件中设置slaveof(Redis5之后改为replaceof)来开启复制功能。
- 主对外,从对内,主可写,从不可写;
- 主挂了,从不可为主。
1.1、主从配置
(1)主Redis
无需特殊的配置。
(2)从Redis
修改从服务器上的redis.conf文件:
# slaveof <masterip> <masterport>
# 表示当前【从服务器】对应的【主服务器】的IP是192.168.10.135,端口是6379。
replicaof 127.0.0.1 6379
作用:
- 读写分离
- 一主多从,主从同步;
- 主负责写,从负责读,提升redis的性能和吞吐量;
- 存在问题,主从的数据一致性;
- 数据容灾
- 从机是主机的备份;
- 主机宕机,从机可读不可写;
- 默认情况下主机宕机,从机不可为主机;
- 利用哨兵可以实现主从切换,做到高可用。
1.2、原理与实现
(1)复制流程
保存主节点信息
当客户端向从服务器发送slaveof(replaceof)主机地址 端口 时:从服务器将主机地址和端口保存到redisServer的masterhost和masterport中:
Struct redisServer{
char *masterhost; //主服务器ip
int masterport; //主服务器端口
};
从服务器向发送slaveof命令的客户端返回OK,表示复制已经被接收,而实际上复制工作是在OK之后进行。
建立socket连接
slave与master建立socket连接。
发送ping命令
slave向master发送ping命令:
- 检测socket的读写状态;
- 检测master能否正常处理。
master的响应:
- 发送“ping”,说明正常;
- 返回错误,说明master不正常;
- timeout,说明网络超时。
权限验证
主从正常连接后,进行权限验证。
发送端口信息
身份验证通过之后,从服务器将执行命令 replconf listening-port,向从服务器发送从服务器的监听端口号。
同步数据
Redis2.8之后,分为全量同步和增量同步。
I、Redis2.8以前使用SYNC命令同步复制
Redis的同步功能分为:同步(sync)和命令传播(command propagate)。
- 同步:
- 通过从服务器发送到SYNC命令给主服务器;
- 主服务器生成RDB文件并发送给从服务器,同时发送保存所有写命令给从服务器;
- 从服务器清空之前的数据并执行RDB文件;
- 保持数据一致(还需要命令传播才能保持一致)。
- 命令操作:同步操作完成后,主服务器执行写命令,该命令发送给从服务器并执行,使主从保持一致。
- 缺陷:没有全量同步和增量同步的概念,从服务器在同步时,会清空所有数据。主服务器短线重启后,会重新生成RDB文件和重新记录缓冲区的所有命令,并全量同步到从服务器上。
II、Redis2.8以后采用PSYNC命令替代SYNC
使用PSYNC命令,具备完整同步和部分同步模式。
- Redis的主从同步,分为全量同步和增量同步;
- 只有从机第一次连接上主机是全量同步;
- 断线重连有可能触发全量同步也有可能是增量同步(master去判断runid是否一致)。
- 除此情况都是增量同步。
命令传播
当同步数据完成后,主服务器就会进入命令传播阶段,主服务器只要将自己执行的写命令发送给从服务器,而从服务器只要一直执行并接收从服务器发来的写命令。
(2)心跳检测
在命令传播阶段,从服务器默认会以每秒一次的频率想主服务器发送命令:
replconf ack <replication_offset>
#ack :应答
#replication_offset:从服务器当前的复制偏移量
主要作用有三个:
- 检测主从的连接状态;
- 辅助实现min-slaves;
- 检测命令丢失。
2、哨兵模式
哨兵(sentinel)是Redis高可用性的解决方案。由一个或多个哨兵实例组成哨兵集群,可以监视一个或多个主服务器和多个从服务器。当主服务器进入下线状态时,哨兵可以将该主服务器下的某一个从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。
选举
故障转移
选择主服务器
哨兵自动选举一个从服务器晋升为新的主服务器的过程:
- 故障检测:
- 哨兵会定期发送心跳包以监控主服务器和从服务器的状态;
- 如果某个哨兵无法达到主服务器(默认情况下是超过指定的毫秒数无响应),它会将主服务器标记为下线(SDOWN)。
- 确认故障:
- 主观下线后,哨兵会询问其他哨兵是否也无法联系该主服务器;
- 如果足够多的哨兵(根据配置的 quorum 值)也认为主服务器不可达,那么该主服务器就被标记为客观下线(ODOWN)。
- 选举新的主服务器:
- 故障转移过程开始,哨兵之间会进行选举,以确定哪个哨兵负责执行故障转移。
- 被选中的哨兵会选择一个从服务器来晋升为新的主服务器,选择标准包括数据的复制偏移量、运行时间和是否处于可用状态。
- 执行故障转移:
- 选定的从服务器会被提升为新的主服务器;
- 其他从服务器将被重新配置,以复制新的主服务器;
- 客户端也将被通知新的主服务器地址。
- 故障恢复:如果原主服务器恢复,它将被降级为从服务器,并开始复制新的主服务器。
注意事项:
- 配置和网络:哨兵模式的有效性依赖于正确的配置和稳定的网络环境;
- 避免脑裂:为了避免“脑裂”现象,哨兵的数量和 quorum 值需要合理配置;
- 数据一致性:在故障转移期间可能存在短暂的数据不一致性。
3、集群模式
- 实现方案
- 集群模式:是一个分布式的高可用架构,支持自动分片和数据的水平扩展。它将数据分布在多个节点上,通过 哈希槽(Hash Slot)实现数据的分片存储,每个节点负责一部分哈希槽的数据
- 多主多从结构:集群中的每个节点都可以有从节点(用于高可用),并且数据自动分片分布在不同的主节点上,主从复制用于容错
- 原理
- 分片存储:集群中的数据通过一致性哈希分片,分布在多个节点上,每个节点负责一定范围的哈希槽,redis默认有16384个哈希槽
- 数据冗余:每个主节点都有一个或多个从节点,主节点宕机,集群会自动将从节点提升为新的主节点保证高可用
- 故障转移:集群模式中,某个主节点宕机后,它的从节点会被自动提升为主节点,保证集群继续可用
- 优点
- 自动分片:可以根据负载情况自动分配数据到不同的节点,实现数据水平扩展,适合大规模数据存储和高并发场景
- 自动故障转移:集群模式提供内置的故障转移机制,保证高可用
- 高可扩展性:可以轻松添加和删除节点来动态扩展集群容量
- 缺点:
- 复杂度较高:配置和管理比主从复制和哨兵模式复杂,需要对数据分片和节点拓扑有较好的理解
- 一致性问题:由于redis集群使用了异步复制,在节点故障或分片丢失时,可能有数据丢失
七、实战应用
1、缓存的介绍与使用
缓存的设计要分多个层次,在不同的层次上选择不同的缓存,包括JVM缓存、文件缓存和Redis缓存。
1.1、JVM缓存
JVM缓存就是本地缓存,设计在应用服务器中(tomcat)。通常可以采用Ehcache和GuavaCache,在互联网应用中,由于要处理高并发,通常选择GuavaCache。
适用本地(JVM)缓存的场景:
- 对性能要求非常高;
- 不经常变化;
- 占用内存不大;
- 有访问整个集合的需求;
- 数据允许不实时一致。
1.2、文件缓存
基于http协议的文件缓存,一般放在nginx中。因为静态文件(css、js、图片),都是不经常更新的,nginx使用proxy_cache将用户的请求缓存到本地一个目录,下一个相同的请求可以直接调取缓存文件,而不用去请求服务器了。
server {
listen 80 default_server;
server_name localhost;
root /mnt/blog/;
location / {
}
#要缓存文件的后缀,可以在以下设置。
location ~ .*\.(gif|jpg|png|css|js)(.*) {
proxy_pass http://ip地址:90;
proxy_redirect off;
proxy_set_header Host $host;
proxy_cache cache_one;
proxy_cache_valid 200 302 24h;
proxy_cache_valid 301 30d;
proxy_cache_valid any 5m;
expires 90d;
add_header wall "hello lagou.";
}
}
1.3、Redis缓存
分布式缓存,采用主从+哨兵或RedisCluster构建缓存集群。
1.4、缓存大小
(1)GuavaCache
// 超过num会按照LRU算法来移除缓存
CacheBuilder.newBuilder().maximumSize(num)
(2)Nginx
http {
...
proxy_cache_path /path/to/cache levels=1:2 keys_zone=my_cache:10m max_size=10g inactive=60m use_temp_path=off;
server {
proxy_cache mycache;
location / {
proxy_pass http://localhost:8000;
}
}
}
(3)Redis
maxmemory=num # 最大缓存量 一般为内存的3/4
maxmemory-policy allkeys lru # 缓存淘汰策略
- allkeys-lru:从所有key中淘汰最近最少使用(推荐);
- volatile-lru:从设置过期时间的key中淘汰最急最少使用;
- allkeys-random:从所有key中随机淘汰(希望请求符合平均分布);
- 自己控制:volatile-ttl(缓存穿透);
- 禁止驱逐:用作DB不设置maxmemory。
1.5、key数量
官方给出Redis单例能处理key:2.5亿个。一个key或value最大大小是512M。
1.6、缓存预热
缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免用户在请求的时候,先查询数据库z再加载到缓存,用户直接查询被预热的缓存数据。
- 数据量不大,可以在项目启动的时候自动进行加载;
- 利用定时任务刷新缓存,将数据库的数据刷新到缓存中。
2、缓存问题
2.1、缓存穿透
缓存穿透是指在高并发下查询key不存在的数据,会穿过缓存查询数据库,导致数据库压力过大而宕机。
解决方案:
- 对查询结果为空的情况也进行缓存,缓存时间设置的短一点或者该key对应的数据插入了之后再清除空缓存。问题:缓存太多空值(各种乱七八糟key的)占用了更多的空间。
- 使用布隆过滤器。在缓存之前加一层布隆过滤器,在查询的时候先去布隆过滤器查询key是否存在,如果不存在就直接返回,存在再查询缓存和DB。
2.2、缓存雪崩
当缓存服务器重启或大量缓存集中在某一个时间段失效,这样在缓存失效的时候,也会给后端服务(DB)带来很大的压力。
解决方案:
- key的失效期分散开,不同的key设置不同的有效期;
- 设置二级缓存(数据可能不一致);
- 高可用(脏读)。
2.3、缓存击穿
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发的访问,是一种非常“热点”的数据,这时,要考虑缓存被“击穿”的问题,他和缓存雪崩的区别在于这里针对某一个key缓存,前者是很多key。缓存在某个时间点过期的时候,恰好在这个时间点对这个key有大量的并发请求过来,这些请求发现缓存过期都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能瞬时把DB压垮。
解决方案:
- 用分布式锁控制线程的访问(单线程访问redis,这样第一个请求到达数据库后就会重新写入缓存,后续的请求就可以直接读取缓存):使用setnx互斥锁进行判断,其它线程就会处于等待状态,保证不会有大并发操作数据库;
- 不设置超时时间,volatile-lru但会造成写一致性问题。当数据库数据发生更新时,缓存中的数据不会及时更新,这样会造成数据库中的数据与缓存中的数据不一致,应用会从缓存中去取到脏数据。可以采用延时双删策略处理。
2.4、数据不一致
缓存和DB的数据不一致的根源:数据源不一样。如何解决?强一致性很难,追求最终一致性。先更新DB在更新缓存还是先更新缓存在更新DB,本质上不是一个原子操作,所以时序控制不可行,高并发下始终会产生不一致。
(1)延时双删
- 首次删除:在更新数据库之前,先从缓存中删除相关数据。
- 防止在更新过程中读到旧缓存数据。
- 更新数据库:执行数据库更新操作。
- 延时:等待一段时间,以确保所有正常的读请求都已经被处理。
- 为了处理在第一次删除后到数据库更新完成这段时间内的读请求。
- 第二次删除:延时后再次从缓存中删除数据。
- 为了清除在延时期间由于直接读取数据库而重新加载到缓存中的可能已经过时的数据。
(2)乐观锁形式更新
延时双删,两次删除会出现两次缓存不存在的击穿;延时删除也会存在一定时间的数据不一致。
采用乐观锁形式的更新,只删除一次缓存,也不用延时删除。减少一次风险,及时更新数据。
- 乐观锁更新数据库
- 更新后立马删除缓存,更新操作完成
- 读操作,缓存为空,读库,读库后采用setnx的方式设置值(缓存为空才写入)
- 如果写入成功,则更新成功
- 如果写入失败,则读并发,有一个读操作已经提前写入了数据,此时需要对比当前数据库的数据和缓存数据的版本号,如果数据库更新,则使用set覆盖旧缓存数据,如果缓存更新,则不处理
- 返回版本号最新的数据。
2.5、数据并发竞争
这里指的是多个redis客户端同时set一个key引起的并发问题。
(1)分布式锁+时间戳
设置一个分布式锁,多个客户端去竞争锁,抢到锁就set操作,加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。
(2)利用消息队列
在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。把Redis的set操作放在队列中使其串行化,必须一个一个的执行。
2.6、Hot Key
当有大量的请求(几十万)访问某个key时,由于流量集中达到网络上限,从而导致redis服务宕机。造成缓存击穿,接下来对这个key的访问将直接访问数据库造成数据库崩溃。
如何发现热key?
- 预估热key,比如秒杀的商品,火爆的新闻等;
- 在客户端进行统计,实现简单,加一行代码即可;
- 如果是Proxy,比如Codis,可以在Proxy端收集;
- 利用Redis自带的命令,monitor、hotkeys,但是执行缓慢(不建议用);
- 利用基于大数据领域的流式计算技术进行实时统计,比如storm、spark streaming、flink等都是可以的。
如何处理热Key?
- 变分布式缓存为本地缓存:发现热key后,把数据直接加载到本地缓存中,使用GuavaCache等(不强要求数据实时一致);
- 在每个Redis主节点上备份热key数据,读取时随机读取,将访问压力负载到每个Redis上;
- 利用对热点数据访问的限流熔断保护措施:每个系统实例每秒最多请求缓存集群读操作不超过400次,一超过就可以熔断掉,不让请求缓存集群,直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面(不能用于首页,不友好)。
2.7、Big Key
Big Key指的是存储的值(value)非常大,常见场景:
- 热门话题下的讨论;
- 大V粉丝列表;
- 序列化后的图片;
- 没有及时处理的垃圾数据
- ... ...
大key的影响:
- 大key会大量占用内存,在集群中无法均衡;
- Redis性能下降,主从复制异常;
- 在主动删除或过期删除时会造成操作时间过长而引起服务阻塞。
如何发现大key?
- redis-cli --bigkeys命令,五种数据类型(string、hash、list、set、zset)的最大key(如果redis的key比较多,该命令会执行缓慢);
- 获取redis的rdb文件,通过rdbtools分析rdb生成csv文件,再倒入mysql或其他数据库中,根据size_in_bytes统计bigkey。
如何处理大key?
优化bigkey的原则就是:string减少字符串长度,list、hash、set、zset减少成员数。
- string类型的bigkey,尽量不要存入redis中,可以使用文档型数据库MongoDB或缓存到SDN上。如果必须使用redis,最好单独存储,不要和其它key一起存储;
- 单个简单的key如果value很大,可以尝试将对象拆分成几个key-value,使用mget获取,这样拆分的意义在于分拆单次操作的压力,将压力平摊到多次操作中,降低对IO的影响;
- hash、set、zset、list中存储过多的元素,可以将这些元素拆分;
- 删除大key时不要使用del,因为del命令是阻塞的,删除时会影响性能,使用lazy delete(unlink命令)。
3、分布式锁
3.1、WATCH
利用watch可实现redis乐观锁。
乐观锁基于CAS思想(比较替换)实现,是不具有互斥性,不会产生锁等待而消耗资源,但是需要反复重试,但也是因为重试的机制,能比较快的响应。
利用redis的watch实现乐观锁的思路如下:
- 利用redis的watch功能,监控这个redisKey的状态值;
- 获取redisKey的值;
- 创建redis事务;
- 给这个key的值+1;
- 然后去执行这个事务,如果key的值被修改则回滚,key不加1。
Redis乐观锁实现秒杀
public class Second {
public static void main(String[] arg) {
String redisKey = "lock";
ExecutorService executorService = Executors.newFixedThreadPool(20);
try {
Jedis jedis = new Jedis("127.0.0.1", 6378);
// 初始值
jedis.set(redisKey, "0");
jedis.close();
} catch (Exception e) {
e.printStackTrace();
}
for (int i = 0; i < 1000; i++) {
executorService.execute(() -> {
Jedis jedis1 = new Jedis("127.0.0.1", 6378);
try {
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人秒杀成功,秒杀结束");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis1.close();
}
});
}
executorService.shutdown();
}
}
3.2、setnx
(1)实现原理
利用redis的单线程特性对共享资源进行串行化处理。
(2)实现方式
获取锁
方式一(使用set命令) --推荐
/**
* 使用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;
}
方式二(使用setnx命令) --并发会产生问题
public boolean getLock(String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if(result == 1) {
//成功设置 进程down 永久有效 别的进程就无法获得锁
jedis.expire(lockKey, expireTime);
return true;
}
return false;
}
释放锁
方式一(使用del命令) --并发会产生问题
/**
* 释放分布式锁
* @param lockKey
* @param requestId
*/
public static void releaseLock(String lockKey, String requestId) {
if (requestId.equals(jedis.get(lockKey))) {
jedis.del(lockKey);
}
}
问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。比如:客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del方法,则将客户端B的锁给解除了。
方式二(使用redis+lua脚本实现) --推荐
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;
}
(3)存在问题
单机:无法保证高可用。
主从:无法保证数据的强一致性,在主机宕机时会造成锁的重复获得。
无法续租:超过expireTime后,不能继续使用。
(4)本质分析
CAP模型分析,在分布式环境下只能满足三者共存,只能满足其中的两者共存,在分布式下P不能舍弃(舍弃P就是单机了),所以只能是CP(强一致性模型)和AP(高可用模型)。分布式锁是CP模型,Redis集群是AP模型。根据场景,当业务不需要数据强一致性时,比如:社交就可以使用redis实现分布式锁,当业务要求必须强一致性时,比如:金融场景就不要使用。
3.3、Redission分布式锁的使用
Redission是架设在Redis基础上的一个Java驻内存数据网格。Redisson在基于NIO的Netty框架上,生产环境是用分布式锁。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.7.0</version>
</dependency>
Redission配置类
public class RedissonManager {
private static Config config = new Config();
// 声明redisso对象
private static Redisson redisson = null;
// 实例化redisson
static{
config.useClusterServers()
// 集群状态扫描间隔时间,单位是毫秒
.setScanInterval(2000)
//cluster方式至少6个节点(3主3从,3主做sharding,3从用来保证主宕机后可以高可用)
.addNodeAddress("redis://127.0.0.1:6379" )
.addNodeAddress("redis://127.0.0.1:6380")
.addNodeAddress("redis://127.0.0.1:6381")
.addNodeAddress("redis://127.0.0.1:6382")
.addNodeAddress("redis://127.0.0.1:6383")
.addNodeAddress("redis://127.0.0.1:6384");
//得到redisson对象
redisson = (Redisson) Redisson.create(config);
}
//获取redisson对象的方法
public static Redisson getRedisson(){
return redisson;
}
}
锁的释放和获取
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();
}
}
业务逻辑中使用分布式锁
public String discount() throws IOException{
String key = "lock001";
//加锁
DistributedRedisLock.acquire(key);
//执行具体业务逻辑
do soming
//释放锁
DistributedRedisLock.release(key);
//返回结果
return soming;
}
Redission分布式锁的实现原理
3.4、分布式锁的特性
- 互斥性:任一时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁;
- 同一性:锁只能被持有该锁的客户端删除,不能由其它客户端删除;
- 可重入性:持有锁的客户端可以继续对该锁加锁,实现锁的续租;
- 容错性:锁失效后(超过时间限制)自动释放锁(key失效),其它客户端可以继续获得该锁,防止死锁。
3.5、分布式锁的实际应用
- 数据并发竞争:利用分布式锁可以将并发场景串行化处理;
- 防止库存超卖:谁拿到锁谁先购买,还是串行化的处理,此种方式效率低,不适合秒杀场景,秒杀可以使用CAS和Redis队列的方式实现。
秒杀场景的锁问题
TPS:是Transactions Per Second的缩写,也就是事务数/秒。它是软件测试结果的测量单位。一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数。
QPS:是Queries Per Second的缩写,意思是每秒查询率,是一台服务器每秒能够相应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。
区别及理解:
1、TPS即每秒处理事务数,包括:”用户请求服务器”、”服务器自己的内部处理”、”服务器返回给用户”,这三个过程,每秒能够完成N个这三个过程,TPS也就是N;
2、QPS基本类似于TPS,但是不同的是,对于一个页面的一次访问,形成一个TPS;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入QPS之中。
3、一般的,评价系统性能均以每秒钟完成的技术交易的数量来衡量。系统整体处理能力取决于处理能力最低模块的TPS值。
4、QPS对应fetches/sec,即每秒的响应请求数,也即是最大吞吐能力。