Redis 基础使用

Redis

Redis是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的Key-Value数据库,并提供多种语言的API。

文章目录

  • Redis
    • Redis 简介
    • Redis 安装
      • 在 Linux 上安装 Redis
      • 在 Windows 上安装 Redis
    • Redis 指令
      • 常用基本指令
      • 字符串(String)指令
      • 列表(List)指令
      • 集合(Set)指令
      • 哈希(Hash)指令
      • 有序集合(ZSet)指令
    • Redis 的发布和订阅
    • Jedis 操作
    • Redis 应用
      • 会话管理
      • 验证码
      • 限流
      • 限时抽奖
      • 计数器、点赞、签到、打卡
      • 原子操作

Redis 简介

  • Redis 是单线程+多路 IO 复用技术
  • Redis读写性能极高, Redis能读的速度是11万次/s,写的速度是8.1万次/s。是已知性能最快的Key-Value数据库

Redis 为什么会用单线程?怎么理解单线程+多路 IO 复用技术?

在 Redis 6.0 以前,Redis的核心网络模型选择用单线程来实现。对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是I/O 密集型

具体到 Redis的话,如果不考虑 RDB/AOF 等持久化方案,Redis是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis选择了单线程的 I/O 多路复用来实现它的核心网络模型。

首先理清一个概念:Redis 是单线程,主要是指 Redis 的网络 IO和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

Redis 6.0为何引入多线程?

就是 Redis的网络 I/O 瓶颈已经越来越明显了。

随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis的性能有两个方向:

  • 优化网络 I/O 模块
  • 提高机器内存读写的速度

后者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:

  • 零拷贝技术或者 DPDK 技术
  • 利用多核优势

零拷贝技术有其局限性,无法完全适配 Redis这一类复杂的网络 I/O 场景,更多网络 I/O 对 CPU 时间的消耗和 Linux 零拷贝技术。而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。

因此,利用多核优势成为了优化网络 I/O 性价比最高的方案。

Redis 发展史

  • 2009 年,redis 诞生
  • 2010 年,发布 redis v1.0,支持多种数据类型
  • 2012 年,发布 redis v2.6,采用 lua,并支持发布订阅功能,还提供了哨兵运行机制
  • 2013 年,发布 redis v2.8,支持 ipv6,并升级了哨兵运行机制
  • 2015 年,发布 redis v3.0,支持集群,还支持存储地理位置数据信息
  • 2016 年,发布 redis v4.0,引入 lazy free、modules 和 rdb-aof
  • 2017 年,发布 redis v5.0,支持流操作,常用于消息队列
  • 2020年,发布 redis v6.0,支持多线程,SSL、ACLS 等
  • 2022年,发布 redis v7.0,引入 functions、ACL v2、shared-pubsub、multi-part aof 等

Redis 安装

本节包括 Linux 和 Windows 两个系统中的 Redis 安装教程。请各位提前准备好如下软件包:

  1. redis-5.0.10-centos-3.10.0-693.el7.x86_64-release.tar.gz
  2. Redis-x64-5.0.14.1.msi 或 Redis-x64-5.0.14.1.zip
  3. Another-Redis-Desktop-Manager.1.6.0.exe 或者 redis-desktop-manager-0.8.8.384.exe

在 Linux 上安装 Redis

  1. 解压 redis 安装包到 /opt

需要注意的是:这里提供的压缩包是在 CentOS 7 平台中已经编译且安装过的,所以,解压后,将 bin 目录下所有的文件赋予可执行权力,即可直接启动,不用做任何配置,但为了方便使用,建议按照教程配置后再使用。

[root@c7100 ~]# cd /opt/
[root@c7100 opt]# tar -zxf redis-5.0.10-centos-3.10.0-693.el7.x86_64-release.tar.gz
  1. 修改 Redis 配置 redis.conf 文件的如下七处
# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES
# JUST COMMENT THE FOLLOWING LINE.
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# bind 127.0.0.1


# Accept connections on the specified port, default is 6379 (IANA #815344).
# If port 0 is specified Redis will not listen on a TCP socket.
port 26379


# By default Redis does not run as a daemon. Use 'yes' if you need it.
# Note that Redis will write a pid file in /var/run/redis.pid when daemonized.
daemonize yes


# Creating a pid file is best effort: if Redis is not able to create it
# nothing bad happens, the server will start and run normally.
#pidfile /var/run/redis_6379.pid
pidfile /opt/redis/data/redis_6379.pid


# Specify the log file name. Also the empty string can be used to force
# Redis to log on the standard output. Note that if you use standard
# output for logging but daemonize, logs will be sent to /dev/null
#logfile /usr/local/redis/log/redis_6379.log
logfile /opt/redis/log/redis_6379.log


# The working directory.
#
# The DB will be written inside this directory, with the filename specified
# above using the 'dbfilename' configuration directive.
#
# The Append Only File will also be created inside this directory.
#
# Note that you must specify a directory here, not a file name.
#dir /var/lib/redis/6379
dir /opt/redis/data


# Require clients to issue AUTH  before processing any other
# commands.  This might be useful in environments in which you do not trust
# others with access to the host running redis-server.
#
# This should stay commented out for backward compatibility and because most
# people do not need auth (e.g. they run their own servers).
#
# Warning: since Redis is pretty fast an outside user can try up to
# 150k passwords per second against a good box. This means that you should
# use a very strong password otherwise it will be very easy to break.
#
# requirepass foobared
requirepass 123456
  1. 在上一步的配置中,将工作目录、日志文件和 pid 文件都放置到了 /opt/redis 下,故而这一步将需要使用的目录创建好
[root@c7100 opt]# mkdir -p /opt/redis/data/
[root@c7100 opt]# mkdir -p /opt/redis/log/
  1. 将 redis 的 bin 目录中的所有文件授予可执行权限
