Redis基于项目方案的结构功能解析
Redis结构功能解析
Remote Dictionary Server , 一款开源, 使用key-value存储的内存型数据结构服务器
简介
NoSQL
| SQL数据库 | NoSQL数据库 | |
|---|---|---|
| 数据结构 | 结构化(Structured) | 非结构化 |
| 数据关联 | 关联(Relational) | 无关联 |
| 查询方式 | SQL查询 | 非SQL |
| 事务特性 | ACID | BASE |
| 存储方式 | 磁盘 | 内存 |
| 扩展性 | 垂直 | 水平 |
| 使用场景 | 1) 数据结构固定 2) 业务对数据安全性、 一致性要求较高 |
1) 数据结构不固定 2) 对一致性、安全性要求不高 3) 对性能需求优先级高 |
特征
- 键值(key-value)型, value支持多种不同数据结构, 功能丰富
- 工作线程为单线程, 每个命令具备原子性
- 低延迟、速度快(基于内存、I/O多路复用、良好编码)
- 支持数据持久化
- 支持主从集群、分片集群
- 支持多语言客户端
安装
apt安装
1 | sudo apt install redis # 安装 |
编译安装
1 | 安装编译器 |
配置
1 | 备份原有配置文件 |
1 | # bind 127.0.0.1 -::1 # 修改为允许任意IP访问 |
运行
1 | cd /usr/local/src/redis-6.2.6 |
关闭
1 | redis-cli -a xxxxxxxxx -p 16379 shutdown |
配置systemctl
1 | sudo vi /etc/systemd/system/redis.service |
1 | [Unit] |
1 | sudo systemctl daemon-reload # 载入 |
通过systemctl配置开机自启动
1 | sudo systemctl enable redis |
客户端
命令行客户端
1 | redis-cli [options] [command] |
1 | options: |

