Redis 一篇足以

Redis 简介

  • 数据是存在内存中的,支持持久化,主要用作备份恢复。
  • 支持简单的key-value模式,还支持多种数据结构的存储,比如 list(链表)、set(集合)、zset(有序集合)和hash(哈希类型)。
  • 这些数据类型都支持push/pop、add/remove和取交集并集和差集等,而且这些操作都是原子性的。
  • 为了保证效率,数据的存储与memcached一样,都是缓存在内存当中的。
  • 一般是作为缓存数据库辅助持久化的数据库。
  • 支持各种不同方式的排序。

应用场景:高频次、热点访问的数据,降低数据库IO;分布式架构,做session共享等


Redis 键(key)

keys *:查看当前库所有key。
exists <key>:判断某个key是否存在。
type <key>:查看你的key是什么类型。
del <key>:删除指定的key数据。
unlink <key>:根据value选择非阻塞删除,也就是异步删除。
expire <key> 10:为给定的key设置过期时间(秒)。
ttl <key>:查看还有多少秒过期,-1表示永不过期,-2表示已过期。
select <dbid>:命令切换数据库。如:select 8
dbsize:查看当前数据库的key的数量。
flushdb:清空当前库。
flushall:通杀全部库。

Redis 字符串(String)

String是Redis最基本的类型,一个key对应一个value;类型是二进制安全的,能够储存jpg图片或者序列化的对象。内部结构实现上类似于Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。Redis中字符串value最多可以储存512MB

set <key> <value>:添加键值对。
get <key>:查询对应键值。
append <key> <value>:将给定的 value 值追加到原值的末尾。
strlen <key>:获得值的长度。
setnx <key> <value>:只有在 key 不存在时 设置 key 的值。
incr <key>:将 key 中储存的数字值增1。(只能对数字值操作,如果为空,新增值为1)
decr <key>:将 key 中储存的数字值减1。(能对数字值操作,如果为空,新增值为-1)
incrby/decrby <key> <number>:将 key 中储存的数字值增减。自定义增长的值。
mset <key1> <value1> <key2> <value2>:同时设置一个或多个 key-value对。
mget <key1> <key2> <key3>:同时获取一个或多个 value。
getrange <key> <起始位置> <结束位置>:获得值的范围,类似java中的substring:前包,后包。
setrange <key> <起始位置> <value>:用 value 覆写 key 所储存的字符串值,从起始位置开始(索引从0开始)。
setex <key> <过期时间> <value>:设置键值的同时,设置过期时间,单位秒。
getset <key> <value>:以新换旧,设置了新值同时获得旧值。

Redis 列表 (List)

Redis 列表是按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。它的底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。

请添加图片描述

lpush/rpush <key> <value1> <value2> <value3>:从左边/右边插入一个或多个值。
lpop/rpop <key>:从左边 / 右边吐出一个值。一旦取出一个值,建也会相应的去除。(值在键在,值亡键亡)
rpoplpush <key1> <key2>:从 key1 列表右边吐出一个值,插到 key2 列表左边。
lrange <key> <start> <stop>:按照索引下标获得元素。(从左到右)
lrange mylist 0 -1:0左边第一个,-1右边第一个,(0 -1表示获取所有)
lindex <key> <index>:按照索引下标获得元素(从左到右)
llen <key>:获得列表长度。
linsert <key> before/after <value> <newvalue>:在 value 的后面(左)/前面(右)插入 newvalue 值。
lrem <key> <n> <value>:从左边删除n个value。(从左到右)
lset<key> <index> <value>:将列表key下标为index的值替换成value。

Redis 集合 (Set)

Redis set对外提供的功能与list类似;特殊之处在于set是可以自动排重的、无序集合、不可重复数据,并且set提供了判断某个成员是否存在于集合中的重要接口,这也是list所不能提供的。
Redis的Set是string类型的无序集合。它底层其实是一个value为null的hash表。

sadd <key> <value1> <value2>:将一个或多个 member 元素加入到集合 key 中,已经存在的 member 元素将被忽略。
smembers <key>:取出该集合的所有值。
sismember <key> <value>:判断集合 key 是否为含有该 value 值,有1,没有0。
scard <key>:返回该集合的元素个数。
srem <key> <value1> <value2>:删除集合中的某个元素。
spop <key>:随机从该集合中吐出一个值。
srandmember <key> <n>:随机从该集合中取出n个值。不会从集合中删除 。
smove <source> <destination>:value把集合中一个值从一个集合移动到另一个集合。
sinter <key1> <key2>:返回两个集合的交集元素。
sunion <key1> <key2>:返回两个集合的并集元素。
sdiff <key1> <key2>:返回两个集合的差集元素。(key1中的,不包含key2中的)

Redis 哈希 (Hash)

Redis hash 是一个键值对集合,hash特别适合用于存储对象;类似Java里面的Map<String,Object>
Hash类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当 field-value 长度较短且个数较少时,使用ziplist,否则使用hashtable。

有2种存储方式

  • 序列化 value 对象:每次修改用户的某个属性需要,先反序列化改好后再序列化回去。开销较大。
    设置唯一的 key(用户ID + 对象字段名):这样使得数据变得冗余。
    请添加图片描述

  • 使用 field 属性标签:通过 key(用户ID) + field(属性标签) 就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。
    请添加图片描述

hset <key> <field> <value>:给 key 集合中的 field 键赋值 value 。
hget <key> <field>:从 key 集合 field 取出 value 。
hmset <key1> <field1> <value1> <field2> <value2> ...: 批量设置 hash 的值。
hexists <key> <field>:查看哈希表 key 中,给定域 field 是否存在。
hkeys <key>:列出该 hash 集合的所有 field。
hvals <key>:列出该 hash 集合的所有 value。
hincrby <key> <field> <increment>:为哈希表 key 中的域 field 的值加上增量 1 -1。
hsetnx <key> <field> <value>:将哈希表 key 中的域 field 的值设置为 value,当且仅当域 field 不存在。

Redis 有序集合 Zset (sorted set)

Redis 有序集合 zset 与普通集合 set 非常相似,是一个没有重复元素的字符串集合。不同之处是有序集合的每个成员都关联了一个评分(score),这个评分(score)被用来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分可以是重复了 。也可以很快的根据评分(score)或者次序(position)来获取一个范围的元素。

zadd <key> <score1> <value1> <score2> <value2> …:将一个或多个 member 元素及其 score 值加入到有序集 key 当中。

zrange <key> <start> <stop> [WITHSCORES]: 返回有序集 key 中,下标在 start stop 之间的元素带WITHSCORES,可以让分数一起和值返回到结果集。

zrangebyscore key minmax [withscores] [limit offset count]:返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)次序排列。

zrevrangebyscore key maxmin [withscores] [limit offset count]:同上,改为从大到小排列。

zincrby <key> <increment> <value>:为元素的score加上增量。

zrem <key> <value>:删除该集合下,指定值的元素。

zcount <key> <min> <max>:统计该集合,分数区间内的元素个数 。

zrank <key> <value>:返回该值在集合中的排名,从0开始。

SortedSet(zset) 是 Redis 提供的一个非常特别的数据结构,一方面它等价于 Java 的数据结构 Map<String, Double>,可以给每一个元素 value 赋予一个权重 score,另一方面它又类似于 TreeSet,内部的元素会按照权重 score 进行排序,可以得到每个元素的名次,还可以通过 score 的范围来获取元素的列表。

zset底层使用了两个数据结构

  • hash的作用就是关联元素 value 和权重 score,保障元素 value 的唯一性,可以通过元素 value 找到相应的 score 值。
  • 跳跃表的目的在于给元素 value 排序,根据 score 的范围获取元素列表。

Redis 配置文件介绍

Units 单位

配置大小单位,开头定义了一些基本的度量单位,只支持bytes,不支持bit(大小写不敏感)。

# 1k => 1000 bytes
# 1kb => 1024 bytes
# 1m => 1000000 bytes
# 1mb => 1024*1024 bytes
# 1g => 1000000000 bytes
# 1gb => 1024*1024*1024 bytes

INCLUDES包含

可以将多个配置文件放入到一个公共的配置文件中

因为 Redis 总是使用最后处理的行作为配置指令的值,最好把 include 放在这个文件的开头,以避免在运行时覆盖配置更改。相反,如果使用 include 覆盖配置,最好将 include 作为最后一行。

################################## INCLUDES ###################################
# include .\path\to\local.conf
# include c:\path\to\other.conf

网络相关配置

################################## NETWORK #####################################