[root@c7100 opt]# chmod +x /opt/redis/bin/*
  1. 为 redis 设置环境变量,即创建 /etc/profile.d/env-redis.sh,其内容如下:
REDIS_HOME=/opt/redis
PATH=$PATH:$REDIS_HOME/bin
export PATH REDIS_HOME
  1. 使用 source 命令使 redis 的环境变量得以生效
[root@c7100 opt]# source /etc/profile
  1. 按照配置启动 redis 服务
[root@c7100 ~]# redis-server /opt/redis/redis.conf

前文提到过,即便是不做2、3、5、6 步骤,也可以通过绝对地址直接启动 redis ,但不推荐,如下所示

[root@c7100 ~]# /opt/redis/bin/redis-server
  1. 启动后,启动客户端连接服务端
[root@c7100 ~]# redis-cli -h localhost -p 26379
localhost:26379>

需要注意的是

  1. 如果没有按照配置来启动 redis 服务,其端口默认是 6379
  2. 如果需要在 Windows 系统上使用客户端连接Linux 中的 Redis 服务,需要开放防火墙端口

在 Windows 上安装 Redis

在 Window 上的 Redis 的相关软件的安装步骤非常简单,双击安装程序后,直接傻瓜式下一步安装即可,这里不做讲解。

安装成功后,会自动增加一个名为 Redis 的系统服务,该服务在启动状态情况下,我们可以使用 Redis 的客户端来进行连接,例如: Another Redis Desktop Manager、Redis Desktop Manager、redis-cli 等。

Redis 指令

常用基本指令

  1. auth 如果 Redis 服务端受密码保护,使用该 auth 指令可授权使用
localhost:26379> auth 123456
OK
localhost:26379>
  1. select 在 Redis 中,默认拥有 16 个数据库,其编号从 0 开始,初始默认使用 0 号库,使用 select 来切换不同的数据库
localhost:26379> select 1
OK
localhost:26379[1]>
  1. set 该指令用于保存一个字符串的值,如果键已经存在,则覆盖原有的值,并忽略其类型,如果操作成功,将丢弃与键相关联的任何先前的生存时间。同时可以通过 ex 参数设置过期时间,如果不指定 ex 参数,则默认永不过期。
localhost:26379[1]> set name tina
OK
localhost:26379[1]> set age 12
OK
localhost:26379[1]> set nickname Gina ex 60
OK
  1. get 该指令用于获取指定键的字符串的值
localhost:26379[1]> get name
"tina"
  1. keys 该指令用于查询所有符合模式的键
localhost:26379[1]> keys *
1) "age"
2) "nickname"
3) "name"
localhost:26379[1]> keys a*
1) "age"
  1. exists 该指令用于确定一个或多个键是否存在
localhost:26379[1]> exists name
(integer) 1
localhost:26379[1]> exists name age
(integer) 2
localhost:26379[1]> exists nickname
(integer) 0
  1. type 该指令用于确定一个键所存储的值的类型
localhost:26379[1]> type name
string
  1. del 该指令用于删除一个或多个键
localhost:26379[1]> del name age
(integer) 2
  1. expire 该指令用于给某个键设置过期时间,单位:秒
localhost:26379[1]> set nickname Gina
OK
localhost:26379[1]> expire nickname 60
(integer) 1

也可以在使用 set 的时候,直接指定其过期时间

localhost:26379[1]> set nickname Gina ex 30
OK
  1. ttl 该指令用于查询某个键的过期时间,单位:秒,如果返回 -2,表示该键已经过期,如果返回 -1,表示该键永不过期
localhost:26379[1]> ttl nickname
(integer) 20
localhost:26379[1]> ttl nickname
(integer) -2
  1. dbsize 获取当前数据库中的键的总数
localhost:26379> dbsize
(integer) 0
  1. flushdb 移除当前库中所有的键
localhost:26379> flushdb
OK
  1. flushall移除所有库中的所有键
localhost:26379> flushall
OK

字符串(String)指令

String 类型是 Redis 最基本的数据类型,一个Redis 中字符串 value 最多可以是 512M

  1. setget 是字符串类型最常用的两个指令
localhost:26379[1]> set name tina
OK
localhost:26379[1]> get name
"tina"
  1. append 该指令用于给指定键的值追加字符串,如果该键不存在,则创建一个新的。
localhost:26379[1]> append name " was a leader"
(integer) 17
localhost:26379[1]> get name
"tina was a leader"
localhost:26379[1]> append k1 hello
(integer) 5
localhost:26379[1]> get k1
"hello"
  1. strlen 该指令用于获取指定键的值的字符串长度
localhost:26379[1]> strlen name
(integer) 17
  1. setnx 该指令用于保存一个字符串的值,仅当键不存在的时候可用,如果键存在,不会覆盖原值
localhost:26379[1]> get name
"tina was a leader"
localhost:26379[1]> setnx name tina
(integer) 0
localhost:26379[1]> get name
"tina was a leader"
localhost:26379[1]> set nick Gina
OK
localhost:26379[1]> get nick
"Gina"
  1. incr 该指令用于将指定键的整数值进行原子递增 1,decr 该指令用于将指定键的整数值进行原子递减 1
localhost:26379[1]> set age 18
OK
localhost:26379[1]> incr age
(integer) 19
localhost:26379[1]> decr age
(integer) 18

需要注意的是:如果指定的键不存在,则会创建该键,并将 0 作为初始值,再来进行自增 1 或自减 1

  1. incrby 该指令用于将指定键的整数值和给定的数值相加后覆盖原值(原子操作),

    decrby 该指令用于将指定键的整数值和给定的数值相减后覆盖原值(原子操作)

localhost:26379[1]> incrby age 2
(integer) 20
localhost:26379[1]> decrby age 3
(integer) 17