可以在连接服务时不指定密码, 进入控制台后使用auth命令补充密码
图形化桌面客户端
AnotherRedisDesktopManager
1 | https://github.com/qishibo/AnotherRedisDesktopManager |
数据结构
key-value数据库, key一般为String, value支持多种数据类型:
| 数据类型 | 示例 |
|---|---|
| 字符串 - String | ‘hello redis’ |
| 哈希 - Hash | {name: ‘jack’, age: 21} |
| 列表 - List | [A -> B -> C -> C] |
| 集合 - Set | {A, B, C} |
| 有序集合 - SortedSet | {A: 1, B: 2, C: 3} |
| GEO | {A: (120.3, 30.3)} |
| BitMap | 0110110101110101011 |
| HyperLogLog | 0110110101110101011 |
1 | 数据结构对应命令可通过官方文档或客户端help @[数据类型]查看 |
通用命令
1 | 文档: [Redis Generic Commands](https://redis.io/commands/?group=generic) |
1 | keys 查看符合模板pattern的所有key, 可能耗费较多系统资源 |
String类型命令
字符串类型包括: string普通字符串, int整数类型, 可以自增减, float浮点类型, 可以自增减
最大空间不超过512m
1 | set 添加或修改一个字符串类型的键值对 |
存储多项目, 多类型, 多用户时, 可以使用符号 : 格式设置键名
1 | mset |

Hash类型命令
哈希类型, 也叫散列, 其value为一个无序字典, 可以将对象中每个字段独立存储, 单独做CURD

1 | hset 添加或修改hash类型key的值 |
List类型命令
可以看做双向链表, 即支持正向检索, 也支持反向检索
特征: 有序, 元素可重复, 插入/删除比较快, 查询速度一般
一般用于存储有序数据, 包括点赞列表, 评论列表等
1 | lpush 向列表左侧插入一个或多个元素 |
模拟栈(先进后出, 即入口出口在同一边)
rpush + rpop / lpush + lpop
模拟队列(先进先出, 即入口出口在不同边)
rpush + lpop / lpush + rpop
模拟阻塞队列(先进先出, 无则不出, 即入口出口在不同边, 出队时采用blpop或brpop)
rpush + blpop / lpush + brpop
Set类型命令
特征: 无序, 元素不可重复, 查找快, 支持交并差集操作
一般用于好友列表/ 共同关注/ 好友关系等
1 | 数据结构对应命令可通过官方文档或客户端help @[数据类型]查看 |
1 | sadd key member 向集合中添加一个或多个元素 |

SortedSet类型命令
可排序集合, 集合中的每一个元素都带有一个score属性, 基于score实现元素排序
特性: 可排序 , 元素不可重复, 查询速度快
基于可排序性 , 一般用于排行榜功能
1 | 数据结构对应命令可通过官方文档或客户端help @[数据类型]查看 |
1 | zadd key score member 添加一个或多个元素到有序集合, 如果已存在则更新其score值 |
score默认为正序, 正序排序为从小到大, 逆序为从大到小, 一般是在命令中加rev
使用Redis处理方案
短信登陆: Redis的共享session应用
商户查询缓存: 企业缓存使用技巧、缓存雪崩 穿透等问题
达人探店: 基于List的点赞列表, 基于SortedSet的点赞排行榜
优惠券秒杀: Redis计数器、Lua脚本Redis、分布式锁、三种消息队列
好友关注: 基于Set集合的关注、取关、共同关注、消息推送等功能
附近的商户: Redis的GeoHash应用
用户签到: BitMap数据统计功能
UV统计: HyperLogLog统计功能
短信登陆
基于session实现登陆流程

**集群的session共享问题:**多台服务器不共享session存储空间, 当请求被切换到不同服务器时导致数据丢失
**替代方案特征:**数据共享, 内存存储, key-value结构
基于Redis实现共享session登陆流程

1 | 需要考虑问题: |
商户缓存查询
缓存
数据交换的缓冲区(cache), 是存贮数据的临时地址, 一般读写性能较高, 可以使用Redis充当应用层缓存
1 | 优势: |
添加Redis缓存

缓存更新策略

1 | 业务场景: |
一般由缓存调用者在更新数据库时更新缓存, 需要考虑如下问题:
- 删除缓存/更新缓存
- 更新缓存可能造成多次无效写操作, 推荐使用删除缓存, 等查询时再更新缓存
- 保证缓存与数据库操作同时成功/失败
- 单体系统可以使用事务保证原子性, 分布式考虑利用TCC等分布式事务方案
- 先操作缓存/先操作数据库
- 推荐先操作数据库, 再删除缓存, 降低线程安全问题发生的概率
缓存穿透
客户端请求的数据在缓存中和数据库中都不存在, 导致每次请求都直接访问数据库, 缓存永远不生效
解决方案:
缓存空对象
将不存在的数据结果也缓存在Redis中, 赋值为null
1 | 优点: |

布隆过滤
在请求缓存时, 先通过布隆过滤器, 若数据库不存在该数据, 则拒绝请求. 若存在, 才会通过
布隆过滤器实际上是一个很长的二进制向量和一系列随机映射hash函数。将数据通过hash运算后映射到二进制向量位上, 当请求参数时, 同样运算后判断结果位是否都为1, 便可以判断是否存在该数据.
Redis中支持
setbit和getbit操作, 可以利用其实现布隆过滤操作
1 | 优点: |

其他预防方式
- 增强ID复杂度, 避免被猜测ID规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数限流, 峰值限流
缓存雪崩
同一时段内大量缓存key失效或Redis服务宕机, 导致大量请求到达数据库, 带来数据库压力
解决方案
1 | - 给不同key的TTL添加随机值 |
缓存击穿
也叫热点key问题, 就是一个被高并发访问并且缓存重建业务较复杂的key突然失效, 在这期间大量请求直接访问数据库, 带来大量服务压力
解决方案
互斥锁
当发现无缓存数据时, 由当前进程setnx获取互斥锁, 然后进行缓存重建, 其余进程无法获取锁, 等待或返回空数据
1 | 优点: |

使用互斥锁方式解决缓存击穿问题逻辑:

逻辑过期
缓存数据永不过期, 额外添加过期字段, 当用户请求发现该字段已过期时, 加锁并重建缓存, 其余进程无法获取锁, 返回该已过期数据
1 | 优点: |

使用逻辑过期方式解决缓存击穿问题逻辑:

缓存封装工具
方法1: 将任意对象序列化为json并存储在String类型的key中, 并可以设置TTL过期时间
1 |
方法2: 将任意对象序列化为json并存储在String类型的key中, 并可以设置逻辑过期时间, 用于处理缓存击穿
1 |
方法3: 根据指定key查询缓存, 并解析为指定类型, 利用缓存空值null的方式解决缓存穿透问题
1 |
方法4: 根据指定key查询缓存, 并解析为指定类型, 利用逻辑过期解决缓存击穿问题
1 |
优惠券秒杀
全局唯一ID
Redis自增
优惠券-用户订单表中, 不推荐使用自增ID, 因为规律性太明显, 且受表单数据量的限制(分表/分库)
1 | 全局ID生成器: |

1 | 组成部分: |
其他方案
1 | - UUID |
实现优惠券秒杀下单

超卖问题

超卖问题是典型的多线程安全问题, 常见解决方案就是加锁:
悲观锁:
认为线程安全问题一定会发生, 因此在操作数据前先获取锁, 确保线程串行执行. 如mysql的
for update和 Redis的setnx实现方式简单粗暴, 但性能一般
乐观锁:
认为线程安全问题不一定发生, 因此不加锁, 只是在更新数据时判断有没有其他线程对数据做了修改, 如果没有修改则认为是安全的, 才更新数据. 如果已经被其他线程修改, 则说明发生了线程安全问题, 尝试重试或异常
优点是性能好, 但存在成功率较低的问题
乐观锁实现方式
版本号法: 添加版本号字段, 修改前与获取版本号对照

CAS - Compare And Set: 对照原有数据, 没有修改才更新数据

为解决失败率过高问题, 可以修改
stock = 1条件为stock > 0
一人一单
修改秒杀业务, 要求同一个优惠券, 一个用户只能下一单

需要为每个用户的行为加悲观锁, 以用户ID作为锁key, 否则查询订单结果不存在会被多个进程执行
集群服务器中的并发安全问题
锁只能保证在本服务器中线程安全, 集群服务时会失效

分布式锁
满足分布式系统或集群模式下多进程可见并且互斥的锁
实现方式:
| MySQL | Redis | Zookeeper | |
|---|---|---|---|
| 互斥 | 利用mysql自身的互斥锁机制 | 利用setnx互斥命令 | 利用节点的唯一性和有序性实现互斥 |
| 高可用 | 主从保证 好 | 好 | 好 |
| 高性能 | 一般 | 好 | 一般 |
| 安全性 | 断开连接, 自动释放锁 | 服务宕机,未释放锁 可以利用锁超时时间, 到期释放 |
临时节点, 断开连接自动释放 |
基于Redis实现分布式锁
1 | 获取锁 |
非阻塞式获取锁

问题一: 误删他锁
该流程中可能发生误删锁的状况, 流程如下:

解决方案:
需要在加锁时添加自己进程/服务身份的标识(uuid+id), 删除锁前判断该锁是否属于本进程/服务, 再执行释放锁操作

修改流程为:

**问题二: 判断/删锁 原子性 **
在判断锁标识是自己的之后, 由于系统阻塞, 阻塞期间锁超时释放, 导致其他锁被误删

解决方案:
Lua脚本, 在一个脚本中编写多条Redis命令, 确保多条命令执行时的原子性
1 | 流程: |
1 | Redis-Lua脚本文档: https://redis.io/docs/manual/programmability/eval-intro/ |

1 | -- Lua脚本 unlock.lua |
其他问题
不可重入
同一线程无法多次获取同一把锁( 函数调用 )
1
2
3
4可通过使用Redis-hash类型数据, 同时加入锁次数字段.
当同一个线程/服务再次请求时, 为锁字段添加1, 同时添加有效期.
执行业务结束后, 释放锁, 即锁次数减1.
当所有业务结束, 锁次数为0时, 删除锁对应key
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16-- 获取锁 tryLock.lua
local key = KEYS[1] -- 锁的key
local threadId = ARGV[1] -- 线程/服务唯一标识
local releaseTime = ARGV[2] -- 锁过期时间
if redis.call('exists', key) == 0 then -- 判断是否存在锁
redis.call('hset', key, threadId, 1) -- 不存在, 获取锁
redis.call('expire', key, releaseTime) -- 设置有效期
return 1 -- 返回成功结果
end
if redis.call('hexists', key, threadId) == 1 then -- 锁已存在, 判断是否可以重入
redis.call('hincrby', key, threadId, 1) -- 获取锁, 重入次数加1
redis.call('expire', key, releaseTime) -- 重置锁有效期
return 1 -- 返回成功结果
end
return 0 -- 获取锁失败1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16-- 释放锁 unlock.lua
local key = KEYS[1] -- 锁的key
local threadId = ARGV[1] -- 线程/服务唯一标识
local releaseTime = ARGV[2] -- 锁过期时间
if redis.call('hexists', key, threadId) == 0 then -- 判断当前锁是否被自己持有
return nil -- 不是自己持有, 返回
end
local count = redis.call('hincrby', key, threadId, -1) -- 是自己持有, 次数加1
if count > 0 then -- 判断重入次数是否已经为0
redis.call('expire', key, releaseTime) -- 大于0说明不能释放锁, 重置过期时间
return nil
else
redis.call('del', key) -- 等于0说明可以释放锁, 删除
return nil
end不可重试
获取锁只尝试一次就返回false, 没有重试机制
1
利用信号量和Redis-PubSub功能实现等待、唤醒, 获取锁失败的重试机制

超时释放
锁超时释放虽然可以避免死锁, 但在业务耗时较长的场景, 存在安全隐患
1
利用watchDog, 每隔一段时间(releaseTime/3), 重置超时时间
主从一致性
如果在主从集群中, 同步存在延迟, 从服务器还未同步到主服务器的写入的锁数据, 其余进程读从服务器数据, 可能导致出现多个锁
1
multilock, 同时在多个同级节点设置锁, 仅在所有节点可以获取锁才算成功

Redis秒杀优化
可以将判断库存/校验一人一单操作需要信息放入Redis, 再通过Lua脚本执行, 只有校验通过的用户请求才会被后台继续执行, 实际操作数据库, 流程如下:

其中库存数据可以通过String格式存储, 一人一单验证则通过将用户ID放入指定集合, 判断集合是否存在用户判断

加快了处理和响应速度, 同时减轻了数据库的访问压力
1 | 实际操作: |
1 | -- 秒杀预处理脚本 seckill.lua |
但该方案仍旧存在内存限制(服务队列消耗本机内存)和数据安全(异常/宕机)问题
Redis消息队列实现异步秒杀
消息队列
1 | 消息队列(Message Queue), 即存放消息的队列, 包括三个角色: |
基于Redis实现消息队列
Redis中提供了三种不同的方式实现消息队列
- List结构: 基于列表结构模拟消息队列
- PubSub: 基本的点对点消息模型
- Stream: 比较完善的消息队列模型
基于List模拟消息队列


1 | 优点: |
基于PubSub模拟消息队列
1 | 文档: https://redis.io/commands/?group=pubsub |

发布订阅(PubSub)是Redis2.0引入的消息传递模型. 即消费者可以订阅一个或多个频道(channel), 生产者向对应channel发送消息后, 所有订阅者都能收到相关消息. 主要使用以下命令实现
1 | publish channel message 向一个频道发送消息 |

1 | 优点: |
基于Stream的消息队列
Stream
Stream是Redis5.0引入的一种数据类型, 可以实现一个功能完善的消息队列
1 | 类型文档: |
核心命令:
发送消息命令
xadd
添加一个新的entry到stream
创建名为
users的队列, 并向其中发送一个消息, 内容为{name=Tom, age=5},无最大消息数量限制, 且使用Redis自动生成ID
读取消息命令
xread从一个或者多个流中读取数据,仅返回ID大于调用者报告的最后接收ID的条目。此命令有一个阻塞选项

1
2
3
4
5命令特点:
- 消息可回溯
- 一个消息队列可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读风险使用xread读取第一条信息

阻塞方式读取当前信息

命令效果
使用 xread ... 0 从头获取消息时, 每个线程都可以获取到该队列第一条数据

使用 xread ... block ... $ 阻塞获取最新一条信息

实际使用
可以通过循环调用xread以阻塞方式查询最新消息, 从而实现持续监听队列效果
1 | while (true){ |
但当指定起始ID为
$时, 代表读取最新消息, 若在处理一条信息时, 有超过1条消息到达队列, 则下次获取时也只能获取到最新一条, 出现 消息漏读 问题
消费者组
Consumer Group: 将多个消费者划分到一个组中, 监听同一个队列.
特征
1 | - 消息分流 |
命令
1 | xgroup CREATE key groupname ID [MKSTREAM] 管理消费者组 |
命令效果
添加消费者组队列, 并使用消费者组消费Stream数据

查看pending-list数据, 并重新获取数据处理, 确认消息后再查看

获取数据时, 将ID由
>改为0, 即可从获取消息 转为 获取 pending-list 未确认消息
实际使用
1 | while (true){ |
特征
1 | - 消息可回溯 |
三种模式对比
| List | PubSub | Stream | |
|---|---|---|---|
| 消息持久化 | 支持 | 不支持 | 支持 |
| 阻塞读取 | 支持 | 支持 | 支持 |
| 消息堆积处理 | 受限于内存空间大小, 可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度, 可以利用消费者组提高消费速度, 减少堆积 |
| 消息确认机制 | 不支持 | 不支持 | 支持 |
| 消息回溯 | 不支持 | 不支持 | 支持 |
基于Stream实现异步秒杀下单
创建一个Stream类型的消息队列,名为stream.orders
1
xgroup CREATE stream.order group1 0 MKSTREAM
修改秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21-- 秒杀预处理脚本 seckill.lua
local voucherId = ARGV[1] -- 优惠券ID, 作为键名
local userId = ARGV[2] -- 用户ID
local orderId = ARGV[3] -- 订单ID
local stockKey = 'seckill:stock:' .. voucherId -- 库存数量Key
local orderKey = 'seckill:order:' .. voucherId -- 订单用户集合Key
if tonumber(redis.call('get', stockKey)) <= 0 then -- 若库存数量不足
return 1
end
if redis.call('sismember', orderKey, userId) == 1 then -- 若用户已在集合中
return 2 -- 说明已经下过单
end
redis.call('decr', stockKey) -- 扣除Redis缓存中的库存
redis.call('sadd', orderKey, userId) -- 保存用户到订单集合中
-- 发送消息到stream队列中
redis.call('xadd', 'stream.order', '*', 'userId', userId, 'voucherId', voucherId, 'orderId', orderId)
return 0服务代码
1
redis.eval('path_of_seckill_lua_script',[voucherId, userId, orderId])
项目启动时,开启任务,尝试获取stream.orders中的消息,完成下单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25while (true){
try{
// 获取消息队列中的订单信息
xreadgroup GROUP group1 consumer1 COUNT 1 BLOCK 2000 STREAMS stream.order >
// 判断消息获取是否成功
// 若获取失败, 说明没有消息, 继续下次循环
// 若获取成功, 可以下单
// ACK确认
xack stream.order group1 [ID]
}catch(Exception e){
// 订单处理异常
while(true){
try{
// 获取pending-list队列中的未确认订单信息
xreadgroup GROUP group1 consumer1 COUNT 1 STREAMS stream.order 0
// 判断消息获取是否成功
// 若获取失败, 说明pending-list没有消息, break
// 若获取成功, 可以下单
// ACK确认
}catch(Exception e){
// 处理pending-list异常信息 sleep + continue + count + break / log + break
}
}
}
}
达人探店
发布探店笔记
点赞
使用Redis中的Set数据结构, 实现点赞功能
1 | - 将当前blog对应的点赞人员ID放入以blogId为键的Set集合中. |

点赞排行榜
使用Redis中的SortedSet数据结构, 实现点赞排行功能
1 | 在blog详情页, 需要添加点赞列, 显示前几个点赞用户头像 |

好友关注
关注和取关
1 | - 在关注时, 使用sadd将目标用户ID放入以用户ID为key的set集合中 |

共同关注
使用Redis中的Set数据结构, 实现显示当前用户与指定用户的共同好友
1 | - 共同关注, 获取当前用户与目标用户的set集合,使用sinter获取交集,即为共同关注用户ID集合 |

关注推送
也叫Feed流. 为用户持续的提供, 通过无限下拉刷新获取新的信息, 有两种常见模式:
TimeLine:
不做内容筛选, 简单按照内容发布时间排序, 常用于好友或关注信息. 如朋友圈
智能排序:
利用算法屏蔽违规、不感兴趣内容. 推送感兴趣的信息吸引用户
TimeLine模式实现方式:
拉模式
也叫读扩散, 只有在关注后查看, 才会主动从目标用户的发件箱中获取信息

推模式
也叫写扩散, 发送消息时向所有关注者同时发送信息

推拉结合
也叫读写混合, 兼具推和拉模式的优点

| 拉模式 | 推模式 | 推拉结合 | |
|---|---|---|---|
| 写消耗 | 低 | 高 | 中 |
| 读消耗 | 高 | 低 | 中 |
| 用户读取延迟 | 高 | 低 | 低 |
| 实现难度 | 简单 | 简单 | 复杂 |
| 使用场景 | 很少使用 | 用户量少, 没有大V | 千万级别用户量,有大V |
Feed流中的滚动分页
由于Feed流中的数据会不断更新, 数据角标索引也在实时变化, 所以不能采用传统分页模式, 而是使用SortedSet数据结构, 使用时间戳为score, 查询分页时记录lastId, 下一页数据从lastId开始查询

1 | 命令参数: |


使用推模式完成推送
1 | - 被关注用户发送blog时, 查询所有关注者, 同时使用Redis中的SortedSet数据结构记录blog_ID到关注者ID的key, 作为关注者收件箱内容, score为发布时间戳 |
简便方式: 在添加score时, 在时间戳后添加随机数或有序自增数
附近商户
GEO数据结构
地理坐标Geolocation, Redis3.2版本后提供, 允许存储地理经纬度信息, 常见命令有:
1 | geoadd 添加一个地理空间信息到SortedSet,经纬度作为score,名称作为member |

附近商户
1 | - 将店铺所在经纬度x,y存入Redis的GEO数据类型中, 可以考虑以搜索类型分类分Key存储 |
用户签到
BitMap数据结构
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位
1 | SETBIT 向指定位置(offset)存入一个0或1 |

签到功能
如果使用数据库实现功能, 可能造成数据大表出现
可以按月统计用户签到数据, 签到记录为1, 未签到记录为0

把每一个bit位对应当前的每一天, 形成映射关系. 用0和1标识业务状态, 即位图(BitMap)
1 | - 获取当前用户 |
签到统计
本月已连续签到天数
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

获取一个月全部签到数据
1
bitfield key get u[天数] 0
从后向前遍历每个bit位
1
整体向右移动一位, 依次与1做与运算获取+最后一个bit位
实现
1 | - 获取本月截止当前天的所有签到记录 |
UV统计
UV(Unique Visitor), 也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV(Page View), 也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
HyperLogLog数据结构
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理可以参考:HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb。作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。


实现UV统计
1 | - 用户访问时, 将用户信息存入Redis中的HyperLogLog数据结构中 |
PS:
1 | 课程文档: https://pan.baidu.com/s/1189u6u4icQYHg_9_7ovWmA?pwd=eh11 |