# 访问的主机地址。如果没有 bind,就是任意 ip 地址都可以访问。生产环境下,需要写自己应用服务器的 ip 地址。
# 把 redis.conf 配置文件中的 bind 127.0.0.1 这一行给注释掉,这里的 bind 指的是只有指定的网段才能远程访问这个redis,注释掉后,就没有这个限制了。
# bind 127.0.0.1

# 保护模式。如果没有指定 bind 指令,也没有配置密码,那么保护模式就开启
# 把 redis.conf配置文件中的 protected-mode 设置成no(默认是设置成yes的, 防止了远程访问,在redis3.2.3版本后)
protected-mode no

# 端口号,默认6379
port 6379

# TCP listen() backlog.
# 在每秒请求数较高的环境中,您需要较高的backlog以避免客户端连接速度变慢的问题。
# 请注意,Linux内核会将其截断为/proc/sys/net/core/somaxconn的值(128),
# 因此请确保同时提高somaxconn和tcp_max_syn_backlog的值(128),以便获得预期的效果。
tcp-backlog 511

# Unix socket.
# 指定用于监听传入连接的Unix套接字的路径。
# 没有默认值,所以Redis不会在没有指定的情况下监听unix套接字。
# unixsocket /tmp/redis.sock
# unixsocketperm 700
# 在客户端空闲N秒后关闭连接(0表示禁用)  
timeout 0

# TCP keepalive.
# 如果为非零,则在不通信的情况下,使用SO_KEEPALIVE向客户端发送TCP ack。 这很有用,有两个原因:  
# 1) Detect dead peers.
# 2) Take the connection alive from the point of view of network
#    equipment in the middle.
#
# 在Linux操作系统下,该值为发送ack报文的时间间隔(单位为秒)  .
# 注意,关闭连接需要双倍的时间。 在其他内核上,周期取决于内核配置。 这个选项的合理值是60秒。
tcp-keepalive 0

常规配置

################################# GENERAL #####################################

# 默认情况下,Redis不会作为守护进程运行(后台启动)。 如果你需要,用“yes”。
# 注意:Redis会在/var/run/ Redis中写入一个pid文件。 当监控pid。  
# 当Redis被upstart或systemd监控时,此参数没有影响。 
daemonize no

# 如果指定了pid文件,Redis会在启动时将其写入指定的位置,并在退出时将其删除。
# 当服务器运行非守护进程时,如果配置中没有指定pid文件,则不会创建pid文件。 当服务器被守护进程化时,即使没有指定pid文件也会使用,默认值为“/var/run/redis.pid”。  
pidfile /var/run/redis_6379.pid

# 指定日志记录级别,Redis总共支持四个级别:debug、verbose、notice、warning,默认为notice
loglevel notice

# 指定日志文件名。
logfile ""

# 设定库的数量 默认16,默认数据库为0,可以使用SELECT <dbid>命令在连接上指定数据库id
databases 16

# 设置redis同时可以与多少个客户端进行连接。
# 默认情况下为10000个客户端。
# 如果达到了此限制,redis则会拒绝新的连接请求,并且向这些连接请求方发出“max number of clients reached”以作回应。
maxclients 10000

# (建议必须设置,否则,将内存占满,造成服务器宕机)
# 设置redis可以使用的内存量。一旦到达内存使用上限,redis将会试图移除内部数据,移除策略可以通过maxmemory-policy属性来指定。
# 如果Redis不能根据策略删除键,或如果策略是设置为'noeviction',那么redis则会针对那些需要申请内存的指令返回错误信息,比如SET、LPUSH等。
# 但是对于无内存申请的指令,仍然会正常响应,比如GET等。如果你的redis是主redis(说明你的redis有从redis),那么在设置内存使用上限时,需要在系统中留出一些内存空间给同步队列缓存,只有在你设置的是'noeviction'的情况下,才不用考虑这个因素。
maxmemory <bytes>

# volatile-lru -> 使用LRU算法移除key,只对设置了过期时间的键
# allkeys-lru -> 在所有集合key中,使用LRU算法移除key
# volatile-random -> 在过期集合中移除随机的key,只对设置了过期时间的键
# allkeys-random -> 在所有集合key中,移除随机的key
# volatile-ttl -> 移除那些TTL值最小的key,即那些最近要过期的key
# noeviction -> 不进行移除。针对写操作,只是返回错误信息
maxmemory-policy noeviction

# 设置样本数量,LRU算法和最小TTL算法都并非是精确的算法,而是估算值,所以你可以设置样本的大小,redis默认会检查这么多个key并选择其中LRU的那个。
# 默认值为5会产生足够好的结果。10非常接近真实的LRU,但花费更多的CPU。3更快,但不太准确。  
# 一般设置3到7的数字,数值越小样本越不准确,但性能消耗越小。
maxmemory-samples 5

Redis 发布和订阅

  • Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。
  • Redis 客户端可以订阅任意数量的频道。

subscribe <channel>: 客户端订阅频道。
pudlish <channel> <message>:另一个客户端给 <channel> 发送消息。返回的值是订阅当前<channel>的数量。

注意:发布的消息市没有持久化的,只能接收到订阅后所发布的消息。

Geospatial(GEO)

Redis 3.2 中增加了对GEO类型的支持。GEO:Geographic 地理信息的缩写。该类型,就是元素的2维坐标,在地图上就是经纬度。redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度Hash等常见操作。
一般会下载城市数据,直接通过 Java 程序一次性导入所有地区的经纬度信息。
有效的经度从 -180 度到 180 度。有效的纬度从 -85.05112878 度到 85.05112878 度。

geoadd <key> <longitude> <latitude> <member>:添加地理位置:经度、纬度、名称。
geopos <key> <member>:获得指定地区的经纬度值。
geodist <key> <member1> <member2> <m|km|ft|mi>:获取两个地区之间的直线距离。

m:表示单位为米(默认值)。
km:表示单位为千米。
mi:表示单位为英里。
ft:表示单位为英尺。

georadius <key> <longitude> <latitude> <radius> <m|km|ft|mi> [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]:以给定的经纬度为中心,找出某一半径内的元素。(经度 纬度 距离 单位)
georadiusbymember <key> <member> <radius> <m|km|ft|mi> [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]:与 georadius 命令一样,都可以找出位于指定范围内的元素, 但是它的中心点是由给定的位置元素决定的,而不是使用经纬度来决定中心点。

WITHCOORD: 将位置元素的经度和纬度也一并返回。
WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。
WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
COUNT 限定返回的记录数。
ASC: 查找结果根据距离从近到远排序。
DESC: 查找结果根据从远到近排序。

geohash <key> <member>:用于获取一个或多个位置元素的 geohash 值。


Redis 事务

单独隔离操作:事务中所有的命令都会序列化,按顺序执行。事务在执行过程中,不会被其他客户端发来的命令请求打断。

没有隔离级别的概念:队列中的命令在没有提交之前都不会实际的执行,因为事务提交前任何命令都不会实际执行。

不保证原子性:事务中如果有一条命名执行失败,其他的命令仍会执行。并不会因为命令的执行失败,而进行回滚。


Lua 脚本

  1. 将复杂的或者多种 Redis 操作,写为一个脚本,一次提交给 Redis 执行,减少反复连接 Redis 的次数,提升性能。
  2. Lua 脚本是类似 Redis 事务,有原子性,不被其它命令插队,可以完成 Redis 事务性的操作
  3. 利用 Lua 脚本淘汰用户,解决超卖问题。使用 Lua 脚本解决争抢问题,实际上是 Redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。

Redis 持久化(RDB)

Redis 提供了2种不同形式的持久化方式:RDB(Redis DataBase)AOF(Append Only File)

什么是 RDB (Redis DataBase)

在指定的时间间隔内将内存中的数据集快照写入磁盘中,恢复时将快照文件直接读到内存里。

1)RDB 备份是如何执行的
Redis 会单独创建 Fork 进程来进行持久化,会先将数据写入到一个临时文件中,待持久化过程结束,在用这个临时文件替换上次持久化好的文件,整个过程中,主进程是不进行任何 IO 操作的,这就确保了极高的新能,如果需要进行大规模数据恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式要比 AOF 方式更加的高效。RDB 的缺点是最后一次持久化后的数据可能丢失。

2)Fork 分支

  • Fork 的作用是复制一个与主线程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致。并作为原进程的子进程。
  • 一般情况父进程和子进程会共用同一段物理内存,只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程

在这里插入图片描述

什么是 AOF (Append Only File)

以日志的形式来记录每个写操作(增量保存),将 Redis 执行过的所有写指令记录下来(读操作不记录), 只许追加文件但不可以改写文件,Redis 重启的话会根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。