需要注意的是:如果指定的键不存在,则会创建该键,并将 0 作为初始值,再来进行加法或减法操作

  1. msetmget 能一次性处理多个键
localhost:26379[1]> mset k1 v1 k2 v2 k3 v3
OK
localhost:26379[1]> mget k1 k2 k3
1) "v1"
2) "v2"
3) "v3"
  1. msetnx 该指令用于保存多个键对应的字符串的值,跟 setnx 类似,仅当所有的键不存在的时候可用,如果存在任意一个键存在,则整个操作失败
localhost:26379[1]> msetnx k3 v3 k4 v4
(integer) 0
localhost:26379[1]> msetnx k4 v4 k5 v5
(integer) 1
localhost:26379[1]> mget k4 k5
1) "v4"
2) "v5"
  1. getrange 该指令用于获取某个键的字符串的子串,后面两个参数是子串的头尾的下标值,从 0 开始计算下标,含头含尾。
localhost:26379[1]> get name
"tina"
localhost:26379[1]> getrange name 0 1
"ti"
localhost:26379[1]> getrange name 1 1
"i"
localhost:26379[1]> getrange name 1 2
"in"
  1. setrange 该指令用于设置某个键的字符串的子串,第一个参数是下标值(包含),第二个参数是需要覆写的子串。
localhost:26379[1]> get name
"tina"
localhost:26379[1]> setrange name 1 -
(integer) 4
localhost:26379[1]> get name
"t-na"
localhost:26379[1]> setrange name 1 xyzg
(integer) 5
localhost:26379[1]> get name
"txyzg"
  1. getset 该指令用于获取指定键的字符串值,同时为其设置新的字符串值
localhost:26379[1]> set name tina
OK
localhost:26379[1]> getset name tom
"tina"
localhost:26379[1]> get name
"tom"

字符串自动扩容特点

|◀───── capacity ─────▶|
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
|◀─── len ───▶|

  • 图中内部为当前字符串实际分配的空间, capacity 一般要高于实际字符串长度 len。

  • 当字符串长度小于 1M时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。

  • 需要注意的是字符串最大长度为 512M。

列表(List)指令

Redis 的 List 是单键多值的类型。

  1. 它是简单的字符串列表,按照插入顺序排序。

  2. 可以添加一个元素到列表的头部(左边) 或者尾部( 右边 )。

  3. 它的底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。

  1. lpush 该指令将一系列值按照头插法依次加入到列表中,如果键不存在,则被创建
localhost:26379[1]> lpush num 1 3 5
(integer) 3

双向链表,采用头插法会改变入表的顺序,最终结果呈现为 5 ↔ 3 ↔ 1

  1. rpush 该指令将一系列值按照尾插法依次加入到列表中,如果键不存在,则被创建
localhost:26379[1]> rpush num 4 6 8
(integer) 6

双向链表,采用尾插法不会改变入表的顺序,最终结果呈现为 5 ↔ 3 ↔ 1 ↔ 4 ↔ 6 ↔ 8

  1. lrange 该指令用于返回指定键的列表中的特定下标之间的值,含头含尾。若下标值为负值,则从尾部开始计算
localhost:26379[1]> lrange num 0 -1
1) "5"
2) "3"
3) "1"
4) "4"
5) "6"
6) "8"
  1. lpop 该指令用于返回头部第一个元素,并缩短列表长度,如果列表长度为零,则键也会被移除
localhost:26379[1]> lpop num
"5"
localhost:26379[1]> lrange num 0 -1
1) "3"
2) "1"
3) "4"
4) "6"
5) "8"
  1. rpop 该指令用于返回尾部第一个元素,并缩短列表长度,如果列表长度为零,则键也会被移除
localhost:26379[1]> rpop num
"8"
localhost:26379[1]> lrange num 0 -1
1) "3"
2) "1"
3) "4"
4) "6"
  1. rpoplpush 该指令用于将第一个键的列表的尾部元素转移到第二个键的列表的头部
localhost:26379[1]> lpush odd 1 3 5
(integer) 3
localhost:26379[1]> rpush even 4 6 8
(integer) 3
localhost:26379[1]> rpoplpush odd even
"1"
localhost:26379[1]> lrange odd 0 -1
1) "5"
2) "3"
localhost:26379[1]> lrange even 0 -1
1) "1"
2) "4"
3) "6"
4) "8"
  1. lindex 该指令用于获取指定键的列表中特定下标的值,正数从列表头部开始,负数则从列表尾部开始
localhost:26379[1]> lindex even 1
"4"
localhost:26379[1]> lindex even -1
"8"
  1. llen 该指令用于获取指定键的列表的长度
localhost:26379[1]> llen even
(integer) 4
  1. linsert 该指令用于向指定键的列表中特定元素前或后插入新的值

向 even 中的 元素 4 前插入元素 2

localhost:26379[1]> linsert even before 4 2
(integer) 5
localhost:26379[1]> lrange even 0 -1
1) "1"
2) "2"
3) "4"
4) "6"
5) "8"

向 even 中的 元素 4 后插入元素 2

localhost:26379[1]> linsert even after 4 2
(integer) 6
localhost:26379[1]> lrange even 0 -1
1) "1"
2) "2"
3) "4"
4) "2"
5) "6"
6) "8"
  1. lrem 该指令用于删除指定键的列表中特定个数的特定元素,下例中的第一个 2 是指删除两个元素,第二个 2 指的是删除元素 2,所以,其结果就是会从列表头部开始删除两个元素 2
localhost:26379[1]> lrem even 2 2
(integer) 2
localhost:26379[1]> lrange even 0 -1
1) "1"
2) "4"
3) "6"
4) "8"
  1. lset 该指令用于将指定键的列表中特定的下标的元素替换为新的元素值
