一、前言
1. 基本概念
理解:字符串对象是 Redis 中最基本的数据类型,也是我们工作中最常用的数据类型。redis中的键都是字符串对象,而且其他几种数据结构都是在字符串对象基础上构建的。字符串对象的值实际可以是字符串、数字、甚至是二进制,最大不能超过512MB
Key:所有 key 都是二进制安全的字符串(binary-safe string)。可以包含任意字符(包括中文、空格、特殊符号等),例如:
SET user:1:name "张三" SET log:2024-01-01 "\x01\x02\x03"
Value:虽然 Redis 的底层统一使用
robj
(Redis Object)来表示对象,但 value 可以是:string、list、hash、set等
⚠️ 但所有这些数据结构,其“元素”本质上也都是字符串,比如列表中的每个元素是字符串,集合中的每个成员也是字符串
2. string 存储方式
🔥 由于Redis内部存储字符串完全是按照 二进制流 的形式保存的,所以Redis是 不处理字符集编码问题 的,客户端传入的命令中使用的是什么字符集编码,就存储什么字符集编码
① 二进制安全(Binary Safe)
- Redis 的 String 是二进制安全 的,意味着它可以存储任何形式的数据:
- 文本:如
"hello"
、"你好"
、"{"name":"Tom"}"
- 数值:整数或浮点数,如
123
,3.14
- 二进制数据:图片、音频、视频、序列化后的对象等
- 文本:如
- Redis 不会对写入的数据进行编码转换或处理。
② Redis 不处理字符集编码
- Redis 不关心你传进来的是 UTF-8、GBK 还是其他编码格式。
- 客户端发送什么编码,Redis 就存储什么编码。
- 解码工作由客户端负责完成。
❗ 因此,在使用 Redis 存储文本数据时,务必确保客户端使用的字符集与读取时一致,否则会出现乱码(避免乱码)
3. 支持的数据类型
数据类型 | 示例 | 特点 |
---|---|---|
文本数据 | "Hello World" ,"{\"id\":1}" |
可以是普通文本或 JSON/XML 等结构化文本 |
数字 | 123 ,3.14 |
Redis 自动识别为整数或浮点数,并优化存储为int 编码 |
二进制数据 | 图片、视频、序列化对象 | Redis 会以原始字节形式存储 |
4. 与 MySQL 字符串对比
对比项 | Redis | MySQL |
---|---|---|
字符集处理 | 不处理字符集,原样存储 | 默认使用特定字符集(如 latin1 或 utf8mb4) |
编码转换 | 不做任何转换 | 插入/查询时可能自动进行编码转换 |
乱码问题 | 客户端控制,Redis 不参与 | 如果配置不当容易出现乱码 |
二进制存储 | 支持任意二进制数据 | BLOB 类型可存二进制,但操作不如 Redis 简便 |
最大容量 | 单个 value 最大 512MB | TEXT/LONGTEXT/BLOB 有大小限制,但通常更大 |
5. 性能考虑
Redis 是单线程模型(核心命令处理)
- 所有命令都在一个主线程中执行(Redis 6.0+ 多线程仅用于 I/O)
- 因此,不能执行耗时过长的操作 ,否则会影响整个服务的响应速度。
⚠️ 不建议:
- 存储过大字符串(如几百 MB 的文件)
- 执行复杂计算(如大范围遍历、正则匹配)
- 使用慢命令(如
KEYS *
、SMEMBERS
等)
二、命令
1. 常用命令
① SET
⛵️ 将 string
类型的 value
设置到key中。**如果key之前存在,则覆盖,无论原来的数据类型是什么。**之前关于此key的TTL也全部失效。命令如下:
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
SET 命令支持多种选项来影响其行为,如下:
EX seconds
:以秒为单位,设置超时时间PX milliseconds
:以毫秒为单位,设置超时时间NX
:只有key不存在才设置,如果存在返回nil
XX
:只有key存在就更新,如果不存在返回nil
注意:由于带选项的SET命令可以被 SETNX 、 SETEX 、 PSETEX 等命令代替,所以之后的版本中,Redis可能进行合并
示例
127.0.0.1:6379> set mykey "Hello"
OK
127.0.0.1:6379> get mykey
"Hello"
127.0.0.1:6379> set mykey "World" NX
(nil)
127.0.0.1:6379> set mykey "World" XX
OK
127.0.0.1:6379> ttl mykey
(integer) -1
127.0.0.1:6379> set mykey "Island" EX 10
OK
127.0.0.1:6379> ttl mykey
(integer) 8
② GET
获取key对应的value。如果key不存在,返回nil。如果value的数据类型不是string,会报错。
127.0.0.1:6379> hset mykey name Bob
(integer) 1
127.0.0.1:6379> get mykey
(error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> del mykey
(integer) 1
127.0.0.1:6379> set mykey Bob
OK
127.0.0.1:6379> get mykey
"Bob"
③ MGET
⼀次性获取多个key的值。如果对应的key不存在或者对应的数据类型不是string,返回 nil
,语法如下:
MGET key [key ...]
示例
127.0.0.1:6379> sey key1 1
(error) ERR unknown command `sey`, with args beginning with: `key1`, `1`,
127.0.0.1:6379> set key1 1
OK
127.0.0.1:6379> set key2 2
OK
127.0.0.1:6379> MGET key1 key2 key3
1) "1"
2) "2"
3) (nil)
④ MSET
⼀次性设置多个key的值。语法如下:
MSET key value [key value ...]
示例
127.0.0.1:6379> MSET key1 1 key2 2
OK
127.0.0.1:6379> MGET key1 key2
1) "1"
2) "2"
多次 GET 和单次MGET比较:使用 MGET/MSET 可有效减少网络时间,性能较高
结论:学会使用批量操作,可以有效提高业务处理效率
- 注意:每次批量操作所发送的键的数量也不是无节制的,否则可能造成单⼀命令执行时间过长,导致
Redis
阻塞
⑤ SETNX|SETEX
下面将介绍 2 个针对 set 的一些常见用法, 进行了缩写.
- 之所以这样, 就是为了让操作更符合人的直觉. (使用者的门槛就越低, 要背的东西就越少)
- 编程语言中, 很多的关键词, 都是和自然语言相关的
- 后续咱们去设计一些 库, 设计一些工具, 代码给别人使用的时候, 也要尽量符合直觉,不要设计的 “反人类” / “反直觉”
setnx
命令:
setnx key value
功能:如果键不存在,则设置键值对。可以理解为 no exist 设置~
示例:
setnx key1 value1 # 返回 0,因为 key1 已存在
setex
命令:
setex key seconds value
功能:设置键值对并指定过期时间(秒)。
示例:
setex key4 10 value4 ttl key4 # 返回剩余时间
2. 计数命令
由于string
内部还可以存储数字,所以Redis
还提供了数字操作的命令。时间复杂度:O(1)
① INCR
命令:incr key
功能:将键的值加1,如果键不存在则创建键并初始化为0。如果 key 对应的string不是⼀个整型或者范围超过了64位有符号整型,则报错。
示例
127.0.0.1:6379> set key bar
127.0.0.1:6379> incr key # 非整形
(error) ERR value is not an integer or out of range
127.0.0.1:6379> del key
127.0.0.1:6379> set key 1
127.0.0.1:6379> incr key # 存在
(integer) 2
127.0.0.1:6379> del key
127.0.0.1:6379> incr key # 不存在
(integer) 1
② INCRBY
命令:incrby key increment
功能:和 INC
R 使用类似,将键的值增加指定的整数。
示例:
incrby key2 7 # 返回 8
③ DECR
命令:deby key
功能:和 INC
R 使用类似,将键的值减1,如果键不存在则创建键并初始化为0。
示例:
set key2 8
decr key2 # 返回 7
④ DECYBY
命令:decrby key decrement
功能:将键的值减少指定的整数。
示例:
set key2 8
decrby key2 2 # 返回 6
⑤ INCRBYFLOAT
命令:incrbyfloat key increment
功能:将键的值增加指定的浮点数(允许采用 科学计数法 表示浮点数)
示例
set key1 1
INCRBYFLOAT key1 0.5 # 返回 1.5
注意:
- Redis存储整数,是直接使用int类型存的,而存储小数,本质上是当作字符串来存储
- Redis的int比较方便算术运算
- 小数意味着每次进行算术运算,都需要把字符串转成小数,进行运算,再把结果转回字符串保存
很多存储系统和编程语言内部使用 CAS 机制实现计数功能,会有⼀定的CPU开销
- 但在Redis中完全不存在这个问题,因为Redis是单线程架构,任何命令到了Redis服务端都要顺序执行
- 由于Redis处理命令的时候,是单线程模型,多个客户端同时针对同一个key进行INCR等操作,不会引起"线程安全"问题
3. 其他命令
① APPEND
命令:append key value
功能:如果key已经存在并且是⼀个string,命令会将value追加到原有string的后边。如果key不存在,则效果等同于SET命令(返回追加完成之后string的长度)
示例:
127.0.0.1:6379> exists mykey
(integer) 0
127.0.0.1:6379> append mykey "Hello"
(integer) 5
127.0.0.1:6379> get mykey
"Hello"
127.0.0.1:6379> append mykey " World"
(integer) 11
127.0.0.1:6379> get mykey
"Hello World"
在启动 redis 客户端的时候,加上一个 --raw 这样的选项就可以使 redis 客户端能够自动的把二进制数据尝试翻译
演示如下:
127.0.0.1:6379> set name "张三"
OK
127.0.0.1:6379> get name
"\xe5\xbc\xa0\xe4\xb8\x89"
lighthouse@VM-8-10-ubuntu:~$ redis-cli --raw
127.0.0.1:6379> get name
张三
② GETRANGE
命令:getrange key start end(左闭右闭,[0, len - 1])
功能:获取键值在指定范围内的子字符串。
注意:
- 如果字符串中保存的是汉字,此时进行字串切分,切出来的很可能不是完成的汉字,因为 redis 是以 字节 为单位的
- 其中redis的getrange操作与python一样是支持负数下标的,其中-1表示倒数第一个字符串,-2表示倒数第二个字符串
示例:
127.0.0.1:6379> get mykey
Hello World
127.0.0.1:6379> getrange mykey 0 3
Hell
127.0.0.1:6379> getrange mykey -3 -1
rld
127.0.0.1:6379> getrange mykey 0 -1
Hello World
127.0.0.1:6379> getrange mykey 20 100
127.0.0.1:6379> getrange mykey 5 10
World
③ SETRANGE
命令:setrange key offset value
功能:从指定偏移量开始设置键值的一部分。返回 string 长度
注意:针对不存在的key
,也可以操作,不过会把offset
之前的内容填充成 0x00
127.0.0.1:6379> get mykey
Hello World
127.0.0.1:6379> setrange mykey 6 "Redis"
11
127.0.0.1:6379> get mykey
Hello Redis
④ STRLEN
命令:strlen key
功能:获取键值的字节长度。
127.0.0.1:6379> strlen mykey
11
127.0.0.1:6379> strlen non
0
4. 小结
下表是字符串类型命令的效果、时间复杂度,开发人员可以参考此表,结合自身业务需求和数据大小选择合适的命令。
三、内部编码
Redis字符串对象底层的数据结构实现主要是int和简单动态字符串SDS(这个字符串,和我们认识的C字符串不太一样,其通过不同的编码方式映射到不同的数据结构
字符串对象的内部编码有3种 :int
、raw
和embstr
。Redis会根据当前值的类型和长度来决定使用哪种编码来实现。
默认情况下,值以字符串形式传入,如果Redis检测到字符串为数字,则转换为int存储,从而节省空间。例如,字符串"1234567890"若作为字符串存储需要10字节,而转换为int后仅需8字节
① int:当存储的值为整数,且值的大小可以用 long 类型表示时,那么字符串对象会将整数值保存在字符串对象结构的ptr
属性里面(将void*
转换成1ong
),并将字符串对象的编码设置为int
- 优点:存储空间小,且无需进行额外的解码操作( 只有整数才会使用int,如果是浮点数, Redis内部其实先将浮点数转化为字符串值,然后再保存)
② raw:当存储的值为字符串,且长度小于等于 44 字节时,Redis 使用 raw
编码。在 raw 编码中,String 对象的实际值会被存储在一个简单的 字符串对象(SDS) 中,该对象包含了字符串的长度和字符数组的指针。
- 优点:存储空间小,且无需进行额外的解码操作。
③ embstr:当存储的值为字符串,且长度大于 44 字节时,Redis 使用 embstr 编码。在 embstr 编码中,String 对象的实际值会被存储在一个特殊的字符串对象中,该对象包含了字符串的长度和字符数组的指针,但是不包含额外的空间。
- 优点:存储空间小,且无需进行额外的解码操作,但是由于需要额外的内存分配,可能会影响性能。
embstr
编码 是专门用于保存短字符串的一种优化编码方式,我们可以看到embstr
和raw
编码都会使用SDS
来保存值,但不同之处在于embstr
会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject
和SDS
。而raw
编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject
和SDS
。Redis这样做会有很多好处。
embstr
编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次- 释放
embstr
编码的字符串对象同样只需要调用一次内存释放函数 - 因为
embstr
编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用CPU缓存提升性能。
明明没有超过阈值,为什么变成raw?
- 对于
embstr
,由于其实现是只读的,因此在对embstr
对象进行修改时,都会先 转化为 raw 再进行修改。因此,只要是修改embstr
对象,修改后的对象一定是 raw 的,无论是否达到了 44 个字节。
Redis中根据数据类型和长度来使用不同的编码和数据结构存储存在于Redis中的每一种对象类型上。其这种小细节上的优化令我叹服不止,后续我们会看到Redis中到处都是这种内存与性能上的小细节优化!
Redis会根据当前值的类型和长度 动态决定使用哪种内部编码实现
# 整形
> set key 2333
OK
> object encoding key
"int"
# 短字符串
> set key "hello"
OK
> object encoding key
"embstr"
# ⼤于39个字节的字符串
> set key "one string greater than 39 bytes ........"
OK
> object encoding key
"raw
思考:
- 某个业务场景,有很多很多的 key,类型都是 string,但是每个 value 的 string 长度都是 100 左右。
- 更关注整体的内存空间。因此,这样的字符串使用 embstr 来存储也不是不能考虑。
上述效果具体怎么实现?
先看 redis 是否提供了对应的配置项,可以修改 39 这个数字。
如果没有提供配置型,就需要针对 redis 源码进行魔改。
为啥很多大厂,往往是自己造轮子,而不是直接使用业界成熟的呢?
- 开源的组件,往往考虑的是通用性,但是大厂往往会遇到一些极端的业务场景,往往就需要根据当前的极端业务,针对上述的开源组件进行 定制化。
关于 SDS
🌤 Redis默认并未直接使用C字符串(C字符串仅仅作为字符串字面量,用在一些无需对字符串进行修改的地方,如打印日志)。而是以Struct的形式构造了一个SDS的抽象类型。当Redis需要一个可以被修改的字符串时,就会使用SDS来表示。在Redis数据库里,包含字符串值的键值对都是由SDS实现的(Redis中所有的键都是由字符串对象实现的 即底层是由SDS实现,Redis中所有的值对象中包含的字符串对象底层也是由SDS实现)
Copystruct sdshdr{
//int 记录buf数组中未使用字节的数量 如上图free为0代表未使用字节的数量为0
int free;
//int 记录buf数组中已使用字节的数量即sds的长度 如上图len为5代表未使用字节的数量为5
int len;
//字节数组用于保存字符串 sds遵循了c字符串以空字符结尾的惯例目的是为了重用c字符串函数库里的函数
char buf[];
}
为什么要使用SDS
上图表示了SDS与C字符串的区别,关于为什么Redis要使用SDS而不是C字符串,我们可以从以下几个方面来分析。
1. 缓冲区溢出
⛽️ C字符串,如果程序员在字符串修改的时候如果忘记给字符串重新分配足够的空间,那么就会发生内存溢出,如上图所示,忘记给s1分配足够的内存空间,s1的数据就会溢出到s2的空间, 导致s2的内容被修改。而Redis提供的SDS其内置的空间分配策略则可以完全杜绝这种事情的发生。当API需要对SDS进行修改时,API会首先会检查SDS的空间是否满足条件,如果不满足, API会自动对它动态扩展, 然后再进行修改。
2. 内存重分配
C字符串内存重分配
在C字符串中,如果对字符串进行修改,那么我们就不得不面临内存重分配。因为C字符串是由一个N+1长度的数组组成,如果字符串的长度变长,我们就必须对数组进行扩容,否则会产生内存溢出。而如果字符串长度变短,我们就必须释放掉不再使用的空间,否则会发生内存泄漏。
SDS空间分配策略
对于Redis这种具有高性能要求的内存数据库,如果每次修改字符串都要进行内存重分配,无疑是巨大的性能损失。而Redis的SDS提供了两种空间分配策略来解决这个问题。
空间预分配:我们知道在数组进行扩容的时候,往往会申请一个更大的数组,然后把数组复制过去。为了提升性能,我们在分配空间的时候并不是分配一个刚刚好的空间,而是分配一个更大的空间。Redis同样基于这种策略提供了空间预分配。当执行字符串增长操作并且需要扩展内存时,程序不仅仅会给SDS分配必需的空间还会分配额外的未使用空间,其长度存到free属性中。其分配策略如下:
- 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节, 那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte
- 如果修改后len长度将大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M.buf实际长度变成了30M+1M+1byte
惰性空间释放:惰性空间释放用于字符串缩短的操作。当字符串缩短是,程序并不是立即使用内存重分配来回收缩短出来的字节,而是使用free属性记录起来,并等待将来使用。
Redis通过空间预分配和惰性空间释放策略在字符串操作中一定程度上减少了内存重分配的次数。但这种策略同样会造成一定的内存浪费,因此Redis SDS API提供相应的API让我们在有需要的时候真正的释放SDS的未使用空间。
3. 二进制安全
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。如果有一种使用空字符来分割多个单词的特殊数据格式,就不能用C字符串来表示,如"Redis\0String",C字符串的函数会把’\0’当做结束符来处理,而忽略到后面的"String"。而SDS的buf字节数组不是在保存字符,而是一系列二进制数组,SDS API都会以二进制的方式来处理buf数组里的数据,使用len属性的值而不是空字符来判断字符串是否结束。
4. 时间复杂度
我们来看几个Redis常见操作的时间复杂度。
- 获取SDS长度:由于SDS中提供了len属性,因此我们可以直接获取时间复杂度为O(1),C字符串为O(n)。
- 获取SDS未使用空间长度:时间复杂度为0(1),原因同1
- 清除SDS保存的内容:由于惰性空间分配策略,复杂度为O(1)
- 创建一个长度为N的字符串:时间复杂度为O(n)
- 拼接一个长度为N的C字符串:时间复杂度为O(n)
- 拼接一个长度为N的SDS字符串:时间复杂度为O(n)
Redis在获取字符串长度上的时间复杂度为常数级O(1)
5. 小结
通过以上分析,我们可以得到,SDS这种数据结构相对于C字符串有以下优点:
- 杜绝缓冲区溢出
- 减少字符串操作中的内存重分配次数
- 二进制安全
- 由于SDS遵循以空字符结尾的惯例,因此兼容部门C字符串函数
Redis定位于一个高性能的内存数据库,其面向的就是大数据量,大并发,频繁读写,高响应速度的业务。因此在保证安全稳定的情况下,性能的提升非常重要。而SDS这种数据结构屏蔽了C字符串的一些缺点,可以提供安全高性能的字符串操作。
四、使用场景
String 类型的具体应用场景
- 缓存功能:作为缓存层,提高读写速度,减轻后端数据库压力。
- 计数功能:实现快速计数,如视频播放次数统计。
- 共享会话:集中管理用户会话,支持分布式系统。
- 手机验证码:存储验证码,设置过期时间,确保安全性。
1. 缓存(Cache)功能
下面是一个比较典型的缓存使用场景,其中 Redis 作为缓冲层,MySQL 作为存储层,大多数请求的数据从 Redis 中获取。由于 Redis 支持 高并发 的特性,所以缓存通常能 加速读写 和 降低后端压力 的作用
Redis+MySQL 组成的缓存存储架构
模拟业务数据访问过程
// 1. 根据用户 Uid 获取用户信息
UserInfo GetUserInfo(long uid) {
// 2. 从 Redis 获取用户信息, 假设 信息保存在 user:info 对应键中
// 根据 uid 得到 Redis 的键
String key = "user:info:" + uid;
// 尝试从 Redis 中获取对应的值
String value = Redis 执行命令:get key;
// 3. 如果没有从Redis中得到⽤⼾信息,及缓存miss,则进⼀步从MySQL中获取对应的信息,随后写⼊缓存并返回
// 如果缓存命中 (hit)
if (value != null) {
// 假设用户信息按照 JSON 格式存储
UserInfo userInfo = JSON 反序列化 (value);
return userInfo;
}
// 如果缓存未命中 (miss)
if (value == null) {
// 从数据库中,根据 uid 获取用户信息
UserInfo userInfo = MySQL 执行 SQL:select * from user_info where uid = <uid>;
// 如果表中没有 uid 对应的用户信息
if (userInfo == null) {
响应 404;
return null;
}
// 将用户信息序列化成 JSON 格式
String value = JSON 序列化 (userInfo);
// 写入缓存,为了防止数据腐烂 (rot),设置过期时间为 1 小时 (3600 秒)
Redis 执行命令:set key value ex 3600;
// 返回用户信息
return userInfo;
}
}
通过增加缓存功能,在理想情况下,每个用户信息,⼀个小时期间只会有⼀次MySQL查询,极大地提升了查询效率,也降低了MySQL的访问数
注意:Redis 没有表、字段等命名空间,键名没有强制要求(除了一些特殊字符)
设计合理的键名,有利于防止键冲突和项目的可维护性。推荐使用 “业务名:对象名:唯一标识:属性” 作为键名。
- 例如:MySQL 的数据库名为 vs,用户表名为 user_info,键名可以是 “vs:user_info:2333” 或 “vs:user_info:2333:name”。
- 如果当前 Redis 只会被一个业务使用,可以省略业务名,如 “user:2333:friends:messages:6666” 可以被 “u:2333: frⓂ️666” 代替。
- 简写的原因:键名过长会影响 Redis 性能,网络传输需要成本
思考:Redis 缓存策略
- 热点数据:经常用来存储频繁被访问的数据。
- 缓存定义:结合业务场景有很多种方式。
- 把最近使用到的数据作为热点数据。(隐含了一层假设:某个数据一旦被用到了,那么很可能在这段时间就会反复用到)
存在一个明显的问题:随着时间的推移,肯定会有越来越多的 key 在 redis 上访问不到,从而从 mysql 读取并写入 redis 了。此时 redis 中的数据是不是就越来越多嘛??
- 过期时间: 在把数据写给 redis 的同时,给这个 key 设置一个过期时间。详见[Redis#3] 通用命令 | 数据类型 | 内部编码 | 单线程 | 快的原因 定时器部分的介绍
- 淘汰策略: Redis 也在内存不足的时候,提供了淘汰策略。(后面再说)
2. 计数器\限速器\分布式系统ID
计数器\限速器\分布式ID等主要是利用Redis字符串自增自减的特性。
- 计数器:经常可以被用来做计数器,如微博的评论数、点赞数、分享数,抖音作品的收藏数,京东商品的销售量、评价数等。
- 限速器:如验证码接口访问频率限制,用户登陆时需要让用户输入手机验证码,从而确定是否是用户本人,但是为了短信接口不被频繁访问,会限制用户每分钟获取验证码的频率,例如一分钟不能超过5次。
- 分布式ID:由于Redis自增自减的操作是原子性的因此也经常在分布式系统中用来生成唯一的订单号、序列号等
如下:视频网站的视频播放次数可以使用 Redis 来完成:用户每播放⼀次视频,相应的视频播放数就会自增 1
示例:统计视频播放次数
long IncrVideoCounter(long vid) {
String key = "video:" + vid;
long count = Redis 执行命令:incr key;
return count;
}
- 注意:实际开发一个成熟、稳定的计数系统面临更多挑战,如 防作弊、按不同维度计数、避免单点问题、数据持久化到底层数据源等
- 根据实际的 业务需求 设计场景
3. 共享会话(Session)
会话的概念:客户端和服务端在交互过程中产生的专属于该客户端的中间状态数据。
- 目的:确保服务器能够识别和记住客户端的多次访问状态。
Cookie 和 Session
- Cookie: 浏览器存储数据的机制
- Session: 服务器存储数据的机制
Session ID 一般保存在 Cookie 中,客户端每次请求都会携带这个 Session ID(通过 Cookie),服务器根据 Session ID 查找对应的服务端 Session 数据
⌚️ 通常在单体系统中,Web服务将会用户的Session信息(例如用户登录信息)保存在自己的服务器中。但是在分布式系统中,这样做会有问题。因为分布式系统通常有很多个服务,每个服务又会同时部署在多台机器上,通过负载均衡机制将将用户的访问均衡到不同服务器上。这个时候用户的请求可能分发到不同的服务器上,从而导致用户登录保存Session是在一台服务器上,而读取Session是在另一台服务器上因此会读不到Session。
实际案例理解:医院就诊
- 初次就诊:我(客户端)生病了,声带发炎,发烧到完全说不出话。到医院挂号,挂了个专家号。医生(服务器)开了雾化理疗,先开了一周的药量,并建议一周后再来复查。
- 复查:一周后去复查,发现初诊的医生不在。新的医生之前没有给我看过病,不了解我的情况。新医生通过刷我的就诊卡(会话标识),看到了我之前的病例和治疗情况。
问题:同一个客户端多次访问可能遇到不同的服务器。解决方案如下:
- 共享会话数据 :借助一个集中的会话存储系统(如 Redis),所有服务器都可以读取和更新同一个用户的会话信息。客户端只需携带会话标识(如 Session ID),无论请求被分发到哪台服务器,都能正确识别用户身份和状态。
- 使用 Cookie 存储 Session ID :浏览器自动携带包含 Session ID 的 Cookie 到服务端,服务端通过该 ID 查询 Redis 获取完整会话信息。
Session 分散存储 图如下:
会话管理的重要性
- 一致性:确保客户端在多次访问中的一致性体验。
- 数据共享:多个服务器之间共享会话数据,避免因服务器切换导致的信息丢失。
解决方案:这种问题通常的做法是把Session存到一个公共的地方,让每个Web服务,都去这个公共的地方存取Session。而Redis就可以是这个公共的地方。(数据库、memecache等都可以各有优缺点)。
Redis 集中管理 Session
4. 示例:手机验证码
- 用户在登录的时候,为了保证用户账号的安全,我们会使用验证码.
- 当用户登录的时候,redis就会在服务器中保存一个与用户对应的验证码,这个验证码具有过期时间(比如在5分钟内有效).
- 在用户输入验证码之后,会从redis中查询对应的键值对,校验用户的验证码.
- 当然为了用户反复接收验证码,导致redis压力过大,一般规定在一分钟之内,最多接收一次验证码,如果手机没有验证码,可以尝试在一分钟之后重新获取验证码.
此功能可以用以下伪代码说明基本实现思路:
String SendCapcha(String phoneNumber) {
String key = "shortMsg:limit:" + phoneNumber;
// 设置过期时间为 1 分钟
// 使用 NX,只在不存在 key 时才能设置成功
bool r = Redis 执行命令:set key ex 60 nx;
if (r == false) {
// 说明之前设置过该手机的验证码了
long c = Redis 执行命令:incr key;
if (c > 5) {
// 说明超过一分钟 5 次的限制了
// 限制发送
return null;
}
}
// 说明要么之前没有设置过手机的验证码;要么次数没有超过 5 次
String validationCode = 生成随机的 6 位数的验证码();
String validationKey = "validation:" + phoneNumber;
// 验证码 5 分钟内有效
Redis 执行命令:set validationKey validationCode ex 300;
// 返回验证码,随后通过手机短信发送给用户
return validationCode;
}
// 验证用户输入的验证码是否正确
bool VerifyCode(String phoneNumber, String validationCode) {
String validationKey = "validation:" + phoneNumber;
String value = Redis 执行命令:get validationKey;
if (value == null) {
// 说明没有这个手机的验证码记录,验证失败
return false;
}
if (value.equals(validationCode)) {
return true;
} else {
return false;
}
}
🏑 小结:Redis 的 String 是一种二进制安全的字符串类型,支持任意格式的数据存储(文本、数字、二进制),不处理字符集编码,适用于缓存、计数器、Session 存储等多种场景,但在使用时应注意控制数据大小和字符集一致性,以保证性能和正确性。