1)AOF 持久化流程

  • 客户端的请求写命令会被 append 追加到 AOF 缓冲区内;
  • AOF 缓冲区根据 AOF 持久化策略(alwayseverysecno)将操作 Sync 同步到磁盘的 AOF 文件中;
  • AOF 文件大小超过重写策略或手动重写时,会对 AOF 文件 rewrite 重写,压缩 AOF 文件容量;
  • Redis 服务重启时,会重新 Load 加载 AOF 文件中的写操作达到数据恢复的目的;

2)Rewrite 压缩

AOF采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制, 当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩, 只保留可以恢复数据的最小指令集。

在这里插入图片描述

Redis 持久化配置

################################ SNAPSHOTTING  ################################
#   快照保存策略:
#   在900秒(15分钟)后,如果至少有一个键改变 
#   在300秒(5分钟)后,如果至少有10个键改变 
#   60秒后,如果至少有10000个密钥改变
save 900 1
save 300 10
save 60 10000

# 转储数据库的文件名 默认为:dump.rdb
dbfilename dump.rdb

# rdb文件的保存路径,也可以修改。默认为Redis启动时命令行所在的目录下。注意:这里必须指定目录,而不是文件名。
dir ./

# 当Redis无法写入磁盘的话,直接关掉Redis的写操作。推荐yes.
stop-writes-on-bgsave-error yes

# 对于存储到磁盘中的快照,可以设置是否进行压缩存储。如果'yes'的话,redis会采用LZF算法进行压缩。
# 如果你不想消耗CPU来进行压缩的话,可以设置为'no'关闭此功能。推荐yes.
rdbcompression yes

# 在存储快照后,还可以让redis使用CRC64算法来进行数据校验
# 但是这样做会增加大约10%的性能消耗,如果希望获取到最大的性能提升,可以关闭此功能'no'。推荐'yes'
rdbchecksum yes
############################## APPEND ONLY MODE ###############################
# AOF 默认不开启,若 AOF 与 RDB 同时开启,系统默认取 AOF 的数据
appendonly no

# 仅追加文件的名称(默认:"appendonly.aof"),AOF 保存路径,于 RDB 路径一致。
appendfilename "appendonly.aof"

# no: redis不主动进行同步,把同步时机交给操作系统。
# always: 始终同步,每次Redis的写入都会立刻记入日志;性能较差但数据完整性比较好
# everysec: 每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失。
appendfsync everysec

Redis 主从复制

主从复制是什么

主机数据更新后根据配置和策略,自动同步到从机的 master/salver 机制,Master 以写为主,Salver 以读为主。

主从复制的优点

  • 读写分离,性能扩展
  • 容灾快速恢复

Redis 主从配置

1)拷贝多个redis.conf文件(redis6379.confredis6380.confredis6381.conf),并以下为例进行配置。

# redis 配置文件(写绝对路径)
include "/myredis/redis.conf"

# 修改端口号,一主多从模式,默认是以6379为主,6380、6381为从
port 6379

# 开启守护进程模式运行,默认是no
daemonize yes

# 修改pid文件名,我是以端口来区分的
pidfile "/var/run/redis_6379.pid"

# log文件名,以端口命名,放在log目录下
logfile "./log/6379.log"

# dump.rdb文件名,存储内存中的快照
dbfilename "dump_6379.rdb"

# dump文件的目录
dir "/usr/local/redis/dump"

# 关掉 AOF,若打开则修改 appendonly 名称
appendonly yes
appendfilename "appendonly_6379.aof"

# 设置从机的优先级,值越小,优先级越高,用于选举主机时使用。默认100
slave-priority 10

2)启动三台 Redis 服务器:redis-server redis6379.confredis-server redis6380.confredis-server redis6381.conf

3)查看系统进程,三台 Redis 服务器是否启动成功:ps -ef | grep redis

4)进入 Redis 客户端查看三台主机运行情况:redis-cli -p 6379info replication;启动后默认角色都为master,稍后在配置slave

5)配置从机指定那个主机:在端口为6380、6381的客户端上执行 slaveof 127.0.0.1 6379 建立主从关系。

# 从机指定主机IP于端口,成为某个实例的从服务器
slaveof <IP> <port>

在主机上写入,可以在从机上读取数据,不能在从机上写数据。若主机挂掉后重启就能恢复。若是从机挂了重启需要重新配置主机 IP 与 port。

在这里插入图片描述


Redis 哨兵模式(Sentinel)

什么是哨兵模式

哨兵模式是一个建立在主从复制模式的独立进程,能够后台监控主机是否宕机,如果宕机了则根据票数自动在从库中选举主库。

哨兵配置文件

#### sentinel.conf ####

# master-name 主机的名称
# ip 主机的IP地址
# redis-port 主机端口号
# quorum 配置的多少个哨兵统一认为master主机失联,则从客观上认为主机已失联。
# mymaster为监控对象起的服务器名称,1 为至少有多少个哨兵同意迁移的数量。
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor mymaster 127.0.0.1 6379 1

# 哨兵 Sentinel 端口号。默认:26379
port 26379

# 默认情况下,Redis Sentinel不作为守护进程运行。 如果你需要用“yes”。
daemonize no

# 当运行daemonized时,Redis Sentinel写入一个pid文件/var/run/redis-sentinel。 默认情况下pid。 您可以在这里指定一个自定义pid文件位置。  
pidfile /var/run/redis-sentinel.pid

# Redis Sentinel 的工作目录
dir /tmp

# sentinel 与 主机 失联时间,若超过指定时间,则认定为失联。默认为:30 秒
# sentinel down-after-milliseconds <master-name> <milliseconds>
sentinel down-after-milliseconds mymaster 30000

启动哨兵:redis-sentinel sentinel.conf

当主机宕机后,会在从机中选举新的主机(slave-priority优先级),当原主机重启后会变成从机。

故障恢复流程

1)从已下线的主机里的所有从机中挑选出主机。

选举条件依次为:

  • 选择优先级靠前:slave-priority
  • 选择偏移量最大:获得原主机数据最全的。
  • 现在 runid 最小:当 Redis 实例启动后会随机生成一个40位的 runid。

2)挑选出新主机之后,sentinel 向原主机下的从机发送 slaveof 新主机的命令,并复制 master。

3)当已下线的服务重新上线时,sentinel 会向其发送 slaveof 命令,让其成为新主机的从机。

在这里插入图片描述


Redis 集群

Redis 集群实现了对 Redis 的水平扩容,即启动 N 个 Redis 节点,将整个数据库分布存储在这 N 个节点中,每个节点存储总数据的 1/N。Redis 集群通过分区来提供一定程度的可能性;即使集群中有一部分节点失效或者无法进行通讯,集群也可以继续处理命令请求。

优点:实现扩容、并发压力分摊、无中心配置相对简单。
缺点:多键操作不被支持、多键的事务不被支持、Lua 脚本不被支持。

Redis Cluster 配置

################################ REDIS CLUSTER  ###############################
# 打开集群模式
cluster-enabled yes

# 设定节点配置文件名,确保在同一系统中运行的实例没有重叠的集群配置文件名称。 
cluster-config-file nodes-6379.conf

# 设定节点失联时间,超过该事件(毫秒),集群自动进行主从切换。
cluster-node-timeout 15000

# yes 当某一段插槽的主从都挂掉了,那么整个集群都挂掉;
# no 当某一段插槽的主从都挂掉,该插槽数据全都不能使用,也无法储存。 默认为:yes
cluster-require-full-coverage yes

启动集群

1)启动多个 Redis 实例。

redis-server redis_6379.confredis-server redis_6380.confredis-server redis_6381.confredis-server redis_6382.confredis-server redis_6383.confredis-server redis_6384.conf

在组合之前,确保所有 Redis 实例启动后,查看当前目录 nodes-xxxx.conf 文件都生成正常。

2)组合集群

redis-cli --cluster create --cluster-replicas 1 192.168.1.111:6379 192.168.1.111:6380 192.168.1.111:6381 192.168.1.111:6382 192.168.1.111:6383 192.168.1.111:6384
  • --cluster create 创建集群;至少要六个节点,其中三个为主节点。
  • --cluster-replicas 1 采用最简单的方式配置集群,一台主机一台从机模式;如果设置为 2 (两台从机),则当前集群中必须要有三台主机,至少需要9个节点才可以。
  • IP 地址不要用127.0.0.1,需要使用真实 IP 地址,尽量保证每个主数据库运行在不同的 IP 地址,每个从库和主库不在一个 IP 地址上。

3)执行后系统提示:输入"yes"确认即可。