localhost:26379[1]> lset even 0 2
OK
localhost:26379[1]> lrange even 0 -1
1) "2"
2) "4"
3) "6"
4) "8"

集合(Set)指令

Redis 的 Set 是自动排重的集合类型。

  1. 它是一个无序集合,且集合中的元素不允许重复,可以类比 Java 中的 HashSet
  2. 它的底层是采用的哈希表,其添加、删除和查找的时间复杂度都是 O(1)
  1. sadd 该指令用于将一系列值依次加入到集合中,如果键不存在,则被创建
localhost:26379[1]> sadd sk Tina Gina Tina Anne
(integer) 3
  1. smembers 该指令用于获取集合中的所有元素
localhost:26379[1]> smembers sk
1) "Tina"
2) "Gina"
3) "Anne"
  1. sismember 该指令用于判断集合中是否存在某个元素
localhost:26379[1]> sismember sk Tina
(integer) 1
localhost:26379[1]> sismember sk Tom
(integer) 0
  1. scard 该指令用于获取集合的元素总数
localhost:26379[1]> scard sk
(integer) 3
  1. spop 该指令用于从集合中随机弹出一个值,并缩短集合长度
localhost:26379[1]> spop sk
"Gina"
  1. srem 该指令用于删除集合中的特定元素
localhost:26379[1]> srem sk Tina
(integer) 1
localhost:26379[1]> smembers sk
1) "Anne"
  1. srandmember 该指令用于随机从集合中挑选一个元素并返回,但不会缩短集合长度
localhost:26379[1]> sadd sk Tina Gina
(integer) 2
localhost:26379[1]> srandmember sk 1
1) "Anne"
  1. smove 该指令用于将第一个键对应的集合中的某个元素移动到第二个集合中,下例中将 girl 中的Tina 移动到 boy 中
localhost:26379[1]> sadd girl Tina Gina
(integer) 2
localhost:26379[1]> sadd boy Tom Jack
(integer) 2
localhost:26379[1]> smove girl boy Tina
(integer) 1
localhost:26379[1]> smembers girl
1) "Gina"
localhost:26379[1]> smembers boy
1) "Tina"
2) "Jack"
3) "Tom"
  1. sinter 用于获取两个集合的交集sunion 用于获取两个集合的并集sdiff 用于获取两个集合的差集,在第一个集合不在第二个集合
localhost:26379[1]> sadd even 2 4 6
(integer) 3
localhost:26379[1]> sadd prime 2 3 5
(integer) 3
localhost:26379[1]> sinter even prime
1) "2"
localhost:26379[1]> sunion even prime
1) "2"
2) "3"
3) "4"
4) "5"
5) "6"
localhost:26379[1]> sdiff even prime
1) "4"
2) "6"

哈希(Hash)指令

Redis 的 Hash 是键值对集合类型。

  1. 它是一个键无序集合,可以类比 Java 中的 Map
  2. Hash 类型对应的数据结构是两种: ziplist (压缩列表),hashtable (哈希表)。
  3. 当键值对集合长度较短且个数较少时,使用 ziplist,否则使用 hashtable。
  1. hset 该指令用于将一系列键值对依次加入到Hash集合中,如果键不存在,则被创建
localhost:26379[1]> hset user name Tina age 20
(integer) 2
  1. hget 该指令用于获取指定键中的 Hash 集合中的某个键的值
localhost:26379[1]> hget user name
"Tina"
localhost:26379[1]> hget user age
"20"
  1. hexists 该指令用于查询指定键的 Hash 集合中的某个键是否存在
localhost:26379[1]> hexists user name
(integer) 1
localhost:26379[1]> hexists user gender
(integer) 0
  1. hkeys 该指令用于获取指定键的 Hash 集合中所有的键
localhost:26379[1]> hkeys user
1) "name"
2) "age"
3) "address"
4) "weight"
5) "height"
  1. hvals 该指令用于获取指定键的 Hash 集合中所有的值
localhost:26379[1]> hvals user
1) "Tina"
2) "20"
3) "Wuhan"
4) "50kg"
5) "178cm"
  1. hincrby 该指令用于指定键的 Hash 集合中的特定键的值自增一个数值
localhost:26379[1]> hincrby user age 1
(integer) 21
localhost:26379[1]> hincrby user age 3
(integer) 24
localhost:26379[1]> hget user age
"24"
  1. hsetnx 该指令用于保存一个键值对的到指定的键,仅当键不存在的时候可用,如果键存在,不会覆盖原值
localhost:26379[1]> hsetnx user name Jack
(integer) 0

有序集合(ZSet)指令

Redis 的 zset 是一个没有重复元素的字符串集合。

  1. 有序集合的每个成员都关联了一个 score ,这个 score 被用来按照从最低分到最高分的方式排序集合中的成员。
  2. 集合的成员是唯一的,但是 score 可以是重复了 。
  3. 因为元素是有序的,所以可以很快的根据 score 或者 position 来获取一个范围的元素。
  1. zadd 该指令用于将 一系列关联 score 的值依次加入到集合中,如果键不存在,则被创建
localhost:26379[1]> zadd language 100 java 50 C++ 110 python
(integer) 3
  1. zrange 该指令用于获取指定索引范围的元素的正序集合,如果需要 score,则增加 withscores 选项
localhost:26379[1]> zrange language 0 -1
1) "C++"
2) "java"
3) "python"
localhost:26379[1]> zrange language 0 -1 withscores
1) "C++"
2) "50"
3) "java"
4) "100"
5) "python"
6) "110"
  1. zrevrange 该指令用于获取指定索引范围的元素的逆序集合,如果需要 score,则增加 withscores 选项