>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 192.168.50.116:6384 to 192.168.50.116:6383
Adding replica 192.168.50.116:6379 to 192.168.50.116:6380
Adding replica 192.168.50.116:6382 to 192.168.50.116:6381
>>> Trying to optimize slaves allocation for anti-affinity
[WARNING] Some slaves are in the same host as their master
M: abf5870b75e0bae7a95add0baa6af18a8141cd03 192.168.50.116:6383
   slots:[0-5460] (5461 slots) master
M: 46238cc8629a137350a7ab96d47dec57e9589230 192.168.50.116:6380
   slots:[5461-10922] (5462 slots) master
M: ee707d55270127aa2255d6c3b98d8dfe188ab672 192.168.50.116:6381
   slots:[10923-16383] (5461 slots) master
S: 0e2cfd7fb4169151ab0790bf4b36be38775b3bea 192.168.50.116:6382
   replicates ee707d55270127aa2255d6c3b98d8dfe188ab672
S: 96f664445a879e74a8d1c4dc0f568a804677a24e 192.168.50.116:6384
   replicates abf5870b75e0bae7a95add0baa6af18a8141cd03
S: 38ac27c653b77739e33ef69488c0c5823d0db19b 192.168.50.116:6379
   replicates 46238cc8629a137350a7ab96d47dec57e9589230
Can I set the above configuration? (type 'yes' to accept): yes

4)访问集群

redis-cli -c -p 6379
  • -c 集群模式在写入数据时会自动切换到相应的写主机。
  • -p 普通方式访问任意一个端口号,可能进入到读主机,在写入数据时,会出现 MOVED 重定向操作。

5)查询集群信息

cluster nodes

插槽 slots

1)一个 Redis 集群包含16384个插槽(hahs slot)数据库中的每个键都属于这16384个插槽的其中一个。集群使用公式 CRC16(key) % 16384 来计算键 key 属于哪个槽, 其中 CRC16(key) 语句用于计算键 key 的 CRC16 校验和 。

集群中的每个节点负责处理一部分插槽:

  • Redis Group A节点负责处理 0 ~ 5460 号插槽
  • Redis Group B节点负责处理 5461 ~ 10922 号插槽
  • Redis Group C节点负责处理 10923 ~ 16383 号插槽

在这里插入图片描述

2)在 redis-cli 每次录入、查询键值,Redis 都会计算出该 Key 应该送往的插槽,如果不是该客户端对应服务器的插槽,Redis 会报错,并告知应前往的 Redis 实例地址和端口。所以 redis-cli 客户端提供了 -c 参数实现自动重定向。

3)不在一个 slot 下的键值,是不能使用mget或者mset操作。但是可以通过{}来定义组的概念,从而使 Key 中 {}内容相同的键值对放到一个 slot 中去。

mset key1{group} value1 key2{group} value2 key3{group} value3

4)查询集群中的值
cluster keyslot <slotName> 返回 slotName 的插槽。
如:cluster keyslot group
cluster countkeysinslot <slot> 返回 slot 插槽中由几个键。
如:cluster countkeysinslot 12148
cluster getkeysinslot <slot> <count> 返回 count 个 slot 槽中的键。

在这里插入图片描述


Redisson 分布式锁

[1] Distributed locks with Redis
[2] Distributed locks with Redis 中文版

分布式锁特点

1)拥有互斥性,在多个客户端当中只能有一个客户端持有锁;
2)不会发生死锁,即使在某个客户端持有锁的期间内发生了宕机,并没有主动的去释放锁,也能保证后续其它客户端加锁。
3)加锁与解锁必须是用一个客户端,客户端自己不能把别人加的锁给释放了。
4)所有指令都是通过 Lua 脚本执行的,Lua 脚本的执行是具有原子性的。
5)具有容错性,只要大多数 Redis 节点正常运行,客户端就能够获取和释放锁。

WatchDog 看门狗机制

1)Redisson 设置一个 Key 的默认过期时间为 30s, 如果某个客户端在持有锁超过了 30s 怎么办,为了避免这种情况的发生,Redisson 内部提供了 WatchDog 的概念,它会在你持有锁的期间内,每隔 10s 帮你把 Key 的超时时间设为 30s。在 Redisson 实例被关闭之前,就算一直持有锁也不会出现 Key 过期。
2)Redisson 的 WatchDog 规避了死锁的发生。当持有锁的客户端宕机了,WatchDog 也会随着消失。此时只需要等待 Key 自动过期。

Redisson 还允许leaseTime在获取锁时指定参数。在指定的时间间隔后锁定的锁将自动释放。设置锁过期时间为 5s,如果设置的过期时间要小于业务处理的时间,此时手动的去释放锁会报错。

可重入锁 RLock

  • void lock():获取锁,如果锁不可用,则当前线程一直等待,直到获得到锁。
  • void lock(long leaseTime, TimeUnit unit):并设置锁过期时间。
  • void lockInterruptibly():和 lock() 方法类似,区别是 lockInterruptibly() 方法在等待的过程中可以被 interrupt 打断
  • void lockInterruptibly(long leaseTime, TimeUnit unit):并设置锁过期时间。
  • boolean tryLock():非阻塞等待获取锁,立即返回一个 boolean 类型的值表示是否获取成功。
  • boolean tryLock(long time, TimeUnit unit):并设置锁过期时间。
  • boolean tryLock(long waitTime, long leaseTime, TimeUnit unit):最多等待waitTime

可重入锁是阻塞的吗

// 获取锁,只要锁的名称相同,则获取的锁是同一把。
RLock lock = redisson.getLock("anyLock");
// 加锁。
lock.lock();
try {
    LOGGER.info("加锁成功执行业务,线程ID:{}", Thread.currentThread().getId());
    Thread.sleep(5000);
} finally {
    // 释放锁
    lock.unlock();
    LOGGER.info("释放锁,线程ID:{}", Thread.currentThread().getId());
}

从下图可以得知,Redisson 可重入锁是阻塞其它线程的,需要等待持有锁的线程释放锁。

在这里插入图片描述

分布式读写锁 RReadWriteLock

Redisson 分布式可重入读写锁 RReadWriteLock 实现了 java.util.current.locks.ReadWriteLock 接口。其中读锁与写锁都继承了 RLock 接口。
写锁:互斥锁
读锁:共享锁

  • 读锁 + 读锁:可以并发读,相当于没有加锁。
  • 读锁 + 写锁:写锁需要等待读锁释放锁。
  • 写锁 + 写锁:互斥性,需要等待对方释放锁。
  • 写锁 + 读锁:读锁需要等待写锁释放。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock")
rwlock.readLock().lock();
rwlock.writeLock().lock();

// 或 自己指定leaseTime加锁的时间,超过这个时间后便自动解开了。
rwlock.readLock().lock(5, TimeUnit.SECONDS);
rwlock.writeLock().lock(5, TimeUnit.SECONDS);

// 或 尝试加锁,最多等待100秒,上锁以后5秒自动解锁。
boolean isLock = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
boolean isLock = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);

// 释放锁
rwlock.writeLock().unlock();
rwlock.writeLock().unlock();

分布式信号量 Semaphore

在资源池中存在一部分共享的资源,多个线程可以从资源池里面去获取资源,如果资源被获取完,那么其他向获取资源的线程就需要等待,别人释放资源。

  • void acquire():阻塞等待,会将信号量里面的值 -1,如果为0,则一直等待直到信号量大于0
  • boolean tryAcquire():非阻塞等待,会将信号量里面的值 -1,如果为0,则返回 false
  • void release():释放信号量里面的值 +1
  • boolean trySetPermits(int permits):尝试设置permits个信号量。
  • void release(int permits):释放信号量里面的值加permits
  • void addPermits (int permits):按定义值增加或减少信号量的数量。
  • int availablePermits():返回可用信号量的数量。
  • int drainPermits():获取并返回所有立即可用的信号量。
RSemaphore semaphore = redisson.getSemaphore("mySemaphore");
// 获取信号量
semaphore.acquire();
// 获取10个信号量
semaphore.acquire(10);
// 尝试获取信号量
boolean res = semaphore.tryAcquire();
// 尝试获取信号量,最多等待15秒
boolean res = semaphore.tryAcquire(15, TimeUnit.SECONDS);
// 尝试获取10个信号量
boolean res = semaphore.tryAcquire(10);
// 尝试获取10个信号量,最多等待15秒
boolean res = semaphore.tryAcquire(10, 15, TimeUnit.SECONDS);
if (res) {
   try {
     ...
   } finally {
       // 释放信号量
       semaphore.release();
   }
}

单机案例