localhost:26379[1]> zrevrange language 0 -1
1) "python"
2) "java"
3) "C++"
localhost:26379[1]> zrevrange language 0 -1 withscores
1) "python"
2) "110"
3) "java"
4) "100"
5) "C++"
6) "50"
  1. zrangebyscore 该指令用于获取指定 score 范围内的元素的正序集合,如果需要 score,则增加 withscores 选项
localhost:26379[1]> zrangebyscore language 10 105
1) "C++"
2) "java"
  1. zrevrangebyscore 该指令用于获取指定 score 范围内的元素的逆序集合,如果需要 score,则增加 withscores 选项
localhost:26379[1]> zrevrangebyscore language 105 10
1) "java"
2) "C++"
  1. zcount 该指令用于统计指定 score 范围内的元素的总数
localhost:26379[1]> zcount language 10 100
(integer) 2
  1. zrank 该指令用于获取指定元素在集合中的排序值
localhost:26379[1]> zrank language java
(integer) 1
localhost:26379[1]> zrank language python
(integer) 2
  1. zrem 该指令用于删除集合中的元素
localhost:26379[1]> zrem language C++
(integer) 1
localhost:26379[1]> zrange language 0 -1
1) "java"
2) "python"
  1. zincrby 该指令用于将指定元素的 socre 值增加一个增量
localhost:26379[1]> zrange language 0 -1 withscores
1) "java"
2) "100"
3) "python"
4) "110"
localhost:26379[1]> zincrby language 11 java
"111"
localhost:26379[1]> zrange language 0 -1 withscores
1) "python"
2) "110"
3) "java"
4) "111"

Redis 的发布和订阅

什么是发布和订阅?

Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者(sub)接收消息。

Redis 客户端可以订阅任意数量的频道。


              ┌─────────────────────┐
              │      publisher      │
              └──────────┬──────────┘
                         │
                         ▼
           ┌───────────────────────────┐
           │          Channel          │
           └───────────────────────────┘
              ▲          ▲           ▲
              │          │           │
              │          │           │
              │          │           │ 
 ┌────────────┐    ┌────────────┐    ┌────────────┐
 │ subscriber │    │ subscriber │    │ subscriber │
 └────────────┘    └────────────┘    └────────────┘

  1. 启动客户端(假定为 A ),订阅两个频道 c1 和 c2
localhost:26379[1]> subscribe c1 c2
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "c1"
3) (integer) 1
1) "subscribe"
2) "c2"
3) (integer) 2
  1. 启动客户端(假定为 B),向两个频道发布消息
127.0.0.1:26379[1]> publish c1 hello
(integer) 1
127.0.0.1:26379[1]> publish c2 hi
(integer) 1
  1. 在客户端 B 发布的时刻,客户端 A 就能立即收到来自 B 发送的消息
1) "message"
2) "c1"
3) "hello"

1) "message"
2) "c2"
3) "hi"

Jedis 操作

  1. 在 maven 项目中引入依赖
<dependencies>
    <dependency>
        <groupId>junitgroupId>
        <artifactId>junitartifactId>
        <version>4.13.2version>
        <scope>testscope>
    dependency>
    <dependency>
        <groupId>redis.clientsgroupId>
        <artifactId>jedisartifactId>
        <version>3.2.0version>
    dependency>
dependencies>
  1. 创建单元测试类,对 Redis 做单元测试
@RunWith(JUnit4.class)
public class TestRedis {
	private final String HOST = "localhost";
	private final int PORT = 26379;
	private final Jedis jedis = new Jedis(HOST, PORT);

	@Before
	public void testBefore() {
		jedis.auth("123456");
		jedis.select(2);
	}

	@After
	public void testAfter() {
		jedis.close();
	}

	@Test
	public void testConnection() {
        // 输出 PONG 则说明连接成功!
		System.out.println(jedis.ping());
	}

	@Test
	public void testStringApi() {
		final String key = "test:string-key";
		// 测试 set/get 方法
		jedis.set(key, "Tina");
		String val = jedis.get(key);
		System.out.println(key + ":" + val);

		// 测试过期时间
		jedis.setex(key, 3, "Tina");
		System.out.println(jedis.get(key));
		Long ttl = jedis.ttl(key);
		System.out.println(key + "剩余" + ttl + "s");
		boolean exists;
		do {
			try {
				ttl = jedis.ttl(key);
				System.out.println("等待" + ttl + "s");
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			exists = Boolean.TRUE.equals(jedis.exists(key));
			System.out.println(key + "是否存在:" + exists);
		} while (exists);

		// 测试自增
		for (int i = 0; i < 18; i++) {
			jedis.incr(key);
		}
		val = jedis.get(key);
		System.out.println(key + ":" + val);
	}

	@Test
	public void testListApi() {
		final String k1 = "test:list:even";
		final String k2 = "test:list:prime";

		// 添加元素 —— 头插法
		jedis.lpush(k1, "2", "4", "6");
		// 添加元素 —— 尾插法
		jedis.rpush(k2, "2", "3", "5");

		// 添加元素 —— 将 List 集合中的数据插入到 Redis
		List<String> strings = Arrays.asList("6", "8", "10");
		jedis.rpush(k1, strings.toArray(new String[3]));

		// 查询总数和所有元素
		strings = jedis.lrange(k1, 0, -1);
		Long len = jedis.llen(k1);
		System.out.println("k1 的长度 :" + len + ",k1 的值 :" + strings);
		strings = jedis.lrange(k2, 0, -1);
		len = jedis.llen(k2);
		System.out.println("k2 的长度 :" + len + ",k2 的值 :" + strings);

		// 测试往索引位置插入值
		long index = 2L;
		String val = jedis.lindex(k1, index);
		System.out.println("索引" + index + "位置的值是:" + val);
		jedis.lset(k1, index, "-6");
		System.out.println("替换为 -6 后, k1 的值 :" + jedis.lrange(k1, 0, -1));
		jedis.linsert(k1, ListPosition.AFTER, "-6", "-7");
		System.out.println("插入 -7 后, k1 的值 :" + jedis.lrange(k1, 0, -1));

		// 删除集合中两个元素 6
		jedis.lrem(k1, 2, "6");
		System.out.println("移除两个 6 后, k1 的值 :" + jedis.lrange(k1, 0, -1));

		// 弹出首尾的元素
		String pop = jedis.rpop(k1);
		System.out.println("pop is:" + pop);
		System.out.println("尾部弹出后, k1 的值 :" + jedis.lrange(k1, 0, -1));
		pop = jedis.lpop(k1);
		System.out.println("pop is:" + pop);
		System.out.println("首部弹出后, k1 的值 :" + jedis.lrange(k1, 0, -1));

		// 将第一个集合的尾部元素移动到第二个集合的头部
		jedis.rpoplpush(k1, k2);
		System.out.println("k1 is:" + jedis.lrange(k1, 0, -1));
		System.out.println("k2 is:" + jedis.lrange(k2, 0, -1));
	}

	@Test
	public void testSetApi() {
		final String k1 = "test:set:even";
		final String k2 = "test:set:prime";

		// 添加元素
		jedis.sadd(k1, "2", "4", "6", "8", "10", "12");
		Set<String> members = new HashSet<>(Arrays.asList("2", "3", "5", "7"));
		jedis.sadd(k2, members.toArray(new String[0]));

		// 查询总数和所有元素
		members = jedis.smembers(k1);
		Long len = jedis.scard(k1);
		System.out.println("k1 的长度 :" + len + ",k1 的值 :" + members);
		members = jedis.smembers(k2);
		len = jedis.scard(k2);
		System.out.println("k2 的长度 :" + len + ",k2 的值 :" + members);

		// 判断元素是否存在
		Boolean sis = jedis.sismember(k1, "2");
		System.out.println("2是否存在:" + sis);
		sis = jedis.sismember(k1, "1");
		System.out.println("1是否存在:" + sis);

		// 移除元素
		jedis.srem(k1, "8", "10");
		System.out.println("移除 8 和 10 后, k1 的值 :" + jedis.smembers(k1));

		// 随机元素
		String member = jedis.srandmember(k1);
		System.out.println("随机挑选的值为:" + member);
		member = jedis.spop(k1);
		System.out.println("随机弹出的值为:" + member + "后, k1 的值 :" + jedis.smembers(k1));

		// 移动元素
		jedis.smove(k1, k2, "12");
		System.out.println("移动 12 后, k1 的值 :" + jedis.smembers(k1));
		System.out.println("移动 12 后, k2 的值 :" + jedis.smembers(k2));

		// 交集、并集和差集
		System.out.println("k1, k2 的交集:" + jedis.sinter(k1, k2));
		System.out.println("k1, k2 的并集:" + jedis.sunion(k1, k2));
		System.out.println("k1, k2 的差集:" + jedis.sdiff(k1, k2));
	}

	@Test
	public void testHashApi() {
		final String k1 = "test:hash:user";
		Map<String, String> map = new HashMap<>();

		// 添加元素
		jedis.hset(k1, "name", "tina");
		{
			map.put("age", "20");
			map.put("weight", "50kg");
			map.put("height", "170cm");
			map.put("address", "武汉");
			jedis.hset(k1, map);
		}

		// 查询总数和所有元素
		map = jedis.hgetAll(k1);
		Long len = jedis.hlen(k1);
		System.out.println("k1 的长度 :" + len + ",k1 的值 :" + map);

		// 判断元素是否存在
		Boolean sis = jedis.hexists(k1, "name");
		System.out.println("name 是否存在:" + sis);
		sis = jedis.hexists(k1, "gender");
		System.out.println("gender 是否存在:" + sis);

		// 移除元素
		jedis.hdel(k1, "weight", "height");
		System.out.println("移除 weight 和 height 后, k1 的值 :" + jedis.hgetAll(k1));
	}

	@Test
	public void testZSetApi() {
		final String k1 = "test:zset:language";
		Map<String, Double> map = new HashMap<>();
		Set<String> set;
		{
			map.put("java", 120D);
			map.put("c++", 110D);
			map.put("python", 105D);
			map.put("c#", 85D);
			map.put("vb", 34D);
			map.put("php", 80D);
			map.put("javascript", 140D);
		}

		// 添加元素
		jedis.zadd(k1, 50D, "scala");
		jedis.zadd(k1, map);

		// 查询总数和所有元素
		set = jedis.zrange(k1, 0, -1);
		Long len = jedis.zcard(k1);
		Long count = jedis.zcount(k1, 100D, 130D);
		System.out.println("k1 的长度 :" + len + ", [100, 130]的个数 :" + count);
		System.out.println("k1 的值 :" + set);

		// 带 score 的查询
		Set<Tuple> tuples = jedis.zrangeByScoreWithScores(k1, 90D, 120D);
		System.out.println("顺[90, 120]:" + tuples);
		tuples = jedis.zrevrangeByScoreWithScores(k1, 120D, 90D);
		System.out.println("逆[90, 120]:" + tuples);

		// 删除元素
		jedis.zrem(k1, "vb", "scala");
		System.out.println("移除 vb 和scala 后, k1 的值 :" + jedis.zrange(k1, 0, -1));

		// 增量
		jedis.zincrby(k1, 20D, "c++");
		System.out.println("调整 score 后, k1 的值 :" + jedis.zrange(k1, 0, -1));
	}
}

Redis 应用

会话管理

spring-session-data-redis 是一个Java Spring框架的库,它用于将Spring Session的数据存储在Redis中。

Spring Session 是Spring生态系统中的一部分,它用于管理用户会话的状态。