// 构造redisson实现分布式锁必要的Config
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.1.111:5379").setPassword("redis").setDatabase(0);
// 构造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 设置锁定资源名称
RLock disLock = redissonClient.getLock("DISLOCK");
boolean isLock;
try {
    //尝试获取分布式锁
    isLock = disLock.tryLock(500, 15000, TimeUnit.MILLISECONDS);
    if (isLock) {
        Thread.sleep(15000);
    }
} catch (Exception e) {
} finally {
    // 释放锁
    disLock.unlock();
}

哨兵案例

Sentinel 模式,实现代码和单机模式几乎一样,唯一的不同就是Config的构造:

Config config = new Config();
config.useSentinelServers().addSentinelAddress(
        "redis://192.168.1.111:26378","redis://192.168.1.111:26379", "redis://192.168.1.111:26380")
        .setMasterName("mymaster")
        .setPassword("redis").setDatabase(0);

集群案例

Config config = new Config();
config.useClusterServers().addNodeAddress(
        "redis://192.168.1.111:6379","redis://192.168.1.111:6380", "redis://192.168.1.111:6381",
        "redis://192.168.1.111:6382","redis://192.168.1.111:6383", "redis://192.168.1.111:6384")
        .setPassword("redis").setScanInterval(5000);

分布式案例

最核心的变化就是RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);

Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.1.111:6379")
        .setPassword("redis").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.1.111:6380")
        .setPassword("redis").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.1.111:6381")
        .setPassword("redis").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

String resourceName = "REDLOCK";
RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
    isLock = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
    if (isLock) {
        Thread.sleep(30000);
    }
} catch (Exception e) {
} finally {
    // 释放锁
    redLock.unlock();
}

缓存穿透

缓存穿透是指查询一个一定不存在的缓存数据,由于缓存是不命中的。将去查询数据库,但是数据库也没有此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要走 DB 查询。如果有人利用不存在的数据进行攻击,数据库压力过大,最终导致崩溃。

解决方案:

  • 采用布隆过滤器(Bloom Filter):在数据写入数据库的同时将这个 ID 同步到到布隆过滤器中,当请求的 ID 不存在布隆过滤器中则说明该请求查询的数据一定没有在数据库中保存,就不要去数据库查询了。

  • 缓存空:缓存数据不存在时,查询 DB将查询到的结果无论它是否为空,都将它写入缓存中,并给定一个短暂的过期时间。


缓存雪崩

缓存雪崩是指大量数据的 Key 采用了相同的过期时间,导致缓存在某一时刻大面积的失效,全部请求都转发进了 DB,DB 压力瞬间激增,最终导致宕机。

解决方案:

  • 过期时间添加随机值:要避免给大量数据设置相同的过期时间,在原有的失效时间上增加一个随机数,比如 1 - 5 分钟随机。这样每一个缓存的过期时间重复率就会降低,就很难引发集体失效的事件。

缓存击穿

高并发流量,访问的这个数据是热点数据,请求的数据在 DB 中存在,但是 Redis 存的那一份已经过期,后端需要从 DB 里加载数据并写到 Redis中。

解决方案:

  • 缓存预热:预先把热门数据提前存入 Redis 中,并设热门数据的超长过期时间。

  • 互斥锁(读写锁):在第一个查询数据的请求上使用互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,当第一个线程查询到了数据,然后写入缓存。后面的线程进来发现已经有缓存了,就直接走缓存。


Redis_Jedis 测试

<dependency>
	<groupId>redis.clients</groupId>
	<artifactId>jedis</artifactId>
	<version>3.3.0</version>
</dependency>

Jedis 测试程序

Jedis jedis = new Jedis("192.168.19.111",6379); //创建Jedis实例,连接本地Redis服务
jedis.auth("root"); //设置Redis的密码
System.out.println("连接成功:" + jedis.ping());
jedis.close();

Jedis 线程池测试程序

JedisPoolConfig poolConfig = new JedisPoolConfig();  // 连接池配置
poolConfig.setMaxTotal(50);  					   // 最大连接数
poolConfig.setMinIdle(10); 						   // 最小空闲数
poolConfig.setMaxIdle(30); 						   // 最大空闲数
JedisPool pool = new JedisPool(poolConfig, "192.168.19.111", 6379); // 创建一个Jedis连接池
Jedis jedis = pool.getResource(); 				    // 从jedis中获取连接资源
jedis.select(5); 						    	   // 切换索引库
jedis.auth("root"); 							   //设置Redis的密码
System.out.println("连接成功:" + jedis.ping());
jedis.close(); 									  // 关闭资源放回到连接池中
pool.close(); 									  // 关闭线程池

Jedis 哨兵测试程序

private static JedisSentinelPool jedisSentinelPool = null;
public static Jedis getJedisFromSentinel() {
    if (jedisSentinelPool == null) {
        Set<String> sentinelSet = new HashSet<>(); // 哨兵服务IP地址
        sentinelSet.add("192.168.19.111:26379");
        JedisPoolConfig poolConfig = new JedisPoolConfig(); // 连接池对象
        poolConfig.setMaxTotal(50);  					  // 最大连接数
        poolConfig.setMinIdle(10); 						  // 最小空闲数
        poolConfig.setMaxIdle(30); 						  // 最大空闲数
        poolConfig.setBlockWhenExhausted(true);        	 	// 连接耗尽是否等待
        poolConfig.setMaxWaitMillis(2000); 				   // 等待时间
        poolConfig.setTestOnBorrow(true);				   // 获取连接的时候进行一次 ping pong 测试
        jedisSentinelPool = new JedisSentinelPool("mymaster", sentinelSet, poolConfig);
    }
    return jedisSentinelPool.getResource();
}

Jedis 集群测试程序

// 主机写、从机读,即使连接的不是主机,集群会自动切换主机存储;无中心化主从集群,无论从那台主机写的数,其它主机上都能够读到。
Set<HostAndPort> nodes = new HashSet<HostAndPort>();
nodes.add(new HostAndPort("192.168.19.111",6379));
nodes.add(new HostAndPort("192.168.19.112",6380));
nodes.add(new HostAndPort("192.168.19.113",6381));
......
JedisCluster jedisCluster = new JedisCluster(set);
jedisCluster.set("key1","value1");

Jedis 常用操作

jedis.flushDB(); 			             // 清空当前库数据
jedis.flushAll(); 			             // 清空所有库数据
Boolean key = jedis.exists("key"); 		 // 判断 Key 是否存在
String randomKey = jedis.randomKey(); 	 // 随机返回一个key,如果没有任何key,返回null。
jedis.expire("key", 60); 				// 设置60秒后该key过期
Long key = jedis.pttl("key"); 			// key剩余存活时间
jedis.persist("key"); 				   // 移除key的过期时间
// 以字符串的形式返回 key 中存储的值的类型。类型可以是“none”、“string”、“list”、“set”之一。如果键不存在,则返回“none”
String key = jedis.type("key"); 
byte[] bytes = jedis.dump("key"); 		// 导出key的值
jedis.renamenx("key", "KEY"); 			// key重命名
Set<String> set = jedis.keys("*"); 		// 模糊查询;*-全部;[ab]-包含a或b;特殊符号用 \ 隔开;
jedis.del("key"); 					   // 删除

Jedis 字符串操作

jedis.set("key", "hello");			// 设置字符串值
jedis.append("key", " world");		// 在指定key的值后面进行拼接字符串
jedis.get("key");				   // 获取指定键的值。如果键不存在,则返回 null。
jedis.setex("key", 2, "value"); 	// 该命令完全等同于以下命令组: set + expire 。操作是原子的。
jedis.mset("key1", "value1", "key2", "value2"); 	// 批量设置字符串值
List<String> values = jedis.mget("key1", "key2"); 	// 批量获取字符串值
jedis.del("key1", "key2"); 			//批量删除

Jedis 链表操作

jedis.rpush("key", "right");	// 将字符串值添加到存储在 key 的列表的头部 (LPUSH) 或尾部 (RPUSH)。
jedis.lpush("key", "left");		// 将字符串值添加到存储在 key 的列表的头部 (LPUSH) 或尾部 (RPUSH)。
jedis.llen("key");			   // 获取链表的长度。
jedis.lrange("key", 0, -1);     // 获取键列表的元素 -1 最后一个元素,-2 是倒数第二个元素,依此类推。
jedis.lindex("key", 0);		   // 获取指定索引位的元素 -1 最后一个元素,-2 是倒数第二个元素,依此类推。
jedis.lset("key", 0, "AA");	   // 替换索引位的元素 
jedis.rpop("key");			  // 从队列最右边取出一个值
jedis.lpop("key");			  // 从队列最左边取出一个值
jedis.lrem("key", 1, "AA");    // 从列表中删除第一次出现的 value 元素

Jedis 集合(Sets )的操作

jedis.sadd("key","aa","bb","cc");	//将指定的成员添加到存储key中。如果 member 已经是集合的成员,则不执行任何操作
jedis.scard("key");				   //返回集合基数(元素数)。如果键不存在,则返回 0,例如空集。
jedis.sinterstore("destination", key, key2);	//方式与 sinter 完全相同,获得两个集合的交集,并存储在一个关键的结果集
jedis.sunionstore("destination", key, key2);	//方式与 sunion 完全相同,结果集存储为 destination。 destination 中的任何现有值都将被覆盖。
jedis.sdiffstore("destination", key, key2);		//方式与 sdiff 完全相同,结果集存储在 destination 中,而不是返回。
jedis.smembers("destination");	    //返回存储在 key 的集合值的所有成员(元素)。
jedis.sismember(key, "aa");		   //如果 member 是存储在 key 的集合的成员,则返回 true,否则返回 false。
jedis.srandmember(key);			   //从 Set 中返回一个随机元素,而不删除该元素。
jedis.smove(key, key2, "aa");	   //将指定成员从 key 的集合移动到 key2 的集合。这个操作是原子的。
jedis.spop(key);				  //从 Set 中移除一个随机元素,将其作为返回值返回。
jedis.srem(key, "cc", "dd");	   //从集合里删除一个或多个元素。

Jedis集合(Sorted Sets)的操作

jedis.zadd(key, 1004.0, "aa");	//添加成员到 key 有序集合中,如果成员已存在有序集合中,则更新分数,并重新排序到指定位置。
jedis.zcard(key);			   //返回 key 有序集合中成员的数量,如果键不存在,则返回 0。
jedis.zrange(key, 0, -1);	    //返回指定范围类的有序集合,0--有序集合的第一个成员 -1--有序集合的倒数第一个成员。
jedis.zrevrange(key, 0, -1);    //与 zrange 行同,但返回的结果集为逆序集合。
jedis.zscore(key, "aa");	    //返回 key 处排序集的指定元素的分数。
jedis.zrem(key, "aa");		   //从存储在 key 的排序集合值中删除指定的成员。
jedis.zcount(key, 1002.0, 1003.0);	//返回指定分数范围的成员数。

Jedis 哈希的操作

jedis.hmset(key, "aa", "44");	//添加成员
jedis.hkeys(key);			   //返回哈希中的所有字段。
jedis.hvals(key);			   //返回哈希中的所有值。
jedis.hlen(key);			   //返回哈希中的项目数。
jedis.hgetAll(key);			   //返回哈希中的所有字段和关联值。
jedis.hexists(key, "aa");	   //测试哈希中是否存在指定字段。
jedis.hmget(key, "aa", "bb");	//检索与指定字段关联的值。
jedis.hget(key, "aa");		   //获取指定字段关联的值。
jedis.hdel(key, "aa");		   //从存储在 key 的哈希中删除指定的字段。
jedis.hincrBy(key, "aa", 100); //在指定 key 中的值进行累加,如果 key 不存在,则创建一个包含哈希的新 key。

Jedis 事务操作

// 创建一个 Jedis 事务
Transaction transaction = jedis.multi();
transaction.set("hello", "world");
transaction.zadd("key", 1, "AA");
transaction.zadd("key", 0, "BB");
// 获取 Set 响应对象
Response<String> response = transaction.get("hello");
// 获取有序集合响应对象
Response<Set<String>> sose = transaction.zrange("key", 0, -1);
// 提交事务
transaction.exec();

// response.get() 可以从响应中获取数据
System.out.println(response.get());
System.out.println(sose.get());
// sose.get() 能够继续调用有序集合中的方法
int size = sose.get().size();
System.out.println(size);

Jedis管道操作

// 创建一个 Jedis 管道
Pipeline pipeline = jedis.pipelined();
pipeline.set("SetKey", "AA");
pipeline.zadd("SortedSetsKey", 1001.4, "AA");
pipeline.zadd("SortedSetsKey", 100, "BB");
Response<String> pipeString = pipeline.get("SetKey");
Response<Set<String>> sortedSetsKey = pipeline.zrange("SortedSetsKey", 0, -1);
// 提交
pipeline.sync();
System.out.println(pipeString);
System.out.println(sortedSetsKey);

int size = sortedSetsKey.get().size();
System.out.println(size);

Set<String> setBack = sortedSetsKey.get();
System.out.println(setBack);

Redis与SpringBoot 整合

<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- spring2.X集成redis所需common-pool2-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

application.properties 配置

# Redis服务器地址
spring.redis.host=localhost
# Redis服务器连接端口
spring.redis.port=6379
# redis访问密码(默认为空)
spring.redis.password:root
# Redis数据库索引(默认为0)
spring.redis.database=0
# 连接超时时间(毫秒)
spring.redis.timeout=1800000
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.lettuce.pool.max-active=20
# 最大阻塞等待时间(负数表示没限制)
spring.redis.lettuce.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.lettuce.pool.max-idle=5
# 连接池中的最小空闲连接
spring.redis.lettuce.pool.min-idle=0
###### 哨兵模式 ######
# 哨兵节点名称
spring.redis.sentinel.master=mymaster
# 哨兵节点地址
spring.redis.sentinel.nodes=193.168.1.111:26379,192.168.1.111:36379,192.168.1.111:46379
###### 集群模式 ######
# 集群节点
spring.redis.cluster.nodes: 192.168.1.111:6379,192.168.1.111:6380,192.168.1.111:6381,192.168.1.111:6382,192.168.1.111:6383,192.168.1.111:6384
# 重定向的最大次数
max-redirects: 3

配置类

@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
	@Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        ObjectMapper om = new ObjectMapper();
        /** 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public */
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        /** 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常 */
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        /** 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值(默认使用JDK的序列化方式) */
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        /** 配置连接工厂 */
        template.setConnectionFactory(factory);
        /** 使用StringRedisSerializer来序列化和反序列化redis的key值 */
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        template.setKeySerializer(redisSerializer);
        /** 设置 value 和 HashMap 值采用json序列化模式 */
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.setHashKeySerializer(jackson2JsonRedisSerializer);
        return template;
    }
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
		//解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
		// 配置序列化(解决乱码的问题),过期时间600秒
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(600))
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
                .disableCachingNullValues();
        RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
        return cacheManager;
    }
}

哨兵配置

@Value("#{'${spring.redis.sentinel.nodes}'.split(',')}")
private List<String> nodes;

@Value("${spring.redis.password}")
private String password;

@Value("${spring.redis.sentinel.nodes}")
private String redisNodes;

@Value("${spring.redis.sentinel.master}")
private String master;

// 定义redis的连接池
@Bean(name = "poolConfig")
@ConfigurationProperties(prefix = "spring.redis")
public JedisPoolConfig poolConfig() {
    JedisPoolConfig poolConfig = new JedisPoolConfig();
    return poolConfig;
}

//定义RedisSentinelConfiguration,用于设置哨兵
@Bean
public RedisSentinelConfiguration sentinelConfiguration() {
    RedisSentinelConfiguration redisSentinelConfiguration = new RedisSentinelConfiguration();
    //配置matser的名称
    redisSentinelConfiguration.master(master);
    //数据库是1库
    redisSentinelConfiguration.setDatabase(1);
    redisSentinelConfiguration.setPassword(password);
    //配置redis的哨兵sentinel
    Set<RedisNode> redisNodeSet = new HashSet<>();
    nodes.forEach(x -> {
        redisNodeSet.add(new RedisNode(x.split(":")[0], Integer.parseInt(x.split(":")[1])));
    });
    logger.info("redisNodeSet -->" + redisNodeSet);
    redisSentinelConfiguration.setSentinels(redisNodeSet);
    return redisSentinelConfiguration;
}

//定义工厂类
@Bean
public JedisConnectionFactory redisConnectionFactory(JedisPoolConfig poolConfig, RedisSentinelConfiguration sentinelConfig) {
    return new JedisConnectionFactory(sentinelConfig, poolConfig);
}

集群配置

@Value("#{'${spring.redis.cluster.nodes}'.split(',')}")
private List<String> nodes;

@Value("${spring.redis.cluster.nodes}")
private String redisNodes;

@Value("${spring.redis.cluster.max-redirects}")
private Integer maxRedirects;

@Value("${spring.redis.password}")
private String password;

// 定义redis的连接池
@Bean(name = "poolConfig")
@ConfigurationProperties(prefix = "spring.redis")
public JedisPoolConfig poolConfig() {
    JedisPoolConfig poolConfig = new JedisPoolConfig();
    return poolConfig;
}