  1. 引入相关依赖
<dependencies>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
        <groupId>org.springframework.sessiongroupId>
        <artifactId>spring-session-data-redisartifactId>
    dependency>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-data-redisartifactId>
    dependency>
dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-dependenciesartifactId>
            <version>2.3.8.RELEASEversion>
            <scope>importscope>
            <type>pomtype>
        dependency>
    dependencies>
dependencyManagement>
  1. 配置 application.yml
spring:
  redis:
    port: 26379
    host: localhost
    password: 123456
    database: 3
  1. 编写会话拦截器
public class SessionInterceptor implements HandlerInterceptor {
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                
		HttpSession session = request.getSession();
		if (!(session != null && session.getAttribute("UNAME") != null)) {
            // 当前请求没有会话数据,则视作未登录,跳转到登录页
			response.sendRedirect("/login");
			return false;
		}
		return true;
	}
}
  1. 编写配置类
@Configuration
@EnableRedisHttpSession // 启用 Redis 会话管理
public class SessionConfigure implements WebMvcConfigurer {
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
        // 增加拦截器及其拦截地址
		registry.addInterceptor(new SessionInterceptor())
				.addPathPatterns("/dashboard", "/dashboard/*");
	}
}
  1. 编写登录处理逻辑
@GetMapping("/login")
public String index(HttpSession session, String username, String password) {
    
    ...... 此处省略验证账号密码 ......
    
    // 设置会话数据信息
    session.setAttribute("UNAME", username);
    return "redirect:/dashboard";
}

验证码

  1. 引入依赖
<dependency>
    <groupId>org.apache.commonsgroupId>
    <artifactId>commons-lang3artifactId>
dependency>
<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-mailartifactId>
dependency>


  1. 设置配置文件

配置中的邮箱账号和密码需要开启 POP3/SMTP 服务

spring:
  redis:
    port: 26379
    host: localhost
    password: 123456
    database: 3
  mail:
    port: 465
    protocol: smtp
    host: smtp.yeah.net
    properties:
      mail:
        smtp:
          auth: true
          ssl:
            trust: smtp.yeah.net
          starttls:
            enable: true
            required: true
          socketFactory:
            port: 465
            class: javax.net.ssl.SSLSocketFactory
        debug: true
    username: [email protected]
    password: **********************
    default-encoding: UTF-8
  1. 编写 Java 类
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
	private static final String KEY_CAPTCHA = "CAPTCHA_CODE";

	@Value("${spring.mail.username}")
	private String mailUsername;
	@Resource
	private StringRedisTemplate redis;
	@Resource
	private JavaMailSender sender;

	/**
	 * 验证邮箱验证码
	 *
	 * @param mail 邮箱
	 * @param code 验证码
	 */
	@RequestMapping("verify/email")
	public String verifyMail(String mail, String code) {
		ValueOperations<String, String> ops = redis.opsForValue();
		String key = "captcha:" + mail;
		String captcha = ops.get(key);
		boolean matched = code != null && code.equals(captcha);
		if (matched) {
			redis.delete(key);
		}
		return matched ? "OK" : "NO";
	}

	/**
	 * 发送邮箱验证码
	 *
	 * @param mail 邮箱
	 */
	@RequestMapping("send/email")
	public String sendMail(String mail) {
		String code = RandomStringUtils.randomAlphanumeric(6);
		int minutes = 5;
		String content = "您的验证码是:[" + code + "]," + minutes + "分钟内有效";
		// 将验证码保存到 redis 中并
		ValueOperations<String, String> ops = redis.opsForValue();
		ops.set("captcha:" + mail, code, minutes, TimeUnit.MINUTES);
		try {
			MimeMessage message = sender.createMimeMessage();
			MimeMessageHelper helper = new MimeMessageHelper(message);
			try {
				helper.setFrom(mailUsername, "武汉晴川学院");
			} catch (UnsupportedEncodingException e) {
				e.printStackTrace();
			}
			helper.setTo(mail);
			helper.setSubject("登录验证码");
			helper.setText(content, true);
			helper.setValidateAddresses(true);
			sender.send(message);
		} catch (MessagingException e) {
			e.printStackTrace();
			return "邮件发送出错:" + e.getMessage();
		}
		return "邮件已发送,请注意查收";
	}

}

限流

本案例主要涉及到的是 redis 的键会自动过期这个特点,参考代码如下:

@Component
public class CaptchaAccessLimitInterceptor implements HandlerInterceptor {
	// 设置访问限制时间,单位:分钟
	private static final int LIMIT_TIME = 1;
	@Resource
	private StringRedisTemplate redis;

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
		HttpSession session = request.getSession();
		ValueOperations<String, String> ops = redis.opsForValue();
		String id = session.getId();
		String key = "CaptchaAccessLimit:" + id;
		boolean exists = Boolean.TRUE.equals(redis.hasKey(key));
		Long val = ops.increment(key);
		val = val == null ? 0 : val;
		// 【键不存在】 或者 【访问次数在 1 分钟内 5 次以下】,允许操作
		if (exists) {
			System.out.println("在" + LIMIT_TIME + "分钟内第" + val + "次访问");
			if (val > 5) {
				response.setContentType("text/plain;charset=utf-8");
				PrintWriter writer = response.getWriter();
				writer.write("访问次数过多,访问受限");
				writer.close();
				return false;
			}
		} else {
			redis.expire(key, LIMIT_TIME, TimeUnit.MINUTES);
			System.out.println("首次访问,设置访问次数:" + val);
		}
		return true;
	}
}

限时抽奖

抽奖案例采用的是 redis 的 set 的 spop,参考代码如下:

@RestController
@RequestMapping("/raffle")
public class RaffleController {
	private static final String KEY_RAFFLE = "RAFFLE";