// 定义RedisSentinelConfiguration,用于设置集群
@Bean
public RedisSentinelConfiguration sentinelConfiguration() {
    RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration();
    // 设置最大重定向数
	redisClusterConfiguration.setMaxRedirects(maxRedirects);
    // 配置redis的集群节点
    Set<RedisNode> redisNodeSet = new HashSet<>();
    nodes.forEach(x -> {
        redisNodeSet.add(new RedisNode(x.split(":")[0], Integer.parseInt(x.split(":")[1])));
    });
    logger.info("redisNodeSet -->" + redisNodeSet);
    redisClusterConfiguration.setClusterNodes(redisNodeSet);
    return redisClusterConfiguration;
}

//定义工厂类
@Bean
public JedisConnectionFactory redisConnectionFactory(JedisPoolConfig poolConfig, RedisSentinelConfiguration sentinelConfig) {
    return new JedisConnectionFactory(sentinelConfig, poolConfig);
}

RedisTemplate 对五种类型的操作方式

连接池自动管理,提供了一个高度封装的RedisTemplate类,针对jedis客户端中大量 API 进行了归类封装,将同一类型操作封装为operation接口。

  • ValueOperations:简单K-V操作
  • SetOperations:set类型数据操作
  • ZSetOperations:zset类型数据操作
  • HashOperations:针对map类型的数据操作
  • ListOperations:针对list类型的数据操作
redisTemplate.opsForValue(); // 操作字符串
redisTemplate.opsForHash();  // 操作hash
redisTemplate.opsForList();  // 操作list
redisTemplate.opsForSet();   // 操作set
redisTemplate.opsForZSet();  // 操作有序set

BoundValueOperations字符串操作:

BoundValueOperations boundValueOps = redistempalate.boundValueOps("key");
// append(String value) 在原来值的末尾添加值
boundValueOps.append("value");

// get(long start, long end) 获取指定区间位置的值
boundValueOps.get(0, 2);

// 获取字符串所有值
boundValueOps.get();

// set(V value) 给绑定键重新设置值
boundValueOps.set("newValue");

// set(V value, long timeout, TimeUnit unit) 在指定时间后重新设置值
boundValueOps.set("newTimeValue", 5, TimeUnit.SECONDS);

// set(V value, long offset) 截取原有值的指定长度后添加新值在后面
boundValueOps.set("spliceValue", 3);

// setIfAbsent(V value) 没有值存在则添加
boundValueOps.setIfAbsent("nullValue");

// getAndSet(V value) 获取原来的值并重新赋新值
Object object = boundValueOps.getAndSet("newValue");

// 获取绑定值的长度
boundValueOps.size();

// increment(double delta) increment(long delta) 自增长键值,前提是绑定值的类型是doule或long类型
boundValueOps.increment(1);

BoundHashOperations Hash操作:

BoundHashOperations<String, String, String> boundHashOps = redistempalate.boundHashOps("key");
// put(HK key, HV value) 新增元素到指定键中
boundHashOps.put("key", "value");

// 获取指定键中的值
boundHashOps.getKey();

// 获取map中的值
boundHashOps.values();

// 获取map中的键值对
boundHashOps.entries();

// get(Object member) 获取map键中的值
boundHashOps.get("key");

// 获取map的键
boundHashOps.keys();

// multiGet(Collection<HK> keys) 根据map键批量获取map值
List list = new ArrayList<>(Arrays.asList("key", "value"));
boundHashOps.multiGet(list);

// putAll(Map<? extends HK,? extends HV> m) 批量添加键值对
Map map = new HashMap<>();
map.put("key", "value");
map.put("key2", "value2");
boundHashOps.putAll(map);

// increment(HK key, long delta) 自增长map键的值
boundHashOps.increment("num", 1);

// putIfAbsent(HK key, HV value) 添加不存在的map键,如果map键不存在,则新增,存在,则不变。
boundHashOps.putIfAbsent("key", "value");

// 获取特定键对应的map大小
boundHashOps.size();

// scan(ScanOptions options) 扫描特定键所有值
Cursor<Map.Entry<String, Object>> cursor = boundHashOps.scan(ScanOptions.NONE);
while (cursor.hasNext()) {
    Map.Entry<String, Object> entry = cursor.next();
    LOGGER.info("遍历绑定键获取所有值:{}----{}", entry.getKey(), entry.getValue());
 }

// delete(Object... keys) 批量删除map值
boundHashOps.delete("key3","key4");

BoundListOperations List操作:

BoundListOperations boundListOps = redistempalate.boundListOps("key");
// leftPush(V value)、rightPush(V value) 在绑定键中添加值
boundListOps.leftPush("left");
boundListOps.rightPush("right");

// range(long start, long end) 获取绑定键中给定的区间值
boundListOps.range(0,-1);

// index(long index) 获取给定位置的值
boundListOps.index(0);

// 弹出左边的值
boundListOps.leftPop();

// 弹出右边的值
boundListOps.rightPop();

// rightPush(V pivot, V value) 在指定的第一个值出现的右边添加值
boundListOps.rightPush("key-4", "key-5");

// leftPush(V pivot, V value) 在指定的第一个值出现的左边添加值
boundListOps.leftPush("key-3", "key2");

// leftPop(long timeout, TimeUnit unit) 在指定的时间后弹出左边的值
boundListOps.leftPop(1, TimeUnit.SECONDS);

// rightPop(long timeout, TimeUnit unit) 在指定的时间后弹出右边的值
boundListOps.rightPop(1, TimeUnit.SECONDS);

// leftPushAll(V... values) 在左边批量添加值
boundListOps.leftPushAll("key-6", "key7");

// rightPushAll(V... values) 在右边批量添加值
boundListOps.rightPushAll("key-8", "key-9");

// leftPushIfPresent(V value) 在左边添加不存在的值
boundListOps.leftPushIfPresent("key");

// rightPushIfPresent(V value) 在右边添加不存在的值
boundListOps.rightPushIfPresent("key");

// remove(long count, Object value) 移除指定个数的值
long removeCount = boundListOps.remove(2, "key");

// set(long index, V value) 在指定位置添加值
boundListOps.set(0, "key");

// trim(long start, long end) 截取原来区间的值为新值
boundListOps.trim(1, 3);

BoundSetOperations Set操作:

BoundSetOperations boundSetOps = redistempalate.boundSetOps("key");
// add(V... values)、members() 批量添加值,获取所有值
boundSetOps.add("a","b","c");

// scan(ScanOptions options) 匹配获取键值对,ScanOptions.NONE为获取全部键值 ScanOptions.scanOptions().match("c").build()匹配获取键位map1的键值对,不能模糊匹配。
Cursor<String> cursor = boundSetOps.scan(ScanOptions.NONE);
while (cursor.hasNext()) {
      LOGGER.info("遍历所有值:{}", cursor.next());
}

// 随机获取一个值 
boundSetOps.randomMember();

// randomMembers(long count) 随机获取指定数量的值
boundSetOps.randomMembers(2);

// distinctRandomMembers(long count) 获取唯一的随机数量值
boundSetOps.distinctRandomMembers(2);

// diff(Collection<K> keys) 比较多个特定键中的不同值
Set list = new HashSet<>();
list.add("bso1");
boundSetOps.diff(list);

// diff(K key) 比较2个特定键中的不同值
boundSetOps.diff("bso2");

// diffAndStore(Collection<K> keys, K destKey)、diffAndStore(K keys, K destKey) 比较键中的不同值并存储
boundSetOps.diffAndStore("bso2","bso3");

// intersect(Collection<K> keys)、intersect(K key) 比较键中的相同值
boundSetOps.intersect("bso2");

// intersectAndStore(Collection<K> keys, K destKey)、intersectAndStore(K key, K destKey) 比较键中的相同值并存储
boundSetOps.intersectAndStore("bso3","bso4");

// union(Collection<K> keys)、union(K key) 将特定键中的所有值合并
boundSetOps.union("bso2");

// unionAndStore(Collection<K> keys, K destKey)、和unionAndStore(K key, K destKey) 将特定键中的所有值合并并存储
boundSetOps.unionAndStore("bso3","bso5");

// move(K destKey, V value) 将value值转移到特定键中
boolean moveSuc = boundSetOps.move("bso6","a");

// 弹出集合中的值
Object p = boundSetOps.pop();

// remove(Object... values) 批量移除元素
long removeCount = boundSetOps.remove("c");

BoundZSetOperations ZSet 操作:

BoundZSetOperations boundZSetOps = redistempalate.boundZSetOps("key");
// add(V value, double score) 绑定键中添加值,同时指定值的分数
boundZSetOps.add("a",1);