	@Value("${spring.redis.host}")
	private String host;
	@Value("${spring.redis.port}")
	private int port;
	@Value("${spring.redis.password}")
	private String password;
	@Value("${spring.redis.database}")
	private int database;

	private Jedis jedis;

	@PostConstruct
	public void init() {
		jedis = new Jedis(host, port);
		jedis.auth(password);
		jedis.select(database);
		jedis.sadd(KEY_RAFFLE, "XIAOMI MIX Fold 3", "HUAWEI Mate60 Pro");
		jedis.sadd(KEY_RAFFLE, "OPPO Find N3 Flip", "VIVO s17");
		jedis.expire(KEY_RAFFLE, 1 * 60);
	}

	@GetMapping(path = "", produces = {"text/html;charset=utf-8"})
	public String index() {
		Long ex = jedis.ttl(KEY_RAFFLE);
		ex = ex == null ? 0L : ex;
		Set<String> strings = jedis.smembers(KEY_RAFFLE);
		if (ex > -1) {
			return "" +
					"

随机抽奖(还剩" + ex + "秒)

"
+ "
  1. " + String.join("
  2. ", strings) + "
"
+ "
立即抢购"
; } return "

随机抽奖(活动已经结束)


"
; } @GetMapping(path = "start", produces = {"text/html;charset=utf-8"}) public String start() { String member = jedis.spop(KEY_RAFFLE); return "

成功抢到" + member + "


返回"
; } }

计数器、点赞、签到、打卡

这三个案例均可以采用了 redis 的 zset 来处理,参考代码如下:

@RestController
@RequestMapping("counter")
public class CounterController {
	private static final String KEY_COUNTER = "COUNTER";

	@Resource
	private StringRedisTemplate redis;

	private static final ObjectMapper mapper = new ObjectMapper();

	@PostConstruct
	public void init() throws JsonProcessingException {
		ValueOperations<String, String> opsForValue = redis.opsForValue();
		ZSetOperations<String, String> opsForZSet = redis.opsForZSet();
		opsForZSet.removeRange(KEY_COUNTER, 0, -1);
		for (int i = 0; i < 30; i++) {
			Article article = new Article();
			article.setId(i);
			article.setTitle("文章[" + RandomStringUtils.randomAlphabetic(10) + "]");
			article.setContent(RandomStringUtils.randomAlphabetic(100, 20000));
			String string = mapper.writeValueAsString(article);
			String key = KEY_COUNTER + ":" + article.getId();
			opsForValue.set(key, string);
			opsForZSet.add(KEY_COUNTER, key, 0.0);
		}
	}

	@GetMapping(path = "", produces = {"text/html;charset=utf-8"})
	public String index() throws JsonProcessingException {
		ZSetOperations<String, String> opsForZSet = redis.opsForZSet();
		ValueOperations<String, String> opsForValue = redis.opsForValue();
		List<String> strings = new ArrayList<>();
		Set<TypedTuple<String>> tuples = opsForZSet.rangeWithScores(KEY_COUNTER, 0, -1);
		if (tuples != null) {
			for (TypedTuple<String> tuple : tuples) {
				Double score = tuple.getScore();
				String key = tuple.getValue();
				if (key == null) {
					continue;
				}
				String s = opsForValue.get(key);
				Article article = mapper.readValue(s, Article.class);
				strings.add("
  • " + article.getTitle() + "(" + article.getId() + ")(" + score + ")加赞" + "减赞
  • "
    ); } } return "

    文章列表


      " + String.join("", strings) + "
    "
    ; } @GetMapping(path = "incr", produces = {"text/html;charset=utf-8"}) public String incr(Integer id, Double step) { step = step == null || step == 0 ? 1D : -1D; ZSetOperations<String, String> ops = redis.opsForZSet(); String key = KEY_COUNTER + ":" + id; ops.incrementScore(KEY_COUNTER, key, step); return ""; } }

    原子操作

    • 如何理解原子操作?

    原子操作指的是一个事务包含多个操作,这些操作要么全部执行,要么全都不执行

    • 在 Java 中,如果在多个线程对同一个变量进行循环累加的操作,其结果会是原子性的吗?先看下面这个单元测试的代码
    @RunWith(JUnit4.class)
    public class TestAtomicity {
    	private static int number = 0;
    
    	public void increase() {
    		String name = Thread.currentThread().getName();
    		System.out.println(name + "`s 开始循环前,number = " + number);
    		for (int i = 0; i < 10000; i++) {
    			number++;
    		}
    	}
    
    	@Test
    	public void testIncreasingOnMultiThreading() throws InterruptedException {
    		int count = 10;
    		Thread[] ths = new Thread[count];
    		for (int i = 0; i < count; i++) {
    			ths[i] = new Thread(this::increase, "T" + i);
    			ths[i].start();
    		}
    		for (int i = 0; i < count; i++) {
    			ths[i].join();
    		}
    		System.out.println("main:" + number);
    	}
    }
    

    从该单元测试的结果不难发现有两个结论:

    1. 无论怎样,每个线程循环的次数都是一万次,那也就意味着 ++ 操作也应该是执行了 10 ✖ 10000 次,但是从 main 函数中的输出结果来看,可以确定的是 Java 语言中的 ++ 操作本身并非是原子操作。
    2. 如果出现多个进程对同一个方法(increase)进行调用的时候,由于线程的创建和调用都是不确定的,所以,每个线程在执行循环前获得的 number 的值并不是预期值,进而使得方法内部的一系列代码无法做到原子性。

    解决思路:

    1. 使用 AtomicInteger 代替 int,使用 incrementAndGet 方法代替 ++ 操作,这样能规避 ++ 造成的问题
    2. 在 increase 方法签名中使用 synchronized 关键字,这样能规避多线程调用引发的问题。

    你可能感兴趣的:(软件开发笔记,redis,数据库,缓存)