// range(long start, long end) 获取绑定键的指定区间值
boundZSetOps.range(0,-1);

// count(double min, double max) 获取从指定位置开始(起始下标不再为0),到结束位置位置的值个数
boundZSetOps.count(1,2);

// add(Set<ZSetOperations.TypedTuple<V>> tuples) 以TypedTuple的方式新增值
ZSetOperations.TypedTuple<Object> typedTuple1 = new DefaultTypedTuple<Object>("E",6.0);
ZSetOperations.TypedTuple<Object> typedTuple2 = new DefaultTypedTuple<Object>("F",7.0);
ZSetOperations.TypedTuple<Object> typedTuple3 = new DefaultTypedTuple<Object>("G",5.0);
Set<ZSetOperations.TypedTuple<Object>> typedTupleSet = new HashSet<ZSetOperations.TypedTuple<Object>>();
typedTupleSet.add(typedTuple1);
typedTupleSet.add(typedTuple2);
typedTupleSet.add(typedTuple3);
boundZSetOps.add(typedTupleSet);

// incrementScore(V value, double delta) 自增长指定键的分数
boundZSetOps.incrementScore("a",1);

// intersectAndStore(Collection<K> otherKeys, K destKey)和intersectAndStore(K otherKey, K destKey) 比较特定键中相同的值并存储
boundZSetOps.intersectAndStore("bzso1","bzso2");

// scan(ScanOptions options) 匹配获取键值对,ScanOptions.NONE为获取全部键值对;ScanOptions.scanOptions().match("C").build()匹配获取键位map1的键值对,不能模糊匹配。
Cursor<ZSetOperations.TypedTuple> cursor = boundZSetOps.scan(ScanOptions.NONE);
while (cursor.hasNext()){
      ZSetOperations.TypedTuple typedTuple = cursor.next();
      System.out.println("扫描绑定数据:" + typedTuple.getValue() + "--->" + typedTuple.getScore());
}

// rangeByLex(RedisZSetCommands.Range range) 根据Range排序区间获取值
RedisZSetCommands.Range range = new RedisZSetCommands.Range();
range.lte("b");
//range.gte("F");
boundZSetOps.rangeByLex(range);

// rangeByLex(RedisZSetCommands.Range range, RedisZSetCommands.Limit limit) 根据Range排序区间和Limit设置的下标及设置的长度获取值
RedisZSetCommands.Limit limit = new RedisZSetCommands.Limit();
limit.count(2);
//起始下标为0
limit.offset(1);
boundZSetOps.rangeByLex(range,limit);

// rangeByScore(double min, double max) 根据分数区间值排序取值
boundZSetOps.rangeByScore(3,7);

// rangeWithScores(long start, long end) 按照位置排序对指定区间取值和分数
Set<ZSetOperations.TypedTuple> tupleSet = boundZSetOps.rangeWithScores(3,5);

// rangeByScoreWithScores(double min, double max) 按照分数排序对指定区间取值和分数
Set<ZSetOperations.TypedTuple> scoreSet = boundZSetOps.rangeByScoreWithScores(1,5);

// reverseRange(long start, long end) 倒序排序获取指定区间的值
boundZSetOps.reverseRange(0,3);

// reverseRangeByScore(double min, double max) 按照分数倒序排序获取区间取值
boundZSetOps.reverseRangeByScore(2,5);

// reverseRangeWithScores(long start, long end) 倒序排序获取指定区间的值和分数
Set<ZSetOperations.TypedTuple> tupleSet = boundZSetOps.reverseRangeWithScores(2,5);

// reverseRangeByScoreWithScores(double min, double max) 按照分数倒序排序获取指定区间的值和分数
Set<ZSetOperations.TypedTuple> scoreSet = boundZSetOps.reverseRangeByScoreWithScores(2,5);

// count(double min, double max) 统计分数在某个区间的个数
boundZSetOps.count(2,5);

// rank(Object o) 获取绑定键中的元素的下标
boundZSetOps.rank("b");

// score(Object o)  获取绑定键中元素的分数
boundZSetOps.score("b");

// 获取绑定键中元素的个数
boundZSetOps.zCard();

// intersectAndStore(Collection<K> otherKeys, K destKey)和intersectAndStore(K otherKey, K destKey) 比较相同的值并存储
//intersectAndStore后的数据
boundZSetOps.intersectAndStore("abc","bac");
redisTemplate.opsForSet().members("bac").forEach(v -> System.out.println("intersectAndStore后的数据:" + v));
//intersectAndStore集合后的数据
List list = new ArrayList<>(Arrays.asList("abc","acb"));
boundZSetOps.intersectAndStore(list,"bac");
redisTemplate.opsForSet().members("bac").forEach(v -> System.out.println("intersectAndStore集合后的数据:" + v));

// unionAndStore(Collection<K> otherKeys, K destKey)和unionAndStore(K otherKey, K destKey) 合并所有的值并存储
//unionAndStore后的数据
boundZSetOps.unionAndStore("abc","bca");
redisTemplate.opsForZSet().range("bca",0,-1).forEach(v -> System.out.println("unionAndStore后的数据:" + v));
//unionAndStore集合后的数据
boundZSetOps.unionAndStore(list,"bca");
redisTemplate.opsForZSet().range("bca",0,-1).forEach(v -> System.out.println("unionAndStore集合后的数据:" + v));

// remove(Object... values) 按值批量删除绑定键中的元素
long removeCount = boundZSetOps.remove("a","b");
System.out.println("移除给定值中的变量个数:" + removeCount);

// removeRange(long start, long end)  删除绑定键中按值排序的区间的值
boundZSetOps.removeRange(2,3);

// removeRangeByScore(double min, double max) 按照分数排序删除区间的值
boundZSetOps.removeRangeByScore(3,5);

分布式锁伪代码(Lua)

public List<String> getRedisLock() {
    String lockName = "redis-lock";
    // 1. 判断缓存中是否存在
    String redisData = (String) redisTemplate.opsForValue().get("dataKey");
    if (!StringUtils.isBlank(redisData)) {
        return redisData;
    }
    // 2. 缓存不存在查询DB 设置锁的ID、名称、过期时间
    String uuid = UUID.randumUUID().toString();
    Boolean isLock = redisTemplate.opsForValue().setIfAbsent(lockName, uuid, 300, TimeUnit.SECONDS);
    // 3. 判断抢占锁释放成功
    if (isLock) {
        // 3.1 加锁成功
        List<String> data;
        try {
            // 4. 执行业务
            data = getDBDate();
        } finally {
            // 5. 删除分布式锁:获取lock锁值进行对比,相同则删除,为了保证原子性使用官方提供的Lua脚本进行解锁
            String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
            Long execute = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(lockName), uuid);
        }
        return data;
    } else {
        // 3.2 加锁失败....重试
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return getDBDate();
    }
}

public List<String> getDBDate() {
    // 1. 执行业务逻辑 再次验证缓存中是否有数据
    String redisData = (String) redisTemplate.opsForValue().get("dataKey");
    if (!StringUtils.isBlank(redisData)) {
        return redisData;
    }
    // 2. 缓存没有 查询DB 此处省略无数行代码
    return DBdata;
}

分布式锁伪代码(Redisson)

public List<String> getRedisLock() {
    String lockName = "redis-lock";
    final RReadWriteLock rwLock = redisson.getReadWriteLock(lockName);
    // 1. 设置读锁
    lock.readLock().lock();
    List<String> data;
    // 2. 判断缓存中是否存在
    if (StringUtils.isBlank(getCacheData())) {
        // 3. 没有缓存数据,在获取写锁之前必须释放读锁
        lock.readLock().unlock(); // 释放读锁
        lock.writeLock().lock();  // 获取写锁
        try {
            // 4. 重新检查缓存,因为另一个线程可能已经获得写锁并在我们之前更新了缓存。
            if (StringUtils.isBlank(getCacheData())) {
                // 4.1 查询DB
                data = getDBDate();
            }
            // 5. 降级通过获取读锁之前释放写锁
            lock.readLock().lock();
        } finally {
            // 释放写锁,仍然保持读
            lock.writeLock().unlock();
        }
    }
    try {
        // 此处仍然持有读锁
        data = getDBDate();
    } finally {
        // 释放读锁
        lock.readLock().unlock();
    }
    return data;
}

public String getCacheData() {
    String redisData = (String) redisTemplate.opsForValue().get("dataKey");
    return redisData;
}

public List<String> getDBDate() {
    // 缓存没有 查询DB 此处省略无数行代码
    return DBdata;
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值