Redis 笔记(黑马点评 —— 基础篇 + 实战篇)

Redis

  • 1. Redis 入门
  • 2. 五大基本数据类型
    • 2.1 String
    • 2.2 Hash
    • 2.3 List
    • 2.4 Set
    • 2.5 ZSet
    • 2.6 应用场景
  • 3. RedisTemplate
    • 3.1 RedisTemplate 的使用
    • 3.1 RedisTemplate 存在的问题
    • 3.3 StringRedisTemplate
  • 4. 短信登录
    • 项目准备
    • 4.1 基于 Session 实现登录
      • 4.1.1 发送验证码
      • 4.1.2 登录和注册
      • 4.1.3 登录验证(拦截器)
    • 4.2 登录的实现方案
      • 4.2.1 Session
      • 4.2.2 Redis + Token
      • 4.2.3 Redis + JWT
    • 4.3 基于 Redis + Token 实现登录
      • 4.3.1 发送验证码 / 登录和注册
      • 4.3.2 前端对 Token 的处理
      • 4.3.2 登录验证(拦截器)
  • 5. 缓存
    • 5.1 添加缓存
      • 5.1.1 /shop/{id}
      • 5.1.2 /shop-type/list
    • 5.2 数据过期策略
    • 5.3 数据淘汰策略
    • 5.4 数据更新策略
    • 5.5 缓存穿透
      • 5.5.1 缓存空对象
      • 5.5.2 布隆过滤器
    • 5.6 缓存雪崩
    • 5.7 缓存击穿
      • 5.7.1 synchronized
      • 5.7.2 setnx
      • 5.7.3 逻辑过期
    • 5.8 封装 Redis Cache 工具类
  • 6. 秒杀相关
    • 6.1 全局唯一 ID
    • 6.2 优惠券秒杀
      • 6.2.1 添加优惠券
      • 6.2.2 下单优惠券
    • 6.3 超卖问题
      • 6.3.1 乐观锁(版本号 & CAS)
      • 6.3.2 CAS 解决超卖问题
    • 6.4 一人一单
      • 6.4.1 增加一人一单逻辑
      • 6.4.2 解决并发安全问题
      • 6.4.3 集群环境下的并发问题
  • 7. 分布式锁
    • 7.1 Redis 实现分布式锁
    • 7.2 分布式锁的误删问题
    • 7.3 Lua 脚本解决原子性问题
    • 7.4 总结
  • 8. Redisson 分布式锁
    • 8.1 Redisson 分布式锁的使用
    • 8.2 可重入原理
    • 8.3 可重试原理
    • 8.4 超时续约原理(WatchDog)
    • 8.5 主从一致原理(MultiLock)
    • 8.6 总结
  • 9. 优化秒杀
  • 10. Redis 消息队列
    • 10.1 基于 List 结构
    • 10.2 基于 PubSub
    • 10.3 基于 Stream
      • 10.3.1 Stream 的消费者组模式
      • 10.3.2 基于 Stream 消息队列实现异步秒杀
  • 11. 点赞相关
    • 11.1 发布并查看笔记
    • 11.2 点赞
    • 11.3 点赞排行榜
  • 12. 关注相关
    • 12.1 关注和取关
    • 12.2 共同关注
    • 12.3 关注推送 - Feed 流
      • 12.3.1 Feed 流的实现方案
      • 12.3.2 Feed 流的滚动分页
      • 12.3.3 代码实现
  • 13. GEO 附近搜索
    • 13.1 GEO 数据结构
    • 13.2 附近商家搜索
  • 14. BitMap 签到
    • 14.1 BitMap 数据结构
    • 14.2 签到
    • 14.3 统计连续签到天数
  • 15. HyperLogLog UV 统计
  • 16. Ops
    • 16.1 Redis 持久化
      • 16.1.1 RDB
      • 16.1.2 AOF
      • 16.1.3 RDB 和 AOF
    • 16.2 Redis 主从集群
    • 16.3 哨兵机制
    • 16.4 Redis 分布式存储方案
      • 哈希取余分区
      • 一致性哈希算法分区
      • 哈希槽分区

项目源码地址:https://gitee.com/sjd75/comment

1. Redis 入门

(NoSQL, Not Only SQL) 非关系型数据库

关系型数据库:以 表格 的形式存在,以 行和列 的形式存取数据,一系列的行和列被称为表,无数张表组成了 数据库。支持复杂的 SQL 查询,能够体现出数据之间、表之间的关联关系;也支持事务,便于提交或者回滚。

非关系型数据库:以 key-value 的形式存在,可以想象成电话本的形式,人名(key)对应电话号码(value)。不需要写一些复杂的 SQL 语句,不需要经过 SQL 的重重解析,性能很高;可扩展性也比较强,数据之间没有耦合性,需要新加字段就直接增加一个 key-value 键值对即可。

Redis 是 速度极快的、基于内存的,键值型 NoSQL 数据库。

  • 为什么这么快?

    • 完全基于内存操作
    • 使用非阻塞的 IO 多路复用机制
    • 数据结构简单,对数据操作也简单。
    • 使用单线程,避免了上下文切换和竞争产生的消耗。
  • 支持多种数据类型,包括 String、Hash、List、Set、ZSet 等。

  • 支持数据的持久化,支持 RDB 和 AOF 两种持久化机制。

  • 支持主从集群、分片集群。

  • Redis 的数据类型

    • Key 的类型只能为 字符串
    • Value 支持五种数据类型:字符串(stirng)、列表(list)、集合(set)、散列表(hash)、有序集合(zset)。
  • Redis 有16个数据库,默认使用的是第 0 个。

    # 切换数据库
    select [0-15]
    
    # 查看数据库大小
    dbsize
    
    # 清除当前数据库内容
    flushdb
    
    # 清除所有数据库内容
    flushall
    

Redis 相关配置及通用命令

# 监听的地址,默认是 127.0.0.1 只能本地访问;修改为 0.0.0.0,可以在任意 IP 访问。
bind 0.0.0.0
# 守护进程
daemonize yes
# 密码
requirepass root

# 启动 Redis 服务
redis-server devTools/redis-6.2.7/redis.conf
# 启动 Redis 客户端
redis-cli -p 6379
# 若有密码,启动 Redis 客户端后需要输入密码
auth 密码
# 关闭 Redis 服务(quit 退出后 shutdown 关闭)
quit
redis-cli shutdown
# 查看所有符合的 key
KEYS patthern 

# 删除一个 或 多个 指定的key
DEL key [key ...]

# 判断某个 key 是否存在
EXISTS key

# 给一个 key 设置有效时间,超时后该 key 会被自动删除
EXPIRE key seconds

# 查看一个 key 的剩余有效时间
TTL key

# 查看某个 key 所存储的 value 的类型
TYPE key

# 为某个 key 重命名
RENAME key newkey

Key 的结构

假设 Blog 中需要存储:用户信息、文章信息,且 用户ID 和 文章ID 都为 1。

让 Redis 的 key 形成层级结构,使用 : 隔开:项目名:业务名:类型:id

set blog:user:1 Jack
set blog:article:1 Spring

若 value 是一个 Java 对象,可以将对象序列化为 JSON 字符串后存储(注意加单引号)。

set blog:user:1 '{"id":1, "name":"Jack", "age":22}'
set blog:user:2 '{"id":2, "name":"Mike", "age":23}'
set blog:article:1 '{"id":1, "title":"Spring"}'

2. 五大基本数据类型

2.1 String

String 的三种类型:字符串、整型、浮点型。

Java 的 String 是不可变的,无法修改。Redis 的 String 是动态的,可以修改的Redis 的 String 在内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。如图所示,当前字符串实际分配的空间为 capacity,一般高于实际的字符串长度 len。当字符串长度小于 1M 时,扩容是对现有空间的成倍增长;如果长度超过 1M 时,扩容一次只会多增加 1M 的空间。String 的最大长度为 512M

Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第1张图片

相关操作

SET key value :添加 或 修改一个键值对。

GET key :获取某个 key 的 value。

MSET key value [key value ...] :批量 SET。

MGET key [key ...] :批量 GET。

STRLEN key :获取某个 key 存储的长度。

# GET / SET
> set k1 v1
> get k1
"v1"
> set k1 value1
> get k1
"value1"

# MGET / MSET
> mset k2 v2 k3 v3
> mget k2 k3
1) "v2"
2) "v3"
> mget k1 k2 k3
1) "value1"
2) "v2"
3) "v3"

# STRLEN
> strlen k1
(integer) 6
> strlen k2
(integer) 2

INCR key :一个整型的 value 自增 1。

INCRBY key increment :一个整型的 value 增加 increment。

INCRBYFLOAT key increment :让一个浮点型的 key 自增。

# 整型自增 1
> set k1 1
> incr k1
(integer) 2

# 整型增加 increment
> incrby k1 3
(integer) 5

# 浮点型增加 increment
> set k2 1.1
> INCRBYFLOAT k2 2.2
"3.3"

SETNX key value :添加一个 String 类型的键值对,前提是这个 key 不存在。

SETEX key seconds value :添加一个 String 类型的键值对,并且指定有效时间。

# SETNX
> setnx k1 v1
(integer) 1
> setnx k1 v2
(integer) 0
> get k1
"v1"

# SETEX
> setex k2 10 v2
> ttl k2
(integer) 5
> ttl k2
(integer) -2
> get k2
(nil)

2.2 Hash

Hash 的 value 可以看作一个 Map 集合,Key-Value 形式,只不过 Value 是一个 Map,也就是 Key-Map(Key-Map)。

  • String 类型存储对象需要将其序列化为 JSON 字符串后存储,如果需要修改对象的某个字段,比较不方便,只能修改整个 JSON 字符串。
  • Hash 适合存储结构体信息(对象),Hash 可以对用户结构中的每个字段单独存储,可以针对结构中的单个字段进行 CRUD。
KEY VLAUE
blog:user:1 {"id": 1, "name": "Jack", "age": 22}
blog:user:2 {"id": 2, "name": "Mike", "age": 23}
KEY VALUE
field value
blog:user:1 name Jack
age 22
blog:user:2 name Mike
age 23

相关操作

HSET key field value [field value ...] :添加 Hash 类型的键值对,或修改 HashKey 的 field 的 value。(HSET 和 HMSET 都可以批量操作)

HGET key field:获取 HashKey 的 field 的 value。(HMGET 批量获取)

HSETNX key field value :添加一个 Hash 类型的键值对,前提是这个 field 不存在。(不能批量添加,一次只能指定一个 HashKey-field-value

# HSET / HMSET
> hset blog:user:1 id 1
> hset blog:user:1 name Jack
> hset blog:user:1 age 22
> hmset blog:user:2 id 2 name Mike age 23

# HGET / HMGET
> HGET blog:user:1 name
"Jack"
> hmget blog:user:1 id name age
1) "1"
2) "Jack"
3) "22"
> hmget blog:user:2 id name age
1) "2"
2) "Mike"
3) "23

# 修改 field
> hset blog:user:1 name Jack123 age 18
> hmget blog:user:1 name age
1) "Jack123"
2) "18"

# HSETNX 不能批量添加,一次只能指定一个 HashKey-field-value
> hsetnx blog:user:3 id 3
(integer) 1
127.0.0.1:6379> hsetnx blog:user:3 id 3
(integer) 0

HGETALL key :获取指定 HashKey 中的所有 field 和 value。

HKEYS key :获取指定 HashKey 中的所有 field。

HVALS key :获取指定 HashKey 中的所有 field 的 value。

> hgetall blog:user:2
1) "id"
2) "2"
3) "name"
4) "Mike"
5) "age"
6) "23"

> hkeys blog:user:2
1) "id"
2) "name"
3) "age"

> hvals blog:user:2
1) "2"
2) "Mike"
3) "23"

HINCRBY key field increment :一个整型的 HashKey 的 field 的 value 增加 increment。

> HINCRBY blog:user:2 age 3
(integer) 26
> HINCRBY blog:user:2 age 4
(integer) 30

HLEN key :查看 HashKey 的字段数量。

HDEL key field [field ...] :批量删除 HashKey 的 field 和 field 对应的 value。

HEXISTS key field :查看 HashKey 的指定字段 field 是否存在。

> hlen blog:user:2
(integer) 3

> hexists blog:user:2 age
(integer) 1
> hdel blog:user:2 age
(integer) 1
> hexists blog:user:2 age
(integer) 0

> hlen blog:user:2
(integer) 2

2.3 List

List 类似 Java 中的 LinkedList,可以看作一个双向链表(有序可重复)。使用 List 可以对链表的两端进行 push 和 pop 操作、读取单个或多个元素、根据值查找或删除元素、支持正向检索和反向检索。

相关操作

LPUSH key element [element ...] :对链表的头插入一个或多个元素。

LPOP key [count] :移除并返回链表的头部元素。

RPUSH key element [element ...] :对链表的尾插入一个或多个元素。

RPOP key :移除并返回链表的尾部元素。

# linked 中的元素:5 4 3 2 1
> lpush linked 1 2 3 4 5

# lpop 是从头部开始移除元素
> lpop linked
"5"
> lpop linked
"4"
> lpop linked 3
1) "3"
2) "2"
3) "1"

# linked 中的元素:1 2 3 4 5
> rpush linked 1 2 3 4 5

# rpop 是从尾部开始移除元素
> rpop linked
"5"
> rpop linked
"4"
> rpop linked 3
1) "3"
2) "2"
3) "1"

LRANGE key start end :返回指定下标范围内的所有元素。

LTRIM key start end :只保留指定范围内的元素,其他的删除。

LINDEX key index :返回指定下标的值。

LLEN key :返回列表的元素个数。

# 返回第 1、2 个元素
> lrange linked 0 1
1) "5"
2) "4"

# 返回所有元素
> lrange linked 0 -1
1) "5"
2) "4"
3) "3"
4) "2"
5) "1"

# 保留 0、1、2 下标对应的元素
> ltrim linked 0 2
OK
> lrange linked 0 -1
1) "5"
2) "4"
3) "3"

BLPOP / BRPOP kye [key ...] timeout :与 LPOP 和 RPOP 类似,但是在没有指定元素时可以等待指定时间,而不是直接返回 nil。

# 此时数据库中没有 key 为 test 的数据,10 秒内不会返回 nil
> blpop test 10

# 在另外一个终端添加一个 test
> lpush test 1

# 10 秒之内若新增了 test,会将其 POP,并返回等待时间。
> blpop test 10
1) "test"
2) "1"
(7.55s)

:LPUSH + LPOP 或 RPUSH + RPOP。

队列:LPUSH + RPOP 或 RPUSH + LPOP。

2.4 Set

Redis 的 Set 类似 HashSet,可以看作一个 value 为 null 的 HashMap;其特征也与 HashSet 类似:无序不可重复,支持 交集、并集、差集等功能。

相关操作

SADD key member [member ...] :向 Set 中添加一个或多个元素。

SMEMBERS key :获取指定 Set 中的所有元素。

SISMEMBER key member :判断 Set 中是否存在指定元素。

SCARD key :返回 Set 中的元素个数。

SREM key member [member ...] :移除 Set 中的指定元素。

> sadd set 1 2 3 4 5
> smembers set
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"

> sismember set 1
(integer) 1
> sismember set 10
(integer) 0

> scard set
(integer) 5
> srem set 4 5
(integer) 2
> scard set
(integer) 3

SINTER key [key ...] :求 n 个 key 间的交集。

SDIFF key [key ...] :求 n 个 key 间的差集。

SUNION key [key ...] :求 n 个 key 间的并集。

> sadd set1 1 2 3 5 7 9
> sadd set2 1 2 4 6 8 10

> sinter set1 set2
1) "1"
2) "2"
> sdiff set1 set2
1) "3"
2) "5"
3) "7"
4) "9"

> sdiff set2 set1
1) "4"
2) "6"
3) "8"
4) "10"

> sunion set1 set2
 1) "1"
 2) "2"
 3) "3"
 4) "4"
 5) "5"
 6) "6"
 7) "7"
 8) "8"
 9) "9"
10) "10"

2.5 ZSet

Redis 的 ZSet 是一个可排序的 Set 集合,类似 ZSet。ZSet 的每一个元素都带有一个 score 属性,可以基于 score 属性对元素排序。

**注意:**排名默认升序,降序需要在命令的 Z 后面添加 REV

相关操作

ZADD key [score member ...] :以 score 为权重向 ZSet 中添加一个或多个元素,如果存在则更新 score。

ZREM key member [member ...] :删除 ZSet 中的指定元素。

ZCARD key :返回 ZSet 中的元素个数。

ZSCORE key member :获取 ZSet 中指定元素的 score 值。

> zadd students 85 Jack 89 Lucy 82 Rose 95 Tom 78 Jerry 92 Amy 76 Miles
> zcard students
(integer) 7

> zrem students Miles
> zcard students
(integer) 6

> zscore students Jack
"85"
> zscore students Rose
"82"

ZRANK key member :获取 ZSet 中指定元素的排名(按照 score 升序)。

ZCOUNT key min max :统计 score 的值在给定范围内的元素个数。

ZINCRBY key increment member :让 ZSet 中的指定元素的 score 增加 increment。

# 升序
> zrank students Tom
(integer) 5

# 降序
> zrevrank students Tom
(integer) 0

> zcount students 90 100
(integer) 2
> zcount students 80 100
(integer) 5

> zincrby students 5 Tom
"100"

ZRANGE key min max :按照 score 排序后,获取 指定范围 内的元素。

# 升序
# 获取倒数前三
> zrange students 0 2
1) "Jerry"
2) "Rose"
3) "Jack"

# 获取所有元素
> zrange students 0 -1
1) "Jerry"
2) "Rose"
3) "Jack"
4) "Lucy"
5) "Amy"
6) "Tom"

# 降序
> zrevrange students 0 2
1) "Tom"
2) "Amy"
3) "Lucy"
> zrevrange students 0 -1
1) "Tom"
2) "Amy"
3) "Lucy"
4) "Jack"
5) "Rose"
6) "Jerry"

ZRANGEBYSCORE key min max :按照 score 排序后,获取 指定 score 范围 内的元素。

# 获取 score 在 0-80 范围内的元素
> zrangebyscore students 0 80
1) "Jerry"

ZINTER numberKeys key [key ...] | ZDIFF numberKeys key [key ...] | ZUNION numberKeys key [key ...] :求 n 个 Zset 的交集、差集、并集。

> zadd zset1 1 a 2 b 3 c 4 d 5 e
> zadd zset2 1 a 2 b 3 c 6 f 7 g

# 求 2 个 ZSet 的交集
> zinter 2 zset1 zset2
1) "a"
2) "b"
3) "c"

# 求 2 个 ZSet 的差集
> zdiff 2 zset1 zset2
1) "d"
2) "e"
> zdiff 2 zset2 zset1
1) "f"
2) "g"

# 求 2 个 ZSet 的并集
> zunion 2 zset1 zset2
1) "a"
2) "b"
3) "d"
4) "e"
5) "c"
6) "f"
7) "g"

2.6 应用场景

String

  • 缓存热点数据。
  • 全局 ID / 分布式 ID
  • 分布式锁( set key value NX EX )。
  • 分布式 Session。

Hash

Hash:value 可以看作一个 Map 集合,Key-Value 形式,只不过 Value 是一个 Map,也就是 Key-Map(Key-Map)。

  • String 类型存储对象需要将其序列化为 JSON 字符串后存储,如果需要修改对象的某个字段,比较不方便,只能修改整个 JSON 字符串。
  • Hash 适合存储结构体信息(对象),Hash 可以对用户结构中的每个字段单独存储,可以针对结构中的单个字段进行 CRUD。

List

List:类似 Java 中的 LinkedList,可以看作一个双向链表(有序可重复)。使用 List 可以对链表的两端进行 push 和 pop 操作、读取单个或多个元素、根据值查找或删除元素、支持正向检索和反向检索。

  • 时间线、队列、栈。

Set

点赞

假设文章 id 为 article1,用户 id 为 user1, user2。

# 点赞
> sadd like:article1 user1
> sadd like:article1 user2

# 取消点赞
> srem like:article1 user1

# 点赞数
> scard like:article1
(integer) 2

# 点赞用户
> smembers like:article1
1) "user1"
2) "user2"

关注

# 关注(user1 关注 user 2,user3 关注 user4)
> sadd follow:user1 user2
> sadd follow:user3 user4

# 互相关注
> sadd follow:user2 user1
> sadd follow:user4 user3

# 共同关注(user1 和 user2 共同关注 user3)
> sadd follow:user1 user3
> sadd follow:user2 user3
> sinter follow:user1 follow:user2
1) "user3"

# 可能认识的人(user1)
# user1 关注 user2,user2 关注 user1、user3、user4
> sadd follow:user1 user2
> sadd follow:user2 user1 user3 user4

# user1、user3 的并集 allFollow
> SUNIONSTORE allFollow follow:user1 follow:user2
> SMEMBERS allFollow
1) "user1"
2) "user3"
3) "user4"
4) "user2"

# 剔除 自身(user1) 和 已关注过的(user2)
> srem allFollow user1
> sdiff allFollow follow:user1
1) "user4"
2) "user3"

ZSet

百度搜索热点

# 2023-01-01 的热点新闻
> zadd hot:20230101 999 title1 777 title2 520 tag3
> ZRANGE hot:20230101 0 -1 withscores
1) "tag3"
2) "520"
3) "title2"
4) "777"
5) "title1"
6) "999"

# 点击量 +1
> ZINCRBY hot:20230101 1 title1
"1000"

3. RedisTemplate

SpringData 是 Spring 中数据操作的模块,包含对各种数据库的集成,其中对 Redis 的集成模块就叫做 SpringDataRedis。

SpringDataRedis 提供了对不同 Redis 客户端的整合(Lettuce 和 Jedis),通过 RedisTemplate 统一 API 操作 Redis。

通过 RedisTemplate 的 opsForValue()opsForHash()opsForList()opsForSet()opsForZSet() 方法可以操作 String、Hash、List、Set、ZSet 类型的数据。

3.1 RedisTemplate 的使用

导入 spring-boot-starter-data-rediscommons-pool2(Redis 连接池) 依赖,并且配置相关信息。

spring:
  redis:
    host: 127.0.0.1
    password: root
    port: 6379
    lettuce:
      pool:
        max-active: 8   # 最大连接数
        max-idle: 8     # 最大空闲数
        min-idle: 0     # 最小空闲数
        max-wait: 100   # 连接等待时间

注入 RestTemplate,测试。

// 自动注入的 `RedisTemplate` 需要加上泛型
@Resource
private RedisTemplate redisTemplate;

@Test
public void test() {
  	redisTemplate.opsForValue().set("k1", "v1");
  	Map<String, String> map = new HashMap<>();
	  map.put("k2", "v2");
  	map.put("k3", "v3");
		map.put("k4", "v4");
  	map.put("k5", "v5");
	  redisTemplate.opsForValue().multiSet(map);
		redisTemplate.opsForValue().multiGet(Arrays.asList("k1", "k2", "k3", "k4")).forEach(System.out::println);  // v1 v2 v3 v4 v5
}
# 在 Redis 中查看通过 RedisTemplate 插入的数据
> keys *
1) "\xac\xed\x00\x05t\x00\x02k1"
2) "\xac\xed\x00\x05t\x00\x02k2"
3) "\xac\xed\x00\x05t\x00\x02k3"
4) "\xac\xed\x00\x05t\x00\x02k4"
5) "\xac\xed\x00\x05t\x00\x02k5"

> get "\xac\xed\x00\x05t\x00\x02k1"
"\xac\xed\x00\x05t\x00\x02v1"

3.1 RedisTemplate 存在的问题

通过以上操作可以发现:RedisTemplate 可以将任意类型的数据写入到 Redis 中,在写入前会将其序列化为字节形式存储,底层默认采用 ObjectOutputStream 序列化。

但是,可读性差,内存占用大

自定义 RedisTemplate 的序列化方式

导入 jackson-databind 依赖,并编写配置类 RedisTemplateConfig

@Configuration
public class RedisTemplateConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 创建 RedisTemplate 对象
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        // 设置连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // 设置序列化工具
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();

        // Key 和 HashKey 采用 String 序列化(StringRedisSerializer)
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
      
        // Value 和 HashValue 采用 JSON 序列化(GenericJackson2JsonRedisSerializer)
        redisTemplate.setValueSerializer(jsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jsonRedisSerializer);
        
        return redisTemplate;
    }
}
// 自动注入的 `RedisTemplate` 需要加上泛型
@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Test
public void test() {
    redisTemplate.opsForValue().set("k1", "v1");
  	redisTemplate.opsForValue().set("user:1", new User("Jack", 21));
}

通过以上的方法能够解决数据序列化时 可读性差、内存占用大 的问题。

但是 JSON 的序列化方式仍然存在一些问题:为了反序列化时知道对象的类型,JSON 序列化器会将类的 class 类型写入 JSON 结果,存入 Redis 中,会带来额外的内存开销。

{
  "@class": "com.sun.entity.User",
  "username": "Jack",
  "age": 21
}

3.3 StringRedisTemplate

为了节省内存空间,Spring 提供了一个 StringRedisTemplate,它的 key 和 value 的序列化方式默认就是 String,统一使用 String 序列化器。

当需要存储 Java 对象时,手动完成对象的序列化和反序列化。

  1. 使用 StringRedisTemplate。
  2. 写入数据到 Redis 中,手动将对象序列化为 JSON。
  3. 从 Redis 中读取数据,手动将读取到的 JSON 反序列化为对象。
@Autowired
private StringRedisTemplate stringRedisTemplate;

private static final ObjectMapper objectMapper = new ObjectMapper();

@Test
public void ttt() throws JsonProcessingException {
    User user = new User("Michael", 27);
    // 手动序列化
    String json = objectMapper.writeValueAsString(user);
    // 写入数据
    stringRedisTemplate.opsForValue().set("user:1", json);
    // 读取数据
    String data = stringRedisTemplate.opsForValue().get("user:1");
    // 反序列化
    User deserializedUser = objectMapper.readValue(data, User.class);
    System.out.println(deserializedUser);
}
{
  "username": "Michael",
  "age": 27
}

4. 短信登录

项目准备

后端代码导入

git clone https://gitee.com/sjd75/comment.git

前端代码导入

Windows:在 nginx 目录下打开 CMD 窗口,输入 start nginx.exe

Mac OS

brew install nginx

# 查看 Nginx 安装地址
brew info nginx
/opt/homebrew/var/www
/opt/homebrew/etc/nginx/nginx.conf

# 将前端项目中 html/hmdp 复制到 /opt/homebrew/var/www 中
# 将前端项目中 conf/nginx.conf 复制到 /opt/homebrew/etc/nginx/nginx.conf 替换并修改

# 启动服务
sudo nginx
# 停止服务
sudo nginx -s stop

ThrowUtils

/**
 * 抛异常工具类(条件成立则抛异常)
 */
public class ThrowUtils {
    public static void throwIf(boolean condition, RuntimeException runtimeException) {
        if (condition) {
            throw runtimeException;
        }
    }

    public static void throwIf(boolean condition, ErrorCode errorCode) {
        throwIf(condition, new BusinessException(errorCode));
    }

    public static void throwIf(boolean condition, ErrorCode errorCode, String message) {
        throwIf(condition, new BusinessException(errorCode, message));
    }
}

Hutool 相关方法

BeanToMap 方法

// 使用 CopyOptions 处理字段值
Map<String, Object> map4UserDTO = BeanUtil.beanToMap(userDTO, new HashMap<>(),
        CopyOptions.create()
                // 是否忽略值为空的字段
                .setIgnoreNullValue(true)
                // StringRedisTemplate 只支持 String 类型,将属性转换为 String 后再存储到 Map 中(该方法优先级更高,此处也需要判空)
                .setFieldValueEditor((fieldName, fieldValue) -> {
                    if (null == fieldValue) {
                        return "";
                    }
                    return fieldValue.toString();
                })
);
// 封装一下
public class BeanMapUtil {
    public static <T> Map<String, Object> beantoMap(T t) {
        return BeanUtil.beanToMap(t, new HashMap<>(32),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> {
                            if (null == fieldValue) {
                                return "";
                            }
                            return fieldValue.toString();
                        })
        );
    }
}

fillBeanWithMap 方法

/**
 * 使用 Map 填充 Bean 对象
 *
 * @param            	Bean 类型
 * @param map           	Map
 * @param bean          	Bean
 * @param isIgnoreError 	是否忽略注入错误
 * @return Bean
 */
UserDTO userDTO = new UserDTO();
userDTO = BeanUtil.fillBeanWithMap(map4UserDTO, userDTO, false);

JSON 转换为 List

List<Shop> shopList = JSONUtil.toList(JSONUtil.parseArray(jsonStr), ShopType.class);

4.1 基于 Session 实现登录

4.1.1 发送验证码

前端发送请求,提交手机号。

校验手机号是否合格,合格则生成验证码并保存到 Redis 中,然后发送验证码。

/**
 * 发送手机验证码并将手机号和验证码保存到 Session 中
 */
@PostMapping("/code")
public CommonResult<String> sendCode(@RequestParam("phone") String phone) {
    return userService.sendCode(phone);
}
@Override
public CommonResult<String> sendCode(String phone, HttpSession httpSession) {
    // 1. 校验手机号
    ThrowUtils.throwIf(StringUtils.isBlank(phone), ErrorCode.PARAMS_ERROR);
    ThrowUtils.throwIf(Boolean.TRUE.equals(RegexUtils.isPhoneInvalid(phone)), ErrorCode.PARAMS_ERROR, "该手机号不合法");
    httpSession.setAttribute("phone", phone);

    // 2. 手机号格式正确,则生成验证码并保存到 Session 中
    String captcha = RandomUtil.randomNumbers(6);
    httpSession.setAttribute("captcha", captcha);

    // 3. 发送验证码
    // todo 暂时不接入第三方短信 API 接口
    log.debug("captcha: {}", captcha);
    return CommonResult.success("验证码发送成功");
}

4.1.2 登录和注册

前端发送请求,提交手机号和验证码。

  1. 校验手机号和验证码是否合格:合格后从 Redis 中获取该手机号对应的验证码,然后进行比对。
  2. 比对不一致则登录失败;比对一致则根据手机号查询用户。
    1. 用户不存在则创建新用户,并将其用户信息存入 Session,登录成功。
    2. 用户存在则直接将用户信息存入 Session,登录成功。
/**
 * 登录功能
 * @param loginForm 登录请求的参数:手机号、验证码(验证码登录);或者手机号、密码(密码登录)。
 */
@PostMapping("/login")
public CommonResult<String> login(@RequestBody LoginFormDTO loginForm){
    return userService.login(loginForm);
}
@Override
public CommonResult<String> login(LoginFormDTO loginForm, HttpSession session) {
    // 1. 校验手机号
  	// 1. 校验请求参数
    String loginPhone = loginForm.getPhone();
    String loginCaptcha = loginForm.getCode();
  	String phone = (String) session.getAttribute("phone");
    ThrowUtils.throwIf(StringUtils.isAnyBlank(loginPhone, loginCaptcha), ErrorCode.PARAMS_ERROR, "手机号和验证码不能为空");
    ThrowUtils.throwIf(Boolean.FALSE.equals(RegexUtils.isPhoneInvalid(loginPhone)), ErrorCode.PARAMS_ERROR, "该手机号不合法");
  	ThrowUtils.throwIf(!StrUtil.equals(loginPhone, phone), ErrorCode.PARAMS_ERROR, "两次输入的手机号不相同");

    // 2. 校验验证码是否正确
    String captcha = (String) session.getAttribute("captcha");
    ThrowUtils.throwIf(!StringUtils.equals(loginCaptcha, captcha), ErrorCode.PARAMS_ERROR, "验证码错误");

		// 3. 判断当前手机号是否已注册(未注册则创建新用户)
  	User user = this.lambdaQuery().eq(User::getPhone, loginPhone).one();
		if (user == null) {
		    user = createNewUser(user, loginPhone);
		}

    // 4. 存在则将用户保存到 Session 中(保证存入 Session 中的用户信息不包含敏感信息,使用 UserDTO)
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
  	session.setAttribute("user", userDTO);
    return CommonResult.success("登录成功")}

/**
 * 根据手机号创建用户
 */
private User createNewUser(User user, String loginPhone) {
    user = new User();
    user.setPhone(loginPhone);
    // 为 Nickname 设置前缀(USER_NICK_NAME_PREFIX = "user_")
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    boolean result = this.save(user);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return user;
}

4.1.3 登录验证(拦截器)

登录拦截器负责拦截需要登录的请求:从 Session 中获取用户信息,用户信息不为空则将用户信息存入 ThreadLocal 中即可;否则直接拦截。

ThreadLocal

线程安全问题的核心在于多个线程会对同一个临界区共享资源进行操作,如果每个线程都使用自己的「共享资源」,即多个线程间达到隔离的状态,这样就不会出现线程安全的问题。

ThreadLocal 表示线程的「本地变量」,即每个线程都拥有该变量副本,人手一份、各用各的,这样就可以避免共享资源的竞争

每个 Thread 中都具备⼀个 ThreadLocalMap ,⽽ ThreadLocalMap 可以存储以 ThreadLocal 为 key ,Object 对象为 value。(可以理解为 key 为当前线程,value 为变量值

public class MyThreadLocal<T> {
    private Map<Thread, T> map = new HashMap<>();

  	// 设置当前线程的局部变量值
    public void set(T t) {
        map.put(Thread.currentThread(), t);
    }
  
  	// 返回当前线程对应的局部变量值
    public T get() {    
        return map.get(Th read.currentThread());     
    }
  	
  	// 移除当前线程对应的局部变量值
    public void remove() {
        map.remove(Thread.currentThread());
    }
}
public class UserHolder {
    private static final ThreadLocal<UserDTO> threadLocal = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        threadLocal.set(user);
    }

    public static UserDTO getUser(){
        return threadLocal.get();
    }

    public static void removeUser(){
        threadLocal.remove();
    }
}

登录拦截器

@Component
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDTO user = (UserDTO) request.getSession().getAttribute("user");
        if (user == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  // 401
            return false;
        }
        UserHolder.saveUser(user);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 线程处理完之后移除用户,防止内存泄漏
        UserHolder.removeUser();
    }
}

配置拦截器

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Resource
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor).excludePathPatterns(
                "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**",
                "/blog/hot", "/user/login", "/user/code"
        );
    }
}
/**
 * 获取当前登录的用户并返回
 */
@GetMapping("/me")
public CommonResult<UserDTO> me(){
    return CommonResult.success(UserHolder.getUser());
}

登录成功后需要将用户信息存储到 Session 中,存储的是 UserDTO;通过拦截器中的 ThreadLocal 获取用户信息,获取到的也是 UserDTO;在 Controller 中通过 ThreadLocal 获取用户信息后返回,返回的也是 UserDTO

这样做是为了隐藏敏感信息,不能将整个 User 对象返回,而是返回一个 UserDTO 对象(仅包含 id、nickName、icon 属性),存入 Session 中的用户信息也应该是一个 UserDTO。

4.2 登录的实现方案

4.2.1 Session

Session 的原理

  1. 客户端发送请求,服务器将登录信息存储在 Session 中,Session 依赖于 Cookie,即服务器创建 Session 时会分配一个 SESSIONID,并在响应时将这个 SESSIONID 存储到 Cookie 中。
  2. 客户端收到这个 Cookie 后自动保存,并在下次访问时带上这个 Cookie,届时服务器就能通过这个 Cookie 中的 SESSIONID 找到对应的 Session 从而识别用户。

对于分布式系统而言,服务器之间是隔离的,Session 是不共享的,存在 Session 共享问题。

  1. 客户端第一次访问服务器,请求被分发到了 ServerA 上,ServerA 会为该客户端创建 Session。
  2. 客户端再次访问服务器,请求被分发到 ServerB 上,ServerB 中没有这个客户端携带的 SESSIONID 对应的 Session,用户身份无法得到验证,从而产生了「不一致」的问题。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第2张图片

4.2.2 Redis + Token

  1. 客户端通过账号密码登录。
  2. 服务器通过查询数据库进行验证。
  3. 验证成功后 签发 Token(创建一个 Token 存储在 Redis 中),并在响应时将这个 Token 返回给客户端。
  4. 客户端收到这个 Token 后将其保存到 SessionStorage 或 LocalStorage(浏览器的一种存储方式),并在下次访问时带上这个 Token。
    • Token 和 SESSIONID 作用相同,只是由服务器实现转变为由我们自己创建一个 Token 存储到 Cookie 中返回给客户端,并同时存储到 Redis 中。
    • Token 令牌:服务端生成的一串加密字符串,作为客户端请求时的一个标识。
  5. 客户端再次访问服务器时携带 Token。服务器 验证 Token:通过请求头中的 Token 查询 Redis 中存储的用户信息即可识别用户。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第3张图片

4.2.3 Redis + JWT

JWT (Json Web Token),JWT 由三部分组成:Header(加密算法和 Token 类型)、Playload(数据)、Sinature

// Decoded
header = {
  	"alg": "HS256",
  	"typ": "JWT"
}
playload = {
  	"sub": "1234567890",
  	"name": "John Doe",
  	"iat": 1516239022
}
signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), 签名Secret)
  
// Encoded
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.7hQ9doI17Q47YK31oBIf-etsfFTVhSK9wwqgoOOr_zs

编码后的内容(分为三个部分,每个部分之间用 . 连接)

  1. Header 经过 BASE64 编码后生成字符串
  2. Playload 经过 BASE64 编码后生成字符串
  3. 第三部分内容通过 Signature 表达式可以得到:前两部分内容 + 密钥,HS256 算法进行加密签名

普通 Token 和 JWT 的区别主要体现在 签发 Token验证 Token

  • 普通 Token 的签发:验证通过后,服务器可以通过 UUID 生成一个字符串作为 Token 或者 对账号密码加密生成一个字符串,存储到 Redis 中并返回给客户端。
  • 普通 Token 的验证:客户端发送请求时携带 Token,服务器对请求头中的 Token 与 Redis 中存储的 Token 进行对比。
  • JWT 的签发:对 Header、Playload 进行 BASE64 编码得到两个字符串,前两部分内容用点连接;将前两部分合起来与密钥通过 HS256 算法加密签名。
  • JWT 的验证:客户端发送请求时携带 JWT,获取 JWT 前两段内容,将其与密钥通过 HS256 算法加密签名;签名后的结果与 JWT 中第三段内容进行对比。

Redis + JWT

  1. 客户端通过账号密码登录。
  2. 服务器通过查询数据库进行验证。
  3. 验证成功后 签发 Token(根据 UserID 创建一个 JWT),并在响应时将这个 JWT 返回给客户端。
    • 注意,此处存储到 Redis 中的是 以 UserID 为 key,用户信息为 value
  4. 客户端收到这个 JWT 后将其保存到 SessionStorage 或 LocalStorage(浏览器的一种存储方式),并在下次访问时带上这个 JWT。
  5. 客户端再次访问服务器时携带 JWT。服务器 验证 Token:解析请求头中的 JWT 获取 UserID,根据 UserID 查询 Redis 中存储的用户信息即可识别用户
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第4张图片

4.3 基于 Redis + Token 实现登录

4.3.1 发送验证码 / 登录和注册

/**
 * 发送手机验证码并将验证码保存到 Redis 中
 */
@PostMapping("/code")
public CommonResult<String> sendCode(@RequestParam("phone") String phone) {
    return userService.sendCode(phone);
}

/**
 * 登录功能(登录成功后返回 Token)
 * @param loginForm 登录请求的参数:手机号、验证码(验证码登录);或者手机号、密码(密码登录)。
 */
@PostMapping("/login")
public CommonResult<String> login(@RequestBody LoginFormDTO loginForm){
    return userService.login(loginForm);
}
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 发送手机验证码并将验证码保存到 Redis 中
     */
    @Override
    public CommonResult<String> sendCode(String phone) {
        // 1. 验证手机号
        ThrowUtils.throwIf(StringUtils.isBlank(phone), ErrorCode.PARAMS_ERROR);
        ThrowUtils.throwIf(Boolean.TRUE.equals(RegexUtils.isPhoneInvalid(phone)), ErrorCode.PARAMS_ERROR, "该手机号不合法");

        // 2. 生成验证码并存入 Redis(设置过期时间为 2 min)
        String captcha = RandomUtil.randomNumbers(6);
        stringRedisTemplate.opsForValue().set(LOGIN_CAPTCHA_KEY + phone, captcha, TTL_TWO, TimeUnit.MINUTES);

        // 3. 发送验证码
        // todo 暂时不接入第三方短信 API 接口
        log.info("captcha = {}", captcha);

        return CommonResult.success("验证码发送成功");
    }

    /**
     * 登录功能(登录成功后返回 Token)
     * @param loginForm 登录请求的参数:手机号、验证码(验证码登录);或者手机号、密码(密码登录)。
     */
    @Override
    public CommonResult<String> login(LoginFormDTO loginForm) {
        // 1. 校验请求参数
        String loginPhone = loginForm.getPhone();
        String loginCaptcha = loginForm.getCode();
        ThrowUtils.throwIf(StringUtils.isAnyBlank(loginPhone, loginCaptcha), ErrorCode.PARAMS_ERROR, "手机号和验证码不能为空");
        ThrowUtils.throwIf(Boolean.TRUE.equals(RegexUtils.isPhoneInvalid(loginPhone)), ErrorCode.PARAMS_ERROR, "该手机号不合法");

        // 2. 从 Redis 中获取该手机号对应的验证码,并进行比对
        String captcha = stringRedisTemplate.opsForValue().get(LOGIN_CAPTCHA_KEY + loginPhone);
        ThrowUtils.throwIf(!StringUtils.equals(loginCaptcha, captcha), ErrorCode.PARAMS_ERROR, "验证码错误");

        // 3. 判断当前手机号是否已注册(未注册则创建新用户)
        User user = this.lambdaQuery().eq(User::getPhone, loginPhone).one();
        if (user == null) {
            user = createNewUser(user, loginPhone);
        }

        // 4. 避免敏感信息泄漏,用一个 UserDTO 装载必要信息即可
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

        // 5. 随机生成 Token 作为登录令牌
        String token = UUID.randomUUID().toString(true);
        String loginUserKey = LOGIN_USER_KEY + token;

        // 6. 保存用户信息到 Redis 中并设置有效时间。(使用 Hash 存储 User 对象)
        Map<String, Object> map4User = BeanUtil.beanToMap(userDTO, new HashMap<>(16),
                CopyOptions.create()
                        // 忽略空值,当源对象为 null 时,不注入此值
                        .ignoreNullValue()
                        // StringRedisTemplate 只支持 String 类型,将属性转换为 String 后再存储到 Map 中(此处也需要判空)
                        .setFieldValueEditor((fieldName, fieldValue) -> {
                            if (fieldValue == null) {
                                return "";
                            }
                            return fieldValue.toString();
                        })
        );
        stringRedisTemplate.opsForHash().putAll(loginUserKey, map4User);
        stringRedisTemplate.expire(loginUserKey, TTL_THIRTY, TimeUnit.MINUTES);
        return CommonResult.success(token);
    }

    /**
     * 根据手机号创建用户
     */
    private User createNewUser(User user, String loginPhone) {
        user = new User();
        user.setPhone(loginPhone);
        // 为 Nickname 设置前缀(USER_NICK_NAME_PREFIX = "user_")
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        boolean result = this.save(user);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return user;
    }
}

4.3.2 前端对 Token 的处理

  1. 用户登录成功后返回 Token 给客户端,客户端将 Token 存储到 SessionStorage(浏览器的一种存储方式)。
  2. 通过 request 拦截器,将 SessionStorage 中的 Token 放入请求头中。每次发送请求,请求头中都会携带这个 Token 头部信息。
login(){
		if(!this.radio){
		  	this.$message.error("请先确认阅读用户协议!");
		  	return
		}
		if(!this.form.phone || !this.form.code){
		  	this.$message.error("手机号和验证码不能为空!");
		  	return
		}
		axios.post("/user/login", this.form).then(({data}) => {
		    if(data){
		      // 将 Token 保存到 SessionStorage(浏览器的一种存储方式)
		      sessionStorage.setItem("token", data);
		    }
		    location.href = "/index.html"
		}).catch(err => this.$message.error(err))
}
// request 拦截器,将 Token 放入请求头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
  config => {
    if(token) config.headers['authorization'] = token
    return config
  },
  error => {
    console.log(error)
    return Promise.reject(error)
  }
)

4.3.2 登录验证(拦截器)

默认情况下,30 分钟后未访问 Session 会自动销毁。对于 Token 而言,也需要一个这样的机制,即 只要有访问就刷新(重置) Token 的有效期,通过拦截器实现。

  1. 第一个拦截器拦截所有请求,只要携带 Token 访问页面就刷新 Token 的有效期,然后将从 Redis 中获取 Token 对应的用户信息并存入 ThreadLocal 中。否则执行放行。
  2. 第二个拦截器针对需要登录的请求,只需要判断 ThreadLocal 中是否存在用户信息即可。
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从请求头中获取 Token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // 直接放行
            return true;
        }

        // 2. 根据 Token 获取 Redis 中存储的用户信息
        String loginUserKey = LOGIN_USER_KEY + token;
        // entries(key):返回 key 对应的所有 Map 键值对
        Map<Object, Object> map4UserDTO = stringRedisTemplate.opsForHash().entries(loginUserKey);
        if (MapUtil.isEmpty(map4UserDTO)) {
            // 直接放行
            return true;
        }

        // 3. 将 Map 转换为 UserDTO(第三个参数 isIgnoreError:是否忽略注入错误)后,存入 ThreadLocal
        UserDTO userDTO = new UserDTO();
        userDTO = BeanUtil.fillBeanWithMap(map4UserDTO, userDTO, false);
        UserHolder.saveUser(userDTO);

        // 4. 刷新 Token 有效期
        stringRedisTemplate.expire(loginUserKey, TTL_THIRTY, TimeUnit.MINUTES);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 线程处理完之后移除用户,防止内存泄漏
        UserHolder.removeUser();
    }
}
@Component
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断 ThreadLocal 中是否有用户信息
        if (ObjectUtil.isNull(UserHolder.getUser())) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        return true;
    }
}
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
    @Resource
    private RefreshTokenInterceptor refreshTokenInterceptor;

    @Resource
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(refreshTokenInterceptor).addPathPatterns("/**").order(0);

        registry.addInterceptor(loginInterceptor).excludePathPatterns(
                "/shop/**", "/shop-type/**", "/upload/**", "/voucher/**",
                "/blog/hot", "/user/login", "/user/code"
        ).order(1);
    }
}

5. 缓存

缓存:一种介于数据永久存储介质和数据应用之间的临时存储介质,也就是 存储在内存中的临时数据,读写性能很高。缓存通过减少 IO 的方式来提高程序的执行效率。

5.1 添加缓存

客户端发送请求,根据请求参数先从 Redis 中获取数据。

  • 命中 则直接返回数据。
  • 未命中 则从数据库中查询数据(需要判断数据库中是否存在数据),将查询到的数据写入到缓存中后返回数据。

5.1.1 /shop/{id}

/**
 * 根据 id 查询商铺信息(添加缓存)
 */
@GetMapping("/{id}")
public CommonResult<Shop> getShopById(@PathVariable("id") Long id) {
    return shopService.getShopById(id);
}
// 采用 Hash 存储
@Override
public CommonResult<Shop> getShopById(Long id) {
    ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
    String shopKey = CACHE_SHOP_KEY + id;   // CACHE_SHOP_KEY = "cache:shop:"

    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    Map<Object, Object> map4ShopInRedis = stringRedisTemplate.opsForHash().entries(shopKey);
    Shop shop = new Shop();
    if (MapUtil.isNotEmpty(map4ShopInRedis)) {
        shop = BeanUtil.fillBeanWithMap(map4ShopInRedis, shop, false);
        return CommonResult.success(shop);
    }

    // 2. 从 Redis 中未查询到数据,则从数据库中查询
    shop = this.getById(id);
    ThrowUtils.throwIf(shop == null, ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");

    // 3. 将从数据库中查询到的数据存入 Redis 后返回
    Map<String, Object> map4Shop = BeanUtil.beanToMap(shop, new HashMap<>(32),
            CopyOptions.create()
                    .ignoreNullValue()
                    .setFieldValueEditor((fieldName, fieldValue) -> {
                        if (fieldValue == null) {
                            return "";
                        }
                        return fieldValue.toString();
                    })
    );
    stringRedisTemplate.opsForHash().putAll(shopKey, map4Shop);
    stringRedisTemplate.expire(shopKey, TTL_TWO, TimeUnit.HOURS);
    return CommonResult.success(shop);
}

// 采用 String 存储
@Override
public CommonResult<Shop> getShopById(Long id) {
    ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
    String shopKey = CACHE_SHOP_KEY + id;

    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
    if (StringUtils.isNotBlank(shopJsonInRedis)) {
        return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
    }

    // 2. 从 Redis 中未查询到数据,则从数据库中查询
    Shop shop = this.getById(id);
    ThrowUtils.throwIf(shop == null, ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");

    // 3. 将从数据库中查询到的数据存入 Redis 后返回
    stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
    return CommonResult.success(shop);
}

5.1.2 /shop-type/list

/**
 * 展示商铺类型(缓存)
 */
@GetMapping("/list")
public CommonResult<List<ShopType>> getShopTypeList() {
    return shopTypeService.getShopTypeList();
}

List(ShopType 类型的 List 转换为 JSON 类型的 List 后存入,从 Redis 中获取到的 List 是 JSON 类型的,转换为 ShopType 后返回)

@Override
public CommonResult<List<ShopType>> getShopTypeList() {
    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    List<String> shopTypeJsonList = stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE_KEY, 0, -1);
    List<ShopType> shopTypeList = new ArrayList<>();
    if (CollectionUtil.isNotEmpty(shopTypeJsonList)) {
        for (String shopTypeJsonInRedis : shopTypeJsonList) {
            shopTypeList.add(JSONUtil.toBean(shopTypeJsonInRedis, ShopType.class));
        }
        return CommonResult.success(shopTypeList);
    }

    // 2. 从 Redis 中未查询到数据,则从数据库中查询
    shopTypeList = this.lambdaQuery().orderByAsc(ShopType::getSort).list();
    ThrowUtils.throwIf(CollectionUtil.isEmpty(shopTypeList), ErrorCode.NOT_FOUND_ERROR, "商铺类型列表不存在");

    // 3. 将从数据库中查询到的数据存入 Redis 后返回
    List<String> shopTypeListJson = shopTypeList.stream()
            .map(shopType -> JSONUtil.toJsonStr(shopType))
            .collect(Collectors.toList());
    stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOP_TYPE_KEY, shopTypeListJson);
    stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, TTL_TWO, TimeUnit.HOURS);
    return CommonResult.success(shopTypeList);
}

String(JSON 转 List:JSONUtil.toList(JSONUtil.parseArray(jsonStr), ShopType.class)

@Override
public CommonResult<List<ShopType>> getShopTypeList() {
    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    String shopTypeJsonInRedis = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY);
    List<ShopType> shopTypeList = null;
    if (StringUtils.isNotBlank(shopTypeJsonInRedis)) {
        shopTypeList = JSONUtil.toList(JSONUtil.parseArray(shopTypeJsonInRedis), ShopType.class);
        return CommonResult.success(shopTypeList);
    }

    // 2. 从 Redis 中未查询到数据,则从数据库中查询
    shopTypeList = this.list();
    ThrowUtils.throwIf(CollectionUtil.isEmpty(shopTypeList), ErrorCode.NOT_FOUND_ERROR, "商铺类型列表不存在");

    // 3. 将从数据库中查询到的数据存入 Redis 后返回
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopTypeList), TTL_TWO, TimeUnit.HOURS);
    return CommonResult.success(shopTypeList);
}

5.2 数据过期策略

数据过期策略:对数据设置 TTL 时间后,超过了 TTL 时间就需要将数据从 Redis 中删除,可以按照不同规则删除。删除规则就是的数据过期策略。

Redis 的过期删除策略:惰性删除 + 定期删除,两种策略配合使用。

惰性删除:获取该 Key 时检查是否过期,未过期则返回,过期则删除。

  • 对 CPU 友好,只有获取该 Key 时才会进行过期检查,不会浪费时间检查未使用到的 Key。
  • 对内存不友好,如果一个 Key 已过期但未使用,该 Key 会一直存在在内存中而不会释放。
set name Jack 10
# 获取时判断该 Key 是否过期,过期则删除
get name

定期删除:每隔一段时间对 Redis 中的一部分 Key 进行检查,将其中过期的 Key 删除。(随着时间推移可以遍历一遍 Redis 中存储的 Key)

  • SLOW 模式:该模式是一个定时任务,默认执行频率为 10hz(每秒清理 10 次),每次耗时不超过 25 ms。
  • FAST 模式:执行频率不固定,但两次清理的间隔不低于 2 ms,每次耗时不超过 1ms。

定期删除可以通过限制删除执行的时长和频率减少删除操作对 CPU 的影响,也能释放过期 Key 占用的内存。但是难以确定删除执行的时长和频率。

5.3 数据淘汰策略

数据淘汰策略:当 Redis 中的内存不足时,此时再向 Redis 中添加新 Key,Redis 就会按照某种规则将内存中的数据淘汰(删除)。这种删除数据的规则就是数据淘汰策略。

Redis 支持 8 种数据淘汰策略

  1. noeviction(默认):内存不足时,不淘汰任何 Key,但是添加新 Key 会报错。
  2. volatile-ttl:比较设置了 TTL 时间的 Key 的 TTL 值,TTL 越小越先被淘汰。
  3. allkeys-random:对所有 Key,随机淘汰。
  4. volatile-random:对设置了 TTL 的 Key,随机淘汰。
  5. allkeys-lru(优先使用):对所有 Key,基于 LRU 算法淘汰。
    • Least Recently Used 最近最少使用
  6. volatile-lru:对设置了 TTL 的 Key,基于 LRU 算法淘汰。
  7. allkeys-lfu:对所有 Key,基于 LFU 算法淘汰。
    • Least Frequently Used 使用频率最小
  8. volatile-lfu:对设置了 TTL 的 Key,基于 LFU 算法淘汰。

若数据库中有 1000 条万数据,Redis 中只能缓存 20 万条数据,如何保证 Redis 中的数据都是热点数据:使用 allkeys-lru 数据淘汰策略。

5.4 数据更新策略

  • 内存淘汰:Redis 内存达到设置的最大值时,触发数据淘汰机制。(默认存在,Redis 按照指定的淘汰策略自动执行)
    • 一致性较差:这种方式无法控制淘汰哪些数据,可能有数据在数据库中发生了变化,但是未被淘汰,导致数据库和 Redis 中的数据不一致。
  • 超时剔除:为缓存添加 TTL 时间,缓存超时后自动删除,下次查询时从数据库中查并更新缓存。
    • 这种方式还是存在数据库和 Redis 的数据不一致,假设 TTL 时间为 30 分钟,在这 30 分钟之内若数据库中的数据发生变化,此时 Redis 和数据库数据依然不一致。
  • 主动更新手动调用方法删除缓存,可以用于解决缓存和数据库不一致问题。

操作缓存和数据库时需要考虑的三个问题

  1. 删除缓存还是更新缓存?
    • 每次更新数据库的同时更新缓存:若数据库更新了 100 次,期间没有任何查询请求,此时缓存的更新就是无效操作。
    • 数据库更新就删除缓存:数据库更新后缓存被删除,此时数据库无论更新多少次,缓存都不会做任何操作。直到有查询请求,缓存才会将数据库中的数据写入到缓存中。
  2. 如何保证缓存和数据库的操作同时成功或失败?
    • 单体系统:将缓存与数据库操作放在一个事务。
    • 分布式系统:利用 TCC 等分布式事务方案。
  3. 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库。假设缓存为 10,数据库为 10。(t1、t2、t3 代表三个时刻)
      • t1:线程 1 删除缓存,并更新数据库为 20。t2:线程 2 查询缓存未命中,从数据库中查询并写入缓存。✔️
      • t1:线程 1 删除缓存。t2:线程 2 查询缓存未命中,从数据库中查询并写入缓存。t3:线程 t1 更新数据库为 20。❌
    • **先操作数据库,再删除缓存。**假设缓存为 10,数据库为 10。(t1、t2、t3、t4 代表四个时刻)
      • t1:线程 1 更新数据库为 20,删除缓存。t2:线程 2 查询缓存未命中,从数据库中查询并写入缓存。✔️
      • t1:线程 1 查询缓存未命中,从数据库中查询。t2:线程 2 更新数据库为 20,删除缓存。t3:线程 1 写入缓存。❌(这种方式出现的概率很小,缓存写入的速度很快。更可能出现的情况是:线程 1 写入缓存后,线程 2 更新数据库然后将缓存删除)

最佳方案

低一致性需求:使用 Redis 自带的数据淘汰机制。

高一致性需求:使用主动更新策略,并以超时剔除作为兜底方案。

  • 读操作:缓存命中则直接返回;缓存未命中则查询数据库,并写入缓存,设定超时时间。
  • 写操作:先操作数据库,再删缓存。(确保数据库与缓存操作的原子性)

为 /shop 接口添加缓存更新策略

/**
 * 更新商铺信息(先操作数据库,后删除缓存)
 */
@PutMapping
public CommonResult<String> update(@RequestBody Shop shop) {
    return shopService.update(shop);
}
@Transactional
@Override
public Result updateShop(Shop shop) {
    if (ObjectUtil.isNull(shop.getId())) {
        throw new RuntimeException("商铺 Id 不能为空");
    }
    boolean isUpdated = updateById(shop);
    if (BooleanUtil.isFalse(isUpdated)) {
        throw new RuntimeException("数据库更新失败");
    }
    Boolean isDeleted = redisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
    if (BooleanUtil.isFalse(isDeleted)) {
        throw new RuntimeException("数据库更新成功,但是 Redis 删除失败");
    }
    return Result.ok();
}
// 1. 访问商铺 `localhost:8081/shop/1`,商铺信息被存储到 Redis 缓存中。
// 2. 修改商铺信息 `localhost:8081/shop`(PUT),数据库的数据发生变化、Redis 中存储的数据被删除。
{
  "id": 1,
  "name": "103茶餐厅"
}

5.5 缓存穿透

**客户端请求的数据在 Redis 和数据库中都不存在,缓存永远不会生效,请求永远都打在数据库上。**如果并发的发起大量的这种请求(恶意攻击),每次请求都查询数据库,可能导致数据库宕机(数据库能够承载的并发远不如 Redis)。

5.5.1 缓存空对象

客户端请求的数据在 Redis 和数据库中都不存在,为了防止不断的请求:空值 缓存到 Redis 中并且设置 TTL 时间后,返回给该请求。

  • 缓存中包含过多的 空值,会造成额外的内存消耗。(设置 TTL 可以缓解)
  • 可能造成短期的不一致:第一次请求的数据在 Redis 和数据库中都不存在,缓存空对象后,数据库中新增了该请求对应的数据。
@Override
public CommonResult<Shop> getShopById(Long id) {
    ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
    String shopKey = CACHE_SHOP_KEY + id;

    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
    if (StringUtils.isNotBlank(shopJsonInRedis)) {
        return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
    }
  	// 命中空值
		if (shopJsonInRedis != null) {
		    throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
		}

    // 2. 从 Redis 中未查询到数据,则从数据库中查询
    Shop shop = this.getById(id);
    
    // 若数据中也查询不到,则缓存空值后返回提示信息
    if (shop == null) {
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", TTL_TWO, TimeUnit.MINUTES);
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
    }

    // 3. 将从数据库中查询到的数据存入 Redis 后返回
    stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
    return CommonResult.success(shop);
}

5.5.2 布隆过滤器

布隆过滤器(Bloom Filter):一个很长的二进制数组(初始化值为 0),通过一系列的 Hash 函数判断该数据是否存在。 布隆过滤器的运行速度快、内存占用小,但是存在误判的可能。

  1. 存储数据时经过 n 个 hash 函数,计算出 n 个 hash 值,hash 值映射后得到 n 个索引,设置索引处的值为 1。(若当前索引处值已经为 1,则不需要任何操作)
  2. 查询数据时也会经过 n 个 hash 函数,计算出 n 个 hash 值,hash 值映射后得到 n 个索引,判断索引处的值是否为 1。
    1. 查询 Anthony:经过 hash 算法得到的 hash 值映射后数组下标为 0、2、6,下标对应的值没有全为 1,数组中不存在该元素
    2. 查询 Coco:经过 hash 算法得到的 hash 值映射后数组下标为 0、2、6,下标对应的值都为 1,数组中可能存在该元素
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第5张图片

请求进来先查询布隆过滤器,不存在则直接返回,存在则查询 Redis;命中则返回,否则查询数据库并写入 Redis 后返回。(预热缓存的同时需要预热布隆过滤器)

5.6 缓存雪崩

大量缓存同时过期,或者 Redis 服务宕机;导致大量的请求直接访问数据库,造成数据库瞬间压力过大、宕机。

  1. 缓存的 TTL 设置为随机值,避免同时失效。
  2. Redis 集群(针对 Redis 宕机)。
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_THIRTY + RandomUtil.randomInt(30), TimeUnit.MINUTES);

5.7 缓存击穿

缓存击穿问题也叫热点 Key 问题:一个被高并发访问的 Key 突然失效,在这个失效的瞬间大量请求穿过缓存直接请求数据库,给数据库带来巨大的冲击。

缓存击穿整体过程:

  1. 一个线程查询缓存,未命中,查询数据库并重建缓存(缓存重建业务比较复杂,时间长)。
  2. 在这个重建缓存的过程中,大量的请求穿过缓存直接请求数据库并重建缓存,导致性能下降。

缓存击穿的解决方案互斥锁(一致性)、逻辑过期(可用性)。

Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第6张图片

互斥锁的测试:使用 Jmeter 测试,创建线程组,线程数设置为 1000,Ramp-Up 时间为 5 秒。QPS(Query Per Second)为 200。

逻辑过期的测试:线程数设置为 1000,Ramp-Up 时间为 1 秒,先预热数据,过期后修改数据库,此时会经历短暂的数据不一致(重建缓存)。

5.7.1 synchronized

  1. 查询缓存,存在则直接返回。
  2. 不存在:执行 synchronized 代码块。
    1. 先查缓存,存在则直接返回。(若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存)
    2. 查询数据库,重建缓存。
@SneakyThrows
@Override
public CommonResult<Shop> getShopById(Long id) {
    ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
    String shopKey = CACHE_SHOP_KEY + id;

    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
    if (StringUtils.isNotBlank(shopJsonInRedis)) {
        return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
    }
  	// 命中空值
		if (shopJsonInRedis != null) {
		    throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
		}

    // 2. 从 Redis 中未查询到数据,则从数据库中查询。(synchronized)
    Shop shop = new Shop();
    synchronized (ShopServiceImpl.class) {
        // 3. 再次查询 Redis:若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
        shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
        if (StringUtils.isNotBlank(shopJsonInRedis)) {
            return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
        }

        // 4. 查询数据库,缓存空值避免缓存穿透,重建缓存。
        shop = this.getById(id);
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", TTL_TWO, TimeUnit.MINUTES);
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
        }
      	// 模拟缓存重建延迟
        Thread.sleep(100);
        stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
    }
    return CommonResult.success(shop);
}

5.7.2 setnx

利用 Redis 的 setnx 方法来表示获取锁(Redis 中没有这个 Key,可以成功插入;如果有这个 Key,则插入失败),直接删除这个 Key 表示释放锁。

/**
 * 获取互斥锁
 */
public boolean tryLock(String key) {
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL_TWO, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(result);
}

/**
 * 释放互斥锁
 */
public void unlock(String key) {
    stringRedisTemplate.delete(key);
}
@SneakyThrows
@Override
public CommonResult<Shop> getShopById(Long id) {
    ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
    String shopKey = CACHE_SHOP_KEY + id;
  	String lockKey = LOCK_SHOP_KEY + id;

    // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
    String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
    if (StringUtils.isNotBlank(shopJsonInRedis)) {
        return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
    }
  	// 命中空值
		if (shopJsonInRedis != null) {
		    throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
		}

    // 2. 从 Redis 中未查询到数据,尝试获取锁后从数据库中查询。
    Shop shop = new Shop();
    boolean tryLock = tryLock(lockKey);
    try {
        // 2.1 未获取到锁则等待一段时间后重试(通过递归调用重试)
        if (BooleanUtil.isFalse(tryLock)) {
            Thread.sleep(50);
            this.getShopById(id);
        }

        // 2.2 获取到锁:查询数据库、缓存重建。
        if (tryLock) {
            // 3. 再次查询 Redis:若多个线程执行到获取锁处,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
            shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
            if (StringUtils.isNotBlank(shopJsonInRedis)) {
                return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
            }

            // 4. 查询数据库,缓存空值避免缓存穿透,重建缓存。
            shop = this.getById(id);
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", TTL_TWO, TimeUnit.MINUTES);
                throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
            }
          	// 模拟缓存重建延迟
            Thread.sleep(100);
            stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
        }
    } finally {
        // 5. 释放锁
        unlock(lockKey);
    }
    return CommonResult.success(shop);
}

5.7.3 逻辑过期

无需考虑缓存雪崩(Redis 宕机除外)、缓存穿透问题:缓存何时过期通过代码控制而非 TTL。需要进行数据预热,缓存未命中时直接返回空

  1. 先查询缓存,未命中则直接返回
  2. 命中则判断缓存是否过期,未过期则直接返回
  3. 过期:获取锁。
    1. 未获取到锁:直接返回。
    2. 获取到锁:开启一个新的线程后直接返回,这个线程负责重建缓存后释放锁。

缓存预热(将热点数据提前存储到 Redis 中)

存储到 Redis 中的 Key 永久有效,过期时间通过代码控制而非 TTL。Redis 存储的数据需要带上一个逻辑过期时间,即 Shop 实体类中需要一个逻辑过期时间属性。新建一个 RedisData,该类包含两个属性 expireTime 和 Data,对原来的代码没有入侵性。

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
/**
 * 缓存预热(将热点数据提前存储到 Redis 中)
 */
public void saveHotDataIn2Redis(Long id, Long expireSeconds) {
    Shop shop = this.getById(id);
    ThrowUtils.throwIf(shop == null, ErrorCode.NOT_FOUND_ERROR, "该数据不存在");
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
# Redis 中存储的数据会多一个 expireTime 的值
{
  "expireTime": 1681660099861,
  "data": {
    "id": 1,
    "name": "101茶餐厅",
    "typeId": 1,
    ...
  }
}

逻辑过期

/**
 * 缓存预热(将热点数据提前存储到 Redis 中)
 */
public void saveHotDataIn2Redis(Long id, Long expireSeconds) throws InterruptedException {
    Shop shop = this.getById(id);
    ThrowUtils.throwIf(shop == null, ErrorCode.NOT_FOUND_ERROR, "该数据不存在");
    // 模拟缓存重建延迟,让一部分线程先执行完毕,在此期间会短暂的不一致
    Thread.sleep(200);
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService ES = Executors.newFixedThreadPool(10);

@SneakyThrows
@Override
public CommonResult<Shop> getShopById(Long id) {
    ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
    String shopKey = CACHE_SHOP_KEY + id;
    String lockKey = LOCK_SHOP_KEY + id;
    // 1. 先从 Redis 中查询数据,未命中则直接返回
    String redisDataJson = stringRedisTemplate.opsForValue().get(shopKey);
    if (StringUtils.isBlank(redisDataJson)) {
        return CommonResult.success(null);
    }
    // 2. 判断是否过期,未过期则直接返回
    RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
    JSONObject jsonObject = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(jsonObject, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    if (expireTime.isAfter(LocalDateTime.now())) {
        return CommonResult.success(shop);
    }
    // 3. 未获取到锁直接返回
    boolean tryLock = tryLock(lockKey);
    if (BooleanUtil.isFalse(tryLock)) {
        return CommonResult.success(shop);
    }
    // 4. 获取到锁:开启一个新的线程后返回旧数据。(这个线程负责查询数据库、重建缓存)
    // 此处无需 DoubleCheck,因为未获取到锁直接返回旧数据,能保证只有一个线程执行到此处
    ES.submit(() -> {
        try {
            // 查询数据库、重建缓存
            this.saveHotDataIn2Redis(id, 3600 * 24L);
        } catch (Exception e) {
            log.error(e.getMessage());
        } finally {
            unlock(lockKey);
        }
    });
    return CommonResult.success(shop);
}

5.8 封装 Redis Cache 工具类

@Component
@Slf4j
public class CacheClient {
    private static final ExecutorService ES = Executors.newFixedThreadPool(10);
  	
    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 获取锁
     */
    public boolean tryLock(String key) {
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL_TWO, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(result);
    }

    /**
     * 释放锁
     */
    public void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

    /**
     * 数据预热(将热点数据提前存储到 Redis 中)
     *
     * @param key        预热数据的 Key
     * @param value      预热数据的 Value
     * @param expireTime 逻辑过期时间
     * @param timeUnit   时间单位
     */
    public void dataWarmUp(String key, Object value, Long expireTime, TimeUnit timeUnit) {
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 将 Java 对象序列化为 JSON 存储到 Redis 中并且设置 TTL 过期时间
     *
     * @param key      String 类型的键
     * @param value    序列化为 JSON 的值
     * @param time     TTL 过期时间
     * @param timeUnit 时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit timeUnit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
    }

    /**
     * 解决缓存穿透问(缓存空值)
     *
     * @param keyPrefix Key 前缀
     * @param id        id
     * @param type      实体类型
     * @param function  有参有返回值的函数
     * @param time      TTL 过期时间
     * @param timeUnit  时间单位
     * @param        实体类型
     * @param       id 类型
     * @return 设置某个实体类的缓存,并解决缓存穿透问题
     */
    public <R, ID> R setWithCachePenetration(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
        String key = keyPrefix + id;

        // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isNotBlank(jsonStr)) {
            return JSONUtil.toBean(jsonStr, type);
        }
        // 命中空值
        if (jsonStr != null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
        }

        // 2. 从 Redis 中未查询到数据,则从数据库中查询
        R result = function.apply(id);
        // 若数据中也查询不到,则缓存空值后返回提示信息
        if (result == null) {
            stringRedisTemplate.opsForValue().set(key, "", TTL_TWO, TimeUnit.MINUTES);
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
        }

        // 3. 将从数据库中查询到的数据存入 Redis 后返回
        this.set(key, result, time, timeUnit);
        return result;
    }

    /**
     * 解决缓存击穿问题(synchronized)
     */
    public <R, ID> R setWithCacheBreakdown4Synchronized(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
        String key = keyPrefix + id;

        // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isNotBlank(jsonStr)) {
            return JSONUtil.toBean(jsonStr, type);
        }
        // 命中空值
        if (jsonStr != null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
        }

        // 2. 从 Redis 中未查询到数据,则从数据库中查询。(synchronized)
        R result = null;
        synchronized (CacheClient.class) {
            // 3. 再次查询 Redis:若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
            jsonStr = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isNotBlank(jsonStr)) {
                return JSONUtil.toBean(jsonStr, type);
            }

            // 4. 查询数据库、缓存空值避免缓存穿透、重建缓存。
            result = function.apply(id);
            if (result == null) {
                stringRedisTemplate.opsForValue().set(key, "", TTL_TWO, TimeUnit.MINUTES);
                throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
            }
            this.set(key, result, time, timeUnit);
        }
        return result;
    }
  	
    /**
     * 解决缓存击穿问题(setnx)
     */
    public <R, ID> R setWithCacheBreakdown4SetNx(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
        String key = keyPrefix + id;
        String lockKey = LOCK_SHOP_KEY + id;

        // 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isNotBlank(jsonStr)) {
            return JSONUtil.toBean(jsonStr, type);
        }
        // 命中空值
        if (jsonStr != null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
        }

        // 2. 从 Redis 中未查询到数据,尝试获取锁后从数据库中查询。
        R result = null;
        boolean tryLock = tryLock(lockKey);
        try {
            // 2.1 未获取到锁则等待一段时间后重试(通过递归调用重试)
            if (BooleanUtil.isFalse(tryLock)) {
                Thread.sleep(50);
                this.setWithCacheBreakdown4SetNx(keyPrefix, id, type, function, time, timeUnit);
            }
            // 2.2 获取到锁:查询数据库、缓存重建。
            if (tryLock) {
                // 3. 再次查询 Redis:若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
                jsonStr = stringRedisTemplate.opsForValue().get(key);
                if (StringUtils.isNotBlank(jsonStr)) {
                    return JSONUtil.toBean(jsonStr, type);
                }

                // 4. 查询数据库、缓存空值避免缓存穿透、重建缓存。
                result = function.apply(id);
                if (result == null) {
                    stringRedisTemplate.opsForValue().set(key, "", TTL_TWO, TimeUnit.MINUTES);
                    throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
                }
                this.set(key, result, time, timeUnit);
            }
        } catch (Exception e) {
            log.error(e.getMessage());
        } finally {
            unlock(lockKey);
        }
        return result;
    }

    /**
     * 解决缓存击穿问题(逻辑过期时间)
     */
    public <R, ID> R setWithCacheBreakdown4LogicalExpiration(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
        String key = keyPrefix + id;
        String lockKey = LOCK_SHOP_KEY + id;

        // 1. 先从 Redis 中查询数据,未命中则直接返回
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isBlank(jsonStr)) {
            return null;
        }

        // 2. 判断是否过期,未过期则直接返回
        RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
        JSONObject jsonObject = JSONUtil.parseObj(redisData.getData());
        R result = JSONUtil.toBean(jsonObject, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            return result;
        }

        // 3. 未获取到锁直接返回
        boolean tryLock = tryLock(lockKey);
        if (BooleanUtil.isFalse(tryLock)) {
            return result;
        }

        // 4. 获取到锁:开启一个新的线程后返回旧数据。(这个线程负责查询数据库、重建缓存)
        // 此处无需 DoubleCheck,因为未获取到锁直接返回旧数据,能保证只有一个线程执行到此处
        ES.submit(() -> {
            try {
                this.dataWarmUp(key, function.apply(id), time, timeUnit);
            } finally {
                unlock(lockKey);
            }
        });
        return result;
    }
}
@Resource
private CacheClient cacheClient;

@Override
public CommonResult<Shop> getShopById(Long id) {
    // 缓存穿透
    Shop shop = cacheClient.setWithCachePenetration(CACHE_SHOP_KEY, id, Shop.class, this::getById, TTL_TWO, TimeUnit.MINUTES);

    // 缓存击穿:synchronized
    Shop shop = cacheClient.setWithCacheBreakdown4Synchronized(CACHE_SHOP_KEY, id, Shop.class, this::getById, TTL_TWO, TimeUnit.HOURS);

    // 缓存击穿:setnx
    Shop shop = cacheClient.setWithCacheBreakdown4SetNx(CACHE_SHOP_KEY, id, Shop.class, this::getById, TTL_TWO, TimeUnit.HOURS);

    // 缓存击穿:逻辑过期
    Shop shop = cacheClient.setWithCacheBreakdown4LogicalExpiration(CACHE_SHOP_KEY, id, Shop.class, this::getById, TTL_TWO, TimeUnit.HOURS);
    return CommonResult.success(shop);
}

6. 秒杀相关

每个店铺都可以发布优惠券,保存到 tb_voucher 表中;当用户抢购时,生成订单并保存到 tb_voucher_order 表中。

订单表如果使用数据库自增 ID,会存在以下问题:

  • ID 的规律太明显,容易暴露信息。
  • 单表数据量的限制,订单过多时单表很难存储得下。数据量过大后需要拆库拆表,但拆分表了之后,各表从逻辑上是同一张表,所以 id 不能一样, 于是需要保证 ID 的唯一性。

6.1 全局唯一 ID

全局唯一 ID 的特点

  • 唯一性:Redis 独立于数据库之外,不论有多少个数据库、多少张表,访问 Redis 获取到的 ID 可以保证唯一。
  • 高可用:Redis 高可用(集群等方案)。
  • 高性能:Redis 速度很快。
  • 递增性:例如 String 的 INCR 命令,可以保证递增。
  • 安全性:为了增加 ID 的安全性,在使用 Redis 自增数值的基础上,在拼接一些其他信息。

全局唯一 ID 的组成(存储数值类型占用空间更小,使用 long 存储,8 byte,64 bit)

  • 符号位:1 bit,永远为 0,代表 ID 是正数。
  • 时间戳:31 bit,以秒为单位,可以使用 69 年。
  • 序列号:32 bit,当前时间戳对应的数量,也就是每秒可以对应 2^32 个不同的 ID。

Redis ID 自增策略:通过设置每天存入一个 Key,方便统计订单数量;ID 构造为 时间戳 + 计数器。

获取指定时间的时间戳

long timestamp = LocalDateTime.of(2023, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC);
System.out.println("timestamp = " + timestamp);   // timestamp = 1672531200

LocalDateTime datetime = LocalDateTime.ofEpochSecond(timestamp, 0, ZoneOffset.UTC);
System.out.println("datetime = " + datetime);   // 2023-01-01T00:00

使用 Redis 生成全局唯一 ID

@Component
public class RedisIdWorker {
    /**
     * 指定时间戳(2023年1月1日 0:0:00) LocalDateTime.of(2023, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC)
     */
    private static final long BEGIN_TIMESTAMP_2023 = 1672531200L;

    /**
     * 序列号位数
     */
    private static final int BIT_COUNT = 32;

    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {
        // 1. 时间戳
        long timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP_2023;
        // 2. 生成序列号:自增 1,Key 不存在会自动创建一个 Key。(存储到 Redis 中的 Key 为 keyPrefix:date,Value 为自增的数量)
        Long serialNumber = stringRedisTemplate.opsForValue().increment(keyPrefix + ":" + DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDate.now()));
        // 3. 时间戳左移 32 位,序列号与右边的 32 个 0 进行与运算
        return timestamp << BIT_COUNT | serialNumber;
    }
}

测试(300 个线程生成共生成 3w 个 id)

@Resource
private RedisIdWorker redisIdWorker;

public static final ExecutorService ES = Executors.newFixedThreadPool(500);

@Test
void testGloballyUniqueID() throws Exception {
    // 程序是异步的,分线程全部走完之后主线程再走,使用 CountDownLatch;否则异步程序没有执行完时主线程就已经执行完了
    CountDownLatch latch = new CountDownLatch(300);
    Runnable task = () -> {
        for (int i = 0; i < 100; i++) {
            long globallyUniqueID = redisIdWorker.nextId("sun");
            System.out.println("globallyUniqueID = " + globallyUniqueID);
        }
        latch.countDown();
    };

    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        ES.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("Execution Time: " + (end - begin));
}

6.2 优惠券秒杀

6.2.1 添加优惠券

优惠券分为普通券和秒杀券,普通券可以任意购买,特价券需要秒杀抢购。

  • tb_voucher 优惠券表(包含普通券和秒杀券) :优惠券的基本信息、是否为秒杀券等字段。
  • tb_seckill_voucher 秒杀券表:关联的优惠券 ID、秒杀优惠券的库存、抢购开始时间、抢购结束时间等字段。该表与 tb_voucher 表是一对一关系。
/**
 * 新增秒杀券的同时在优惠券表中新增优惠券
 */
@PostMapping("/seckill")
public CommonResult<Long> addSeckillVoucher(@RequestBody Voucher voucher) {
    return voucherService.addSeckillVoucher(voucher);
}

@Override
@Transactional
public CommonResult<Long> addSeckillVoucher(Voucher voucher) {
    // 新增优惠券
    boolean result = this.save(voucher);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "新增优惠券失败");

    // 新增秒杀券
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    result = seckillVoucherService.save(seckillVoucher);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "新增秒杀券失败");
    return CommonResult.success(voucher.getId());
}
// type 为 1 代表秒杀券
{
    "shopId": 1,
    "title": "100元代金券",
    "subTitle": "周一到周五均可使用",
    "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
    "payValue": 8000,
    "actualValue": 10000,
    "type": 1,
    "stock": 100,
    "beginTime":"2023-05-05T13:13:13",
    "endTime":"2023-06-06T14:14:14"
}
# tb_voucher
`id`              2
`shop_id`         1
`title`           100元代金券
`sub_title`       周一到周五均可使用
`rules`           全场通用\n无需预约\n可无限叠加\不兑现、不找零\n仅限堂食
`pay_value`       8000
`actual_value`    10000
`type`            1
`status`          1
`create_time`     2023-05-05 13:13:13
`update_time`     2023-05-05 13:13:13

# tb_seckill_voucher
`voucher_id`      2
`stock`           100
`create_time`     2023-05-05 13:13:13
`begin_time`      2023-05-05 13:13:13
`end_time`        2023-06-06 14:14:14
`update_time`     2023-05-05 13:13:13

6.2.2 下单优惠券

  1. 发送下单请求,提交优惠券 ID。
  2. 下单前需要判断:秒杀是否开始或结束、库存是否充足
  3. 库存充足则扣减库存,创建订单并返回订单 ID。
/**
 * 秒杀下单优惠券
 * @param voucherId     优惠券 ID
 * @return              订单 ID
 */
@PostMapping("/seckill/{id}")
public CommonResult<Long> seckillVoucher(@PathVariable("id") Long voucherId) {
    return voucherOrderService.seckillVoucher(voucherId);
}
@Resource
private SeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;

/**
 * VERSION1.0 - 秒杀下单优惠券(通过 CAS 解决超卖问题)
 */
@Override
@Transactional
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 1. 判断秒杀是否开始或结束、库存是否充足。
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    ThrowUtils.throwIf(seckillVoucher == null, ErrorCode.NOT_FOUND_ERROR);
    LocalDateTime now = LocalDateTime.now();
    ThrowUtils.throwIf(now.isBefore(seckillVoucher.getBeginTime()), ErrorCode.OPERATION_ERROR, "秒杀尚未开始");
    ThrowUtils.throwIf(now.isAfter(seckillVoucher.getEndTime()), ErrorCode.OPERATION_ERROR, "秒杀已经结束");
    ThrowUtils.throwIf(seckillVoucher.getStock() < 1, ErrorCode.OPERATION_ERROR, "库存不足");
    
    // 2. 扣减库存
    Long userId = UserHolder.getUser().getId();
    // UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE voucher_id = ?
    boolean result = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");
    
    // 3. 下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setUserId(userId);
    voucherOrder.setId(redisIdWorker.nextId("seckillVoucherOrder"));
    voucherOrder.setVoucherId(voucherId);
    result = this.save(voucherOrder);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");
    return CommonResult.success(voucherOrder.getId());
}

6.3 超卖问题

Jmeter 测试:添加线程组,线程数 200,Ram-Up 时间 0,循环 1 次;请求 localhost:8081/voucher-order/seckill/秒杀券id;添加 HTTP 信息头管理器设置 Authorization。

通过 Jmeter 测试发现秒杀优惠券的库存为 负数,生成的订单数量超过 100 份。出现了 超卖问题

超卖问题:假设库存为 1,有线程1、2、3,时刻 t1、t2、t3、t4。

  • t1:线程1 查询库存,库存为 1;
  • t2:线程2、线程 3 查询库存,库存为 1;
  • t3:线程1 下单,库存扣减为 0。
  • t4:线程2 和 线程3 下单,库存扣减为 -2。

6.3.1 乐观锁(版本号 & CAS)

悲观锁,也就是互斥锁,互斥锁的同步方式是悲观的。

将资源锁定,只供一个线程调用,而阻塞其他线程。

乐观锁(通过 版本号机制 & CAS 实现)

不会将资源锁定,但是在 更新时会判断在此期间有没有其他线程更新该数据

  • 如果没有其他线程修改则更新成功
  • 如果被其他线程修改了,则放弃本次更新操作以避免出现冲突,并通过报错、重试等方式进行下一步处理

版本号机制

一般是在数据库表中加上一个 version 字段表示 数据被修改的次数。数据被修改时 version 值加 1。

  1. 线程 A 读取数据,同时读取到 version 值。
  2. 提交更新时,若刚才读到的 version 值未发生变化:则提交更新并且 version 值加 1。
  3. 提交更新时,若刚才读到的 version 值发生了变化:放弃更新,并通过报错、自旋重试等方式进行下一步处理。

CAS(Compare And Set 对比交换)

CAS 操作需要输入两个数值,一个旧值(操作前的值)和一个新值,操作时先比较下在旧值有没有发生变化,若未发生变化才交换成新值,发生了变化则不交换。

CAS 是原子操作,多线程并发使用 CAS 更新数据时,可以不使用锁。原子操作是最小的不可拆分的操作,操作一旦开始,不能被打断,直到操作完成。也就是多个线程对同一块内存的操作是串行的

版本号机制解决超卖问题。(假设 stock = 1,version = 1)

  • t1:线程 A 获取的 stock 和 version 为 1 和 1。

  • t2:线程 B 获取的 stock 和 version 为 1 和 1。

  • t3:线程 A 更新时比对 version 的值,值相同,提交更新。(更新操作包括 version = version +1

    update tb_seckill_voucher set stock = stock - 1 and version = version + 1 where id = 2 and version = 1
    
  • t4:线程 B 更新时比对 version 的值,值不同,放弃更新,通过报错、重试等方式进行下一步处理。

    # 此时 version = 2
    update tb_seckill_voucher set stock = stock - 1 and version = version + 1 where id = 2 and version = 1;
    

CAS 解决超卖问题。(假设 stock = 1)

  • t1:线程 A 获取的 stock 为 1。

  • t2:线程 B 获取的 stock 为 1。

  • t3:线程 A 更新时比对 stock 的值,值未发生变化,提交更新。

    update tb_seckill_voucher set stock = stock - 1 where id = 2 and stock = 1;
    
  • t4:线程 B 更新时比对 stock 的值,值已经发生了变化,放弃更新,通过报错、重试等方式进行下一步处理。

    # 此时 stock = 0
    update tb_seckill_voucher set stock = stock - 1 where id = 2 and stock = 1;
    

6.3.2 CAS 解决超卖问题

方式1:库存尚未不足时就会导致很多线程更新失败,若有 10 个线程查询到的 stock 为100,只要有一个更新成功,stock 值发生了变化,其他线程都会更新失败

// 扣减库存
boolean result = seckillVoucherService.lambdaUpdate()
        .eq(SeckillVoucher::getVoucherId, voucherId)
        .gt(SeckillVoucher::getStock, 0)
        .set(SeckillVoucher::getStock, stock - 1)
        .update();

方式2:只需要让 stock > 0 即可,可以有效的提高线程更新的成功率。

// 扣减库存
boolean result = seckillVoucherService.lambdaUpdate()
        .eq(SeckillVoucher::getVoucherId, voucherId)
        .gt(SeckillVoucher::getStock, 0)
        .set(SeckillVoucher::getStock, stock - 1)
        .update();

6.4 一人一单

6.4.1 增加一人一单逻辑

  1. 发送下单请求,提交优惠券 ID。
  2. 下单前需要判断:秒杀是否开始或结束、库存是否充足
  3. 库存充足:根据优惠券 ID 和用户 ID 查询订单,判断该用户是否购买过该优惠券
  4. 该用户对该优惠券的订单不存在时,扣减库存、创建订单、返回订单 ID。
@Override
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 判断秒杀是否开始或结束、库存是否充足。
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    ThrowUtils.throwIf(seckillVoucher == null, ErrorCode.NOT_FOUND_ERROR);
    LocalDateTime now = LocalDateTime.now();
    ThrowUtils.throwIf(now.isBefore(seckillVoucher.getBeginTime()), ErrorCode.OPERATION_ERROR, "秒杀尚未开始");
    ThrowUtils.throwIf(now.isAfter(seckillVoucher.getEndTime()), ErrorCode.OPERATION_ERROR, "秒杀已经结束");
    ThrowUtils.throwIf(seckillVoucher.getStock() < 1, ErrorCode.OPERATION_ERROR, "库存不足");

    // 下单
    return this.createVoucherOrder(voucherId);
}

/**
 * 下单(超卖 - CAS、一人一单 - synchronized)
 */
@Override
@Transactional
public CommonResult<Long> createVoucherOrder(Long voucherId) {
    // 1. 判断当前用户是否下过单
    Long userId = UserHolder.getUser().getId();
    Integer count = this.lambdaQuery()
            .eq(VoucherOrder::getVoucherId, voucherId)
            .eq(VoucherOrder::getUserId, userId)
            .count();
    ThrowUtils.throwIf(count > 0, ErrorCode.OPERATION_ERROR, "禁止重复下单");

    // 2. 扣减库存
    boolean result = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");

    // 3. 下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setUserId(userId);
    voucherOrder.setId(redisIdWorker.nextId("seckillVoucherOrder"));
    voucherOrder.setVoucherId(voucherId);
    result = this.save(voucherOrder);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");
    return CommonResult.success(voucherOrder.getId());
}

6.4.2 解决并发安全问题

单人下单(一个用户),高并发的情况下:该用户的 10 个线程同时执行到 查询该用户 ID 和秒杀券对应的订单数量,10 个线程查询到的值都为 0,即未下单。于是会出现一个用户下 10 单的情况。

**此处仍需加锁,乐观锁适合更新操作,插入操作需要选择悲观锁。**若直接在方法上添加 synchronized 关键字,会让锁的范围(粒度)过大,导致性能较差。因此,采用 一个用户一把锁 的方式。

一个用户一把锁

  1. 首先,需要保证锁必须是同一把。

    **userId.toString() 获取用户 ID 对应的字符串常量池保证用的锁是同一把。**保证了一个用户即使在高并发的情况下也只能下单一次的同时,不同用户也不会争抢同一把锁,提高性能。(同一个用户的多个线程争抢同一把锁,保证一个线程下单成功,其他线程失败)

    • toString() 方法底层会 new 一个字符串,因此获取到的的字符串是不同的对象。
    • 通过 intern() 方法可以 直接从常量池里获取到与该值相同的字符串常量。同一个用户 ID 相同,userId.toString().itern() 相同。
    @Override
    @Transactional
    public CommonResult<Long> createVoucherOrder(Long voucherId, Integer stock) {
        Long userId = UserHolder.getUser().getId();
      	synchronized(userId.toString().intern()) {
          	...
        }
    }
    
  2. 其次,事务是在方法执行完毕后由 Spring 提交的:开启事务执行方法,抢到锁后执行相关代码,释放锁后才会提交事务。这种方式依然存在并发安全问题,因为锁的范围小了。

    @Override
    @Transactional
    public CommonResult<Long> createVoucherOrder(Long voucherId, Integer stock) {
        Long userId = UserHolder.getUser().getId();
      	// 假设此处有 1 个线程抢到锁并执行同步代码块,还有 9 个线程在等待。
      	synchronized(userId.toString().intern()) {
          	...
        }
      	// 锁释放后才会提交事务,当锁释放的瞬间,又有其他线程抢到了锁。循环往复,还是存在一个用户下多单的问题。
    }
    

    因此,需要扩大锁的范围:将整个方法用 synchronized 包裹起来

    @Override
    public Result seckillVoucher(Long voucherId) {
        ...
    
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
          	// 此处实际上执行的是 `this.createVoucherOrder(voucherId);`,这个 this 指的是当前类
            return createVoucherOrder(voucherId);
        }
    }
    
  3. 最后,方法的调用没有经过动态代理,Spring 的事务是通过动态代理 AOP 实现的,必需使用代理对象调用方法。

    
    <dependency>
        <groupId>org.aspectjgroupId>
        <artifactId>aspectjweaverartifactId>
    dependency>
    
    // 在主启动类上添加该注解(exposeProxy 暴露代理对象)
    @EnableAspectJAutoProxy(exposeProxy = true) 
    
    synchronized (userId.toString().intern()) {
    		// 获取代理 VoucherOrderService 接口的代理对象
    		VoucherOrderService proxy = (VoucherOrderService) AopContext.currentProxy();
    		return proxy.createVoucherOrder(voucherId);
    }
    

终极版

/**
 * VERSION2.0 - 秒杀下单优惠券(通过 synchronized 解决一人一单问题)
 */
@Override
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 判断秒杀是否开始或结束、库存是否充足。
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    ThrowUtils.throwIf(seckillVoucher == null, ErrorCode.NOT_FOUND_ERROR);
    LocalDateTime now = LocalDateTime.now();
    ThrowUtils.throwIf(now.isBefore(seckillVoucher.getBeginTime()), ErrorCode.OPERATION_ERROR, "秒杀尚未开始");
    ThrowUtils.throwIf(now.isAfter(seckillVoucher.getEndTime()), ErrorCode.OPERATION_ERROR, "秒杀已经结束");
    ThrowUtils.throwIf(seckillVoucher.getStock() < 1, ErrorCode.OPERATION_ERROR, "库存不足");

    // 下单
    synchronized (UserHolder.getUser().getId().toString().intern()) {
        // 1. 锁释放后才能提交事务,若释放锁的瞬间其他线程抢占到锁则继续执行,仍然存在一人多单的问题,因此需要扩大锁的范围为整个方法。
        // 2. this 指向当前类而非代理类,Spring 事务通过动态代理 AOP 实现,必需使用代理对象调用方法。
        // 3. 导入 aspectjweaver,在主启动类上添加 @EnableAspectJAutoProxy(exposeProxy = true) 注解。(exposeProxy 暴露代理对象)
        VoucherOrderService voucherOrderService = (VoucherOrderService) AopContext.currentProxy();
        return voucherOrderService.createVoucherOrder(voucherId);
        // return this.createVoucherOrder(voucherId);
    }
}

@Override
@Transactional
public CommonResult<Long> createVoucherOrder(Long voucherId) {
    // 1. 判断当前用户是否下过单
    Long userId = UserHolder.getUser().getId();
    Integer count = this.lambdaQuery()
            .eq(VoucherOrder::getVoucherId, voucherId)
            .eq(VoucherOrder::getUserId, userId)
            .count();
    ThrowUtils.throwIf(count > 0, ErrorCode.OPERATION_ERROR, "禁止重复下单");

    // 2. 扣减库存
    boolean result = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0)
            .update();
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");

    // 3. 下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setUserId(userId);
    voucherOrder.setId(redisIdWorker.nextId("seckillVoucherOrder"));
    voucherOrder.setVoucherId(voucherId);
    result = this.save(voucherOrder);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");
    return CommonResult.success(voucherOrder.getId());
}

6.4.3 集群环境下的并发问题

  1. 将服务启动两份,端口分别为 8081 和 8082,设置 VM Options: -Dserver.port=8082 即可。
  2. 修改 Nginx 的 配置文件,配置反向代理和负载均衡,然后 nginx -s reload Reload 重新加载配置文件。

Nginx 启动时会解析配置文件,得到需要监听的端口和 IP 地址。Nginx 监听的 IP 为 localhost、端口为 80、路径为 /api。发送 localhost:8080/api/** 请求,会被反向代理到 http://backend,在 backend 中配置了 127.0.0.1:8081127.0.0.1:8082,默认采用轮询的负载均衡策略。

http {
		...
    server {
        listen       8080;
        server_name  localhost;
        # 指定前端项目所在的位置
        location / {
            root   /opt/homebrew/var/www/hmdp;
            index  index.html index.htm;
        }
        
        ...
        
        location /api {  
            ...
            # proxy_pass http://127.0.0.1:8081
            proxy_pass http://backend;
        }
    }

		# 配置反向代理和负载均衡,请求会在这两个节点上负载均衡。
    upstream backend {
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
    }  
}

集群环境下的并发安全问题:集群环境下由于部署了多个 Tomcat,每个 Tomcat 中都有属于自己的 JVM

  • 假设在 ServerA 的 Tomcat 内部有两个线程 A 和 B,这两个线程使用的是同一份代码,他们的锁对象是同一个,可以实现互斥。
  • 假设在 ServerB 的 Tomcat 内部有两个线程 C 和 D,这两个线程使用的是同一份代码,他们的锁对象是同一个,可以实现互斥。

ServerA 和 ServerB 的锁对象写的一样(都是 userId.toString().itern()),但是在不同的 JVM 中,不是同一个锁对象。因此,同一个服务器中的线程可以实现互斥,不同服务器中的线程无法实现互斥。导致 synchronized 锁失效,分布式锁 可以解决。

7. 分布式锁

ServerA 和 ServerB 中各自有一个线程监视器,保证锁对象的范围应用于该服务器中的所有线程,从而实现服务器内所有线程的互斥。分布式锁提供的就是一个外部的线程监视器,让锁对象的范围扩大为 多进程可见

分布式锁:满足分布式系统或集群模式下的 多进程可见并互斥的锁。核心思想就是 所有线程都使用同一把锁,让程序串行执行。分布式锁需要满足的条件:

  • 可见行:多个线程都能看到相同的结果。
  • 互斥:分布式锁的最基本条件,为了让程序串行执行。
  • 高可用:保证程序不易崩溃。
  • 高性能:加锁本身会让性能降低,因此需要分布式锁具有较高的加锁性能和释放锁性能。
  • 安全性。

7.1 Redis 实现分布式锁

获取锁:互斥(确保只有一个线程获取到锁);非阻塞(只尝试一次,成功返回 true;失败直接返回 false,不会阻塞等待)。

释放锁:手动释放(删除即可)、超时释放(设置 TTL 时间)。

# 添加锁(NX 互斥、EX 设置 TTL 时间)
SET lock thread1 NX EX 10

# 手动释放锁
DEL lock
public interface DistributedLock {
    /**
     * 获取锁(只有一个线程能够获取到锁)
     * @param timeout   锁的超时时间,过期后自动释放
     * @return          true 代表获取锁成功;false 代表获取锁失败
     */
    boolean tryLock(long timeout);

    /**
     * 释放锁
     */
    void unlock();
}

public class SimpleDistributedLock4Redis implements DistributedLock {
    private static final String KEY_PREFIX = "lock:";
    private final String name;
    private final StringRedisTemplate stringRedisTemplate;

    public SimpleDistributedLockBased4Redis(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeout) {
        String threadId = Thread.currentThread().getId().toString();
        Boolean result = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeout, TimeUnit.SECONDS);
      	// result 是 Boolean 类型,直接返回存在自动拆箱,为防止空指针不直接返回
        return Boolean.TRUE.equals(result);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}
/**
 * VERSION3.0 - 秒杀下单优惠券(通过分布式锁解决一人一单问题)
 */
@Override
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 判断秒杀是否开始或结束、库存是否充足。
    ...

    // 下单
    SimpleDistributedLock4Redis lock = new SimpleDistributedLock4Redis("order:" + UserHolder.getUser().getId(), stringRedisTemplate);
    boolean tryLock = lock.tryLock(TTL_TWO);
    ThrowUtils.throwIf(!tryLock, ErrorCode.OPERATION_ERROR, "禁止重复下单");
    try {
        VoucherOrderService voucherOrderService = (VoucherOrderService) AopContext.currentProxy();
        return voucherOrderService.createVoucherOrder(voucherId);
    } finally {
        lock.unlock();
    }
}

测试:将断点打到 lock.tryLock() 获取锁方法处,发送两次 http://localhost:8080/api/voucher-order/seckill/2 请求,第一次请求打到 8081,第二次请求打到 8082。F8 跳到下一行让 8081 获取到锁,即 tryLock 为 true,此时 F8 跳到下一行让 8082 去获取锁,tryLock 为 false。

7.2 分布式锁的误删问题

误删问题的逻辑说明

# 线程 1 获取到锁后执行业务,碰到了业务阻塞。
setnx lock:order:1 thread01

# 业务阻塞的时间超过了该锁的 TTL 时间,触发锁的超时释放。超时释放后,线程 2 获取到锁并执行业务。
setnx lock:order:1 thread02

# 线程 2 执行业务的过程中,线程 1 的业务执行完毕并且释放锁,但是释放的是线程 2 获取到的锁。(线程 2:你 TM 放我锁是吧!)
del lock:order:1

# 线程 3 获取到锁(此时线程 2 和 3 并行执行业务)
setnx lock:order:1 thread03
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第7张图片

解决方案:在线程释放锁时,判断当前这把锁是否属于自己,如果不属于自己,就不会进行锁的释放(删除)。

# 线程 1 获取到锁后执行业务,碰到了业务阻塞。
setnx lock:order:1 thread01

# 业务阻塞的时间超过了该锁的 TTL 时间,触发锁的超时释放。超时释放后,线程 2 获取到锁并执行业务。
setnx lock:order:1 thread02

# 线程 2 执行业务的过程中,线程 1 的业务执行完毕并且释放锁。但是线程 1 需要判断这把锁是否属于自己,不属于自己就不会释放锁。
# 于是线程 2 一直持有这把锁直到业务执行结束后才会释放,并且在释放时也需要判断当前要释放的锁是否属于自己。
del lock:order:1

# 线程 3 获取到锁并执行业务
setnx lock:order:1 thread03
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第8张图片

基于 Redis 的分布式锁的实现(解决误删问题)

相较于最开始分布式锁的实现,只需要增加一个功能:释放锁时需要判断当前锁是否属于自己。(而集群环境下不同 JVM 中的线程 ID 可能相同,增加一个 UUID 区分不同 JVM)

因此通过分布式锁存入 Redis 中的线程标识包括:UUID + 线程 ID。UUID 用于区分不同服务器中线程 ID 相同的线程,线程 ID 用于区分相同服务器的不同线程。

public class SimpleDistributedLockBasedOnRedis implements DistributedLock {
    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleDistributedLockBasedOnRedis(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";

  	// ID_PREFIX 在当前 JVM 中是不变的,主要用于区分不同 JVM
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    /**
     * 获取锁
     */
    @Override
    public boolean tryLock(long timeoutSeconds) {
      	// UUID 用于区分不同服务器中线程 ID 相同的线程;线程 ID 用于区分同一个服务器中的线程。
        String threadIdentifier = ID_PREFIX + Thread.currentThread().getId();
        Boolean isSucceeded = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadIdentifier, timeoutSeconds, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(isSucceeded);
    }

    /**
     * 释放锁(释放锁前通过判断 Redis 中的线程标识与当前线程的线程标识是否一致,解决误删问题)
     */
    @Override
    public void unlock() {
        // UUID 用于区分不同服务器中线程 ID 相同的线程;线程 ID 用于区分同一个服务器中的线程。
        String threadIdentifier = THREAD_PREFIX + Thread.currentThread().getId();
        String threadIdentifierFromRedis = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 比较 Redis 中的线程标识与当前的线程标识是否一致
        if (!StrUtil.equals(threadIdentifier, threadIdentifierFromRedis)) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "释放锁失败");
        }
        // 释放锁标识
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

测试:启动 8081 和 8082,在获取锁(tryLock)和释放锁(unlock)处打上断点,发送两次 http://localhost:8080/api/voucher-order/seckill/2 请求。

  1. 8081 先获取到锁,lock:用户ID 作为 Key、线程标识作为 Value 被存入 Redis 中,然后在 Redis 中将锁删除(模拟业务阻塞后超时释放锁)。
  2. 8082 获取到锁,lock:用户ID 作为 Key、线程标识作为 Value 被存入 Redis 中。
  3. 跳到 8081 释放锁处的断点,8081 最初存入 Redis 中的线程标识为和当前 Redis 中存储的线程标识不同,8081 无法释放锁。
  4. 8082 正常执行完毕后释放锁。

7.3 Lua 脚本解决原子性问题

分布式锁的原子性问题

  • 线程 1 获取到锁并执行完业务,判断锁标识一致后释放锁,释放锁的过程中阻塞,导致锁没有释放成功,并且阻塞的时间超过了锁的 TTL 释放,导致锁自动释放
  • 此时线程 2 获取到锁,执行业务;在线程 2 执行业务的过程中,线程 1 完成释放锁操作。
  • 之后,线程 3 获取到锁,执行业务,又一次导致此时有两个线程同时在并行执行业务。

因此,需要保证 unlock() 方法的原子性,即判断线程标识的一致性和释放锁这两个操作的原子性。

Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保 Redis 多条命令执行时的原子性。

调用函数语法格式redis.call('命令名称', 'key', '其他参数', ...)

# 先执行 SET name Anna,再执行 GET name,最后返回
redis.call('set', 'name', 'Anna')
local name = redis.call('get', 'name')
return name

编写完脚本后,调用脚本EVAL script numkeys key [key ...] arg [arg ...]

注意:调用脚本时可以向 Lua 脚本传递参数。Key 类型的参数会放入 KEYS 数组,其他参数会放入 ARGV 数组,通过 KEYS 数组和 ARGV 数组获取参数。Lua 的数组下标从 1 开始。

# 向 Lua 脚本传递参数:name ==> KEYS[1]、Annabelle ==> ARGV[1]
# 1:代表需要的 Key 类型的参数为 1 个
> EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Annabelle
OK
> get name
"Annabelle"

Lua 脚本的编写

  1. 获取 Redis 中的线程标识。
  2. 判断是否与最初存入的线程标识一致,一致则释放锁(删除)。
-- KEYS[1]:锁的 Key
-- ARGV[1]:Redis 中的线程标识

-- 比较 Redis 中的线程标识与当前线程的线程标识是否一致,一致则释放锁。
if ((redis.call("get", KEYS[1])) == ARGV[1]) then
    return redis.call("del", KEYS[1]);
end
return 0

通过 RedisTemplate 中的 execute() 方法调用 Lua 脚本

public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
	return scriptExecutor.execute(script, keys, args);
}
private static final DefaultRedisScript<Long> SCRIPT;
static {
    SCRIPT = new DefaultRedisScript<>();
    SCRIPT.setLocation(new ClassPathResource("Unlock.lua"));
    SCRIPT.setResultType(Long.class);
}

/**
 * 释放锁前通过判断 Redis 中的线程标识与当前线程的线程标识是否一致,解决误删问题,并通过 Lua 脚本保证释放锁操作的原子性。
 */
@Override
public void unlock() {
    // 调用 Lua 脚本
    stringRedisTemplate.execute(
            SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),   // KEYS[1]
            THREAD_PREFIX + "-" + Thread.currentThread().getId()    // ARGV[1]
    );
}

7.4 总结

集群环境下在不同的 JVM 中,锁对象即使写法一样,但是不是同一个锁对象。同一个服务器中的线程可以实现互斥,不同服务器中的线程无法实现互斥。导致 synchronized 锁失效。

分布式锁:满足分布式系统或集群模式下的 多进程可见并互斥的锁。核心思想就是 所有线程都使用同一把锁,让程序串行执行

  1. 利用 SET key NX EX 获取锁并设设置 TTL 时间。
  2. 获取锁时存入 Redis 中的 Value 若使用线程 ID,不同服务器的线程 ID 可能相同。存入 Redis 中的 Value 为 UUID-线程ID,线程 ID 区分同一个服务器中的不同线程,UUID 区分不同服务器。
  3. 释放锁前通过判断 Redis 中的线程标识与当前线程的线程标识是否一致,解决误删问题。
  4. 通过 Lua 脚本保证释放锁的原子性(判断和删除)。

8. Redisson 分布式锁

8.1 Redisson 分布式锁的使用

基于 SETNX 实现的分布式锁存在以下问题

  • 不可重入:同一个线程无法多次获取同一把锁。可重入锁的意义在于防止死锁synchronizedLock 锁都是可重入的。

  • 不可重试:目前的分布式锁只能尝试一次,合理的情况应该是一个线程在获取锁失败后,能够再次尝试获取锁。

  • 超时释放:业务执行耗时较长,TTL 时间的设置太短,会导致锁的释放,存在安全隐患。

  • 主从一致性:为了提高 Redis 的可用性,一般会搭建一个主从集群:向主节点写数据时,主节点异步的将数据同步给从节点

    • 一个线程获取到锁,获取锁需要向 Redis 中进行写操作,也就是需要将锁的信息写入到主节点。
    • 若主节点在将写入的锁的信息同步到从节点之前宕机:会选择一个从节点作为主节点,但此时该从节点中没有存储锁信息,会导致其他线程能够获取到锁。

Redisson 是一个在 Redis 基础上实现的分布式工具集合(Redisson 实现了 可重入、可重试、超时续约、主从一致)

导入依赖并配置 Redisson 客户端后,使用 Redisson 提供的分布式锁

<dependency>
    <groupId>org.redissongroupId>
    <artifactId>redissonartifactId>
    <version>3.16.8version>
dependency>
@Configuration
public class RedisConfiguration {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // Redis 地址和密码(useSingleServer 单节点,useClusterServers 集群)
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("root");
        // 创建客户端
        return Redisson.create(config);
    }
}
@Resource
private RedissonClient redissonClient;

private RLock lock;

@BeforeEach
void beforeTestMethod() {
  	// 创建锁对象并指定名称(可重入锁)
    lock = redissonClient.getLock("aLock");
}

@Test
void testRedissonLock() throws InterruptedException {
    /**
     * 尝试获取锁
     * waitTime:获取锁失败后的最大等待时间,也就是在获取锁失败后 n 秒内会重试获取锁,n 秒内依然未获取到锁后才会返回 false(默认为 -1,不重试)
     * leaseTime:锁的自动释放时间
     * unit:时间单位
     */
    boolean tryLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
    if (BooleanUtil.isTrue(tryLock)) {
        try {
            System.out.println("执行业务..");
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}

@Test
void m1() {
    boolean tryLock = lock.tryLock();
    try {
        if (tryLock) {
            System.out.println("m1 tryLock...");
            m2();
        }
    } finally {
        lock.unlock();
        System.out.println("m1 unlock...");
    }
}

@Test
void m2() {
    boolean tryLock = lock.tryLock();
    try {
        if (tryLock) {
            System.out.println("m2 tryLock...");
        }
    } finally {
        lock.unlock();
        System.out.println("m2 unlock...");
    }
}
// m1 tryLock...
// m2 tryLock...
// m2 unlock...
// m1 unlock...

使用 Redisson 提供的分布式锁

/**
 * VERSION4.0 - 秒杀下单优惠券(通过 Redisson 分布式锁解决一人一单问题)
 */
@Override
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 判断秒杀是否开始或结束、库存是否充足。
    ...
    // 下单
    // SimpleDistributedLock4Redis lock = new SimpleDistributedLock4Redis("order:" + UserHolder.getUser().getId(), stringRedisTemplate);
		// boolean tryLock = lock.tryLock(TTL_TWO);
    RLock lock = redissonClient.getLock("seckillVoucherOrder");
    boolean tryLock = lock.tryLock();
    ThrowUtils.throwIf(!tryLock, ErrorCode.OPERATION_ERROR, "禁止重复下单");
    try {
        VoucherOrderService voucherOrderService = (VoucherOrderService) AopContext.currentProxy();
        return voucherOrderService.createVoucherOrder(voucherId);
    } finally {
        lock.unlock();
    }
}

8.2 可重入原理

可重入锁的意义在于防止死锁synchronizedLock 锁都是可重入的。可重入锁借助于一个 state 变量来记录重入的状态。(采用 Hash 结构存储锁:Key 存储锁名称、Field 存储线程标识、Value 存储 state)

假设在方法 A 中调用方法 B,两个方法的执行都需要先获取到锁:

  • 不可重入锁:A 先获取到锁,A 调用 B(也需要锁),但是此时 A 未释放锁,造成死锁。
  • 可重入锁:A 调用 B 时,A 中获取到锁后 state = 1,A 调用 B 并记录重入状态 state = 2。B 执行完后修改重入状态为 state = 1,A 执行完后 state = 0,此时可以释放锁。

重入流程

  1. 所有线程获取锁时必须先判断锁是否已经存在。
  2. 不存在则直接获取锁并执行业务代码(将线程标识存入 Redis 并设置 TTL 时间)。释放锁时判断锁是否属于自己,如果不属于自己就证明锁已释放。(锁超时释放,被其他线程获取)
  3. 若锁已经存在:获取锁时需要判断当前要获取锁的和持有锁的是否为同一个线程,如果是就进行重入
    1. 重入:锁计数器 +1 并重置 TTL 时间后,执行业务。
    2. 释放:判断当前锁是否属于该线程,属于则计数器 -1 并判断计数器是否为 0。
      1. 计数器不为 0:重置锁的 TTL 时间,执行业务、判断当前锁是否属于该线程、属于则判断计数器是否为 0。
      2. 计数器为 0:释放锁。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第9张图片

Redisson 获取锁的 Lua 脚本

-- 使用 Hash 存储锁:key-锁的名称、field-线程标识、value-重入次数
-- 锁的名称
local key = KEYS[1];
-- 线程标识
local threadIdentifier = ARGV[1];
-- 锁的自动释放时间
local autoReleaseTime = ARGV[2];

-- 如果 Redis 中没有锁,则获取锁并设置 TTL 时间
if (redis.call('exists', key) == 0) then
    redis.call('hset', key, threadIdentifier, '1');
    redis.call('expire', key, autoReleaseTime);
    return 1;
end

-- 如果 Redis 中有锁,并且该锁属于当前线程,则重入次数 + 1
if (redis.call('hexists', key, threadIdentifier) == 1) then
    redis.call('hincrby', key, threadIdentifier, '1');
    redis.call('expire', key, autoReleaseTime);
    return 1;
end

-- 代码执行到此处说明:Redis 中的锁不属于当前线程
return 0;

Redisson 释放锁的 Lua 脚本

-- 使用 Hash 存储锁:key-锁的名称、field-线程标识、value-重入次数
-- 锁的名称
local key = KEYS[1];
-- 线程标识
local threadIdentifier = ARGV[1];
-- 锁的自动释放时间
local autoReleaseTime = ARGV[2];

-- 判断 Redis 中的锁是否属于当前线程(不属于当前线程则代表可能超时释放了,该锁已经被其他线程获取)
if (redis.call('hexists', key, threadIdentifier) == 0) then
    return nil;
end

-- 锁属于当前线程,重入次数 -1 后:判断重入次数是否为 0;为 0 则直接删除,否则超时续约。
local count = redis.call('hincrby', key, threadIdentifier, -1);
if (count > 0) then
    redis.call('expire', key, autoReleaseTime4Lock);
    return nil;
else
    redis.call('del', key);
    return nil;
end

注意:在 Redisson 代码中,通过 redis.call('del', KEYS[1]) 释放锁后,会通过 redis.call('publish', KEYS[2], ARGV[1]) 发布一个锁释放的消息,唤醒其他等待获取锁的线程,也就是下面可重试原理中的一部分。

8.3 可重试原理

/**
 * 尝试获取锁
 * waitTime:获取锁失败后的最大等待时间,也就是在获取锁失败后 n 秒内会重试获取锁,n 秒内依然未获取到锁后才会返回 false(默认为 -1,不重试)
 * unit:时间单位
 */
boolean tryLock = lock.tryLock(1, TimeUnit.SECONDS);
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    long time = unit.toMillis(waitTime);
    long current = System.currentTimeMillis();
    long threadId = Thread.currentThread().getId();
  	// 尝试获取锁并设置锁的 TTL 时间为 30 秒。
  	// 锁不存在则获取锁并且 `state + 1` 后返回 nil;存在则重入锁并且 `state + 1` 后返回 nil;获取锁失败则返回 TTL 时间。
    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    // ttl 为 null 代表成功获取到锁,直接返回 true
    if (ttl == null) {
        return true;
    }
    
  	// 当前时间 - 获取到锁之前的时间 = 获取锁过程消耗的时间(time = time - 获取锁过程消耗的时间)
    time -= System.currentTimeMillis() - current;
  	// 获取锁的时间超过了 获取锁失败后的最大等待时间,则直接返回 false,表示获取锁失败。否则,重试获取锁
    if (time <= 0) {
        acquireFailed(waitTime, unit, threadId);
        return false;
    }
    
    current = System.currentTimeMillis();
  	// 获取锁失败后不会立即重试,而是订阅了 “其他线程释放锁的消息”。
  	// 释放锁的代码中有一个 `redis.call('publish', ...)` 脚本,表示在释放锁后发布锁释放消息。
    CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
  	// 在 最大等待时间 内,未接收到任何锁释放消息,返回 false,获取锁失败。
    ...
		acquireFailed(waitTime, unit, threadId);
    return false;

    try {
      	// 等待到锁释放的消息后,再次判断 等待期间 最大等待时间 是否还有剩余,没有则返回 false,获取锁失败。
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
    
      	// 最大等待时间 依然大于 0,则开始第一次重试获取锁(通过循环,不断的重试、等待,前提是 最大等待时间 有剩余,否则返回 false,获取锁失败)
        while (true) {
            long currentTime = System.currentTimeMillis();
            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                return true;
            }
          	// 循环中还有个 ttl 与 time 的判断:ttl < time 则等待 ttl 秒,反之等待 time 秒。
          	...
        }
    } finally {
        unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);
    }
}

RedissonLock#tryLock 方法中调用 tryAcquire 方法:尝试获取锁,并设置锁的 TTL 时间为 30 秒。

  • 锁不存在则获取锁并且 state + 1 后返回 nil。
  • 锁存在则重入锁并且 state + 1 后返回 nil。
  • 获取锁失败则返回 TTL 时间。

重试原理

  1. time 减去获取锁过程中消耗的时间,若 time < 0,返回 false,获取锁失败。
  2. time > 0,不会立即重试,而是订阅一个 其他线程释放锁的消息。(释放锁的代码中有一个 redis.call('publish', ...) 脚本,表示在释放锁后发布锁释放消息)
  3. 订阅完后再次判断在等待过程中 time 是否大于 0,若小于 0 则直接返回 false,获取锁失败。
  4. 等待到锁的释放后,再次判断等待过程中 time 是否大于 0,若小于 0 则直接返回 false,获取锁失败。若大于 0,则开始重试获取锁。
  5. 重试获取锁的代码是一个循环:首先进行一次重试,获取成功则返回 true;否则再次使用 time 减去获取锁过程中消耗的时间并判断 time 是否大于 0。大于 0,则判断 ttltime 的值,谁更小则等待更小的那一个时间。
  6. 循环直到获取到锁(timettl 不小于 0),或者 timettl 小于 0 则表示获取锁失败。

8.4 超时续约原理(WatchDog)

获取到锁后,开启一个线程对锁的 TTL 时间进行续约(也就是看门狗线程)

  • 看门狗线程在 TTL / 3 秒后执行续约(TTL 默认为 30),将 TTL 重置为 30 秒。续约成功则递归调用自己,TimerTask 并且在 10s 后触发;循环往复,不停的续约。(保证锁永不过期)
  • unlock 释放锁方法中有一个方法用于取消看门狗线程的定时任务。

注意:只有 leaseTime 为 -1 时,才会开启 WatchDog 线程执行定时任务。也就是获取锁时不指定 TTL 时间 —— lock.tryLock(1, TimeUnit.SECONDS);

8.5 主从一致原理(MultiLock)

为了提高 Redis 的可用性,一般会搭建一个主从集群:向主节点写数据时,主节点异步的将数据同步给从节点

  • 一个线程获取到锁,获取锁需要向 Redis 中进行写操作,也就是需要将锁的信息写入到主节点
  • 若主节点在将写入的锁的信息同步到从节点之前宕机:会选择一个从节点作为主节点,但此时该从节点中没有存储锁信息,会导致其他线程能够获取到锁。

为解决上述问题,Redisson 提出了 MutiLock 锁获取锁时需要将数据写入到每一个节点中,全部写入成才算获取锁成功

搭建主从集群,获取锁时将数据写入到每一个主节点中:若某个主节点宕机并且尚未完成主从同步,从节点会变成新的主节点(当前节点没有存入锁)。此时某个线程获取锁是无法获取到的,因为获取锁时必须将数据写入到所有的主节点才能成功获取到。

Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第10张图片

MultiLock 的实现

搭建三个 Redis 节点并配置 Redisson 客户端

@Configuration
public class RedisConfiguration {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        // Redis 地址和密码(useSingleServer 单节点,useClusterServers 集群)
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("root");
        // 创建客户端
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClientTwo() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6380").setPassword("root");
        return Redisson.create(config);
    }

    @Bean
    public RedissonClient redissonClientThree() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6381").setPassword("root");
        return Redisson.create(config);
    }
}
@Slf4j
@SpringBootTest
public class RedissonTest {
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private RedissonClient redissonClientTwo;
    @Resource
    private RedissonClient redissonClientThree;

    private RLock multiLock;

    @BeforeEach
    void setUp() {
        RLock lockOne = redissonClient.getLock("aLock");
        RLock lockTwo = redissonClientTwo.getLock("aLock");
        RLock lockThree = redissonClientThree.getLock("aLock");
        // 创建联锁 MultiLock 
        RLock multiLock = redissonClient.getMultiLock(lockOne, lockTwo, lockThree);
    }

    @Test
    void m1() throws InterruptedException {
        boolean isLocked = multiLock.tryLock(1L, TimeUnit.SECONDS);
        if (!isLocked) {
            log.error("Fail To Get Lock...");
            return;
        }
        try {
            System.out.println("m1 tryLock...");
            m2();
        } finally {
            multiLock.unlock();
						System.out.println("m1 unlock...");
        }
    }

    @Test
    void m2() throws InterruptedException {
        boolean isLocked = multiLock.tryLock(1L, TimeUnit.SECONDS);
        if (!isLocked) {
            log.error("Fail To Get Lock...");
            return;
        }
        try {
            System.out.println("m2 tryLock...");
        } finally {
            multiLock.unlock();
          	System.out.println("m2 unlock...");
        }
    }
}
  • 执行到 m1m2tryLock 处,Redis 集群中的三个节点中都存储了 Hash 结构的锁数据,并且 state 分别为 1 和 2。
  • 执行到 m2unlock 处,Redis 集群中的三个节点中都存储了 Hash 结构的锁数据,并且 state 为 1。
  • 执行到 m1unlock 处,Redis 集群中的三个节点中的锁数据都被删除了。

8.6 总结

  1. 不可重入的 Redis 分布式锁
    • 原理:利用 setnx 的互斥性、ex 避免死锁、释放锁时判断线程标识避免误删
    • 缺陷:不可重入、不可重试、业务未执行完就 超时释放
  2. 可重入的 Redis 分布式锁
    • 原理:利用 Hash 结构,记录线程标识和重入次数、利用 WatchDog 延续锁的超时时间、利用 Pub/Sub 实现获取锁失败的重试机制
    • 缺陷:Redis 宕机引起锁失效问题
  3. Redisson 的 MultiLock
    • 原理:多个独立的 Redis 节点,获取锁时必须将数据写入到所有节点才算获取所成功
    • 缺陷:运维成本高、实现复杂。

9. 优化秒杀

用户发送请求到 Nginx,Nginx 访问 Tomcat,Tomcat 中的程序串行执行:

  1. 查询优惠券
  2. 查询到优惠券后,判断秒杀是否开始或结束、库存是否充足。
  3. 调用创建订单的方法,该方法中首先就是 查询订单
  4. 查询到订单后,判断当前用户是否下单过。(校验一人一单)
  5. 减库存
  6. 创建订单

以上每一步操作都是串行执行的(按照代码顺序从上到下),并且 1、3、5、6 的操作都需要与数据库进行交互,从而导致程序执行的很慢。

秒杀优化方案

新增秒杀券的同时将秒杀券信息保存到 Redis 中,然后将 2、4 中逻辑判断的操作放到 Redis 中:优惠券库存充足并且该用户未下过单。

2、4 中的逻辑判断执行成功就代表该用户可以下单,直接返回下单成功给用户,然后再开启一个线程执行耗时较久的下单操作

秒杀优化的实现思路

秒杀券库存使用 String 存储(Key - 优惠券标识,Value - 库存),秒杀券的订单使用 Set 存储(Key - 订单标识,Value - 用户 ID)。

  1. 新增秒杀券的同时,将秒杀券信息保存到 Redis 中。
  2. 基于 Lua 脚本,判断库存是否充足、校验一人一单
    • 判断库存是否充足,不充足则返回 1。
    • 判断用户是否下过单,下过单返回 2。
    • 扣减库存并将用户 ID 存入 Set 集合,返回 0。
  3. 若 Lua 脚本的执行结果为 0 则代表用户有购买资格:将订单信息存入阻塞队列并返回订单 ID。(此时返回给用户的是下单成功,之后就是异步操作数据库)
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单。

秒杀优化的代码实现

新增秒杀券的同时将其存储到 Redis 中

/**
 * 新增秒杀券的同时将其存储到 Redis,同时还需要在优惠券表中新增优惠券
 * @param voucher 优惠券信息
 * @return 优惠券 id
 */
@PostMapping("/seckill")
public CommonResult<Long> addSeckillVoucher(@RequestBody Voucher voucher) {
    return voucherService.addSeckillVoucher(voucher);
}
@Override
@Transactional
public CommonResult<Long> addSeckillVoucher(Voucher voucher) {
    // 新增优惠券
    boolean result = this.save(voucher);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "新增优惠券失败");

    // 新增秒杀券
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    result = seckillVoucherService.save(seckillVoucher);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "新增秒杀券失败");

    // 将秒杀券存储到 Redis(SECKILL_STOCK_KEY = "seckill:stock:")
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
    return CommonResult.success(voucher.getId());
}

基于 Lua 脚本,判断秒杀库存是否充足、用户是否下过单。

local voucherId = ARGV[1];
local userId = ARGV[2];

-- Lua 中的拼接使用的是两个点
local stockKey = "seckill:stock:" .. voucherId;
local orderKey = "seckill:order:" .. voucherId;

-- 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) < 1) then
    return 1;
end;

-- 判断用户是否下过单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2;
end;

-- 执行到此处说明库存充足且用户未下过单:扣减库存并将用户 ID 存入 Set
redis.call('incryby', stockKey, -1);
redis.call('sadd', orderKey, userId);
return 0;
  1. 获取 Lua 脚本的执行结果,判断用户是否有购买资格。
  2. 如果有购买资格,将订单信息存入阻塞队列并返回订单 ID。
  3. 开启异步线程:不断从阻塞队列中获取消息,获取到消息后获取锁,获取锁后调用下单方法。
// Lua 脚本
private static final DefaultRedisScript<Long> SCRIPT;
static {
    SCRIPT = new DefaultRedisScript<>();
    SCRIPT.setLocation(new ClassPathResource("SeckillVoucher.lua"));
    SCRIPT.setResultType(Long.class);
}

// 阻塞队列:一个线程尝试从队列中获取元素时,若队列中没有元素线程就会被阻塞,直到队列中有元素时线程才会被唤醒并且去获取元素。
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

// 代理对象
VoucherOrderService proxy;

/**
 * VERSION5.0 - 秒杀下单优惠券(通过 Redisson 解决一人一单问题;通过 Lua 脚本判断用户有购买资格后直接返回,异步下单)
 */
@Override
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 1. 判断秒杀是否开始或结束
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    ThrowUtils.throwIf(seckillVoucher == null, ErrorCode.NOT_FOUND_ERROR);
    LocalDateTime now = LocalDateTime.now();
    ThrowUtils.throwIf(now.isBefore(seckillVoucher.getBeginTime()), ErrorCode.OPERATION_ERROR, "秒杀尚未开始");
    ThrowUtils.throwIf(now.isAfter(seckillVoucher.getEndTime()), ErrorCode.OPERATION_ERROR, "秒杀已经结束");

    // 2. 判断用户是否有购买资格 —— 库存充足且该用户未下过单,即 Lua 脚本的执行结果为 0。
    Long userId = UserHolder.getUser().getId();
    Long executeResult = stringRedisTemplate.execute(
            SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString()
    );
    int result = executeResult.intValue();
    if (result != 0) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, result == 1 ? "库存不足" : "请勿重复下单");
    }

    // 3. 将下单信息保存到阻塞队列中,让线程异步的从队列中获取下单信息并操作数据库
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setUserId(userId);
    voucherOrder.setId(redisIdWorker.nextId("seckillVoucherOrder"));
    voucherOrder.setVoucherId(voucherId);
    orderTasks.add(voucherOrder);

    // 4. 获取代理对象后赋值给 proxy
    proxy = (VoucherOrderService) AopContext.currentProxy();

    // 5. 直接返回订单号告诉用户下单成功,业务结束。(异步操作数据库下单)
    return CommonResult.success(voucherOrder.getId());
}

// 线程池
private static final ExecutorService ES = Executors.newSingleThreadExecutor();

@PostConstruct
public void init() {
    ES.submit(new VoucherOrderHandler());
}

/**
 * 异步任务
 */
private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                // 获取队列中的消息并操作数据库下单
                VoucherOrder voucherOrder = orderTasks.take();
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
                throw new BusinessException(ErrorCode.OPERATION_ERROR, "下单失败");
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        // userId 存储在 ThreadLocal 中、代理对象在主线程中,在新开启的线程中无法获取到这些信息。
        Long userId = voucherOrder.getUserId();
        RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId);
        boolean isLocked = lock.tryLock();
        if (!isLocked) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取锁失败");
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            lock.unlock();
        }
    }
}

/**
 * 异步下单
 */
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) {
    // 1. 再次判断当前用户是否下过单
    Long voucherId = voucherOrder.getId();
    Long userId = voucherOrder.getUserId();
    Integer count = this.lambdaQuery()
            .eq(VoucherOrder::getVoucherId, voucherId)
            .eq(VoucherOrder::getUserId, userId)
            .count();
    ThrowUtils.throwIf(count > 0, ErrorCode.OPERATION_ERROR, "禁止重复下单");

    // 2. 扣减库存
    boolean result = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0)
            .update();
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");

    // 3. 下单
    result = this.save(voucherOrder);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "下单失败");
}

10. Redis 消息队列

消息队列(MQ, Message Queue)消息传输过程中保存消息的容器

参与消息传递的双方称为 生产者消费者生产者负责发送消息,消费者负责处理消息。队列 Queue 是一种先进先出(FIFO)的数据结构,所以消费消息时也是按照顺序来消费的。

Redis 提供了三种消息队列的实现方式:List 结构、Pub/Sub、Stream

10.1 基于 List 结构

Redis 的 List 数据结构是一个双向链表,可以通过 LPUSH + RPOPRPUSH + LPOP 实现。

编写消费逻辑时,需要不断地从队列中拉取消息进行处理,一般是一个 死循环。当队列中没有消息的时候,消费者执行 RPOP 或 LPOP 时会返回 NULL,并不会像阻塞队列那样阻塞并等待消息。而是不断的拉取消息,从而造成 CPU空转

通过 BLPOPBRPOP 可以实现阻塞效果。(B means Block)该命令还支持传入一个超时时间:0 表示不设置超时,直到有新消息才返回;否则在指定的超时时间后仍未获取到消息后返回 NULL。

  • 优点
    • 利用 Redis 存储,不受限于 JVM 的内存上限。
    • 基于 Redis 的持久化机制,数据安全性有保证。
    • 可以满足有序性(先进先出 FIFO)。
  • 缺点
    • 不支持重复消费,只支持单消费者(阅后即焚):消费者拉取消息消费后,将消息就从 List 中删除,无法被其他消费者再次消费。类似 RabbitMQ 工作模式中的 SimpleQeueu(一对一)和 WorkQueue(一对多,多个消费者轮询获取消息并消费)。
    • 无法避免消息丢失:消费者拉取到消息后,如果发生异常宕机,该消息就丢失了。

10.2 基于 PubSub

PubSub(发布订阅)可以实现 重复消费,消费者可以订阅一个或多个 channel,生产者向对应 channel 发送消息后,所有订阅者都能收到相关消息。

Pub/Sub 的实现十分简单,不基于任何数据结构,也没有任何的数据存储;只是单纯的为生产者和消费者建立 数据转发通道,将符合规则的数据从一端发到另一端。

Redis 提供了 PUBLISHSUBSCRIBE 命令实现发布和订阅。

  • PUBLISH channel msg :向一个 channel 发送消息。
  • SUBSCRIBE channel [channel] :订阅一个或多个 channel。
  • PSUBSCRIBE pattern [pattern] :订阅与 pattern 格式匹配的所有 channel。? 匹配一个字符,* 匹配多个字符,[] 匹配括号内的字符。
# 两个消费者分别订阅 `queue1` 和 `queue.*`,两个消费者都会被堵塞住,等待新消息的到来。
> subscribe queue1
> psubscribe queue.*

# 发布消息
> PUBLISH queue1 msg1		# 两个消费者都能获取到消息
> PUBLISH queue2 msg2		# 订阅 queue.* 的消费者能获取到消息

消费者必须在生产者发布消息之前订阅队列,否则消息会丢失

优点 :支持 多生产者、多消费者处理消息

缺点 :不支持数据持久化,无法避免消息丢失,消息堆积也会导致数据丢失。

10.3 基于 Stream

Stream 是 Redis5.0 引入的一种新的 数据类型,可以实现一个功能完善的消息队列。通过 XADD 发布消息、XREAD 读取消息

发送消息

XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...]

  • key :队列名称。
  • [NOMKSTREAM] :若队列不存在,是否自动创建队列,默认自动创建(不用管)。
  • [MAXLEN|MINID [=|~] threshold [LIMIT count]] :设置消息队列的最大消息数量(不用管)。
  • *|ID :消息的唯一 ID,* 代表由 Redis 自动生成。
  • field value [field value ...] :发送到队列中的消息,格式为多个键值对。
# 创建名为 queue 的队列并向该队列送一个内容为 {name: Jack, age: 21} 的消息,ID 由 Redis 自动生成。
XADD queue * name Jack age 21

> XADD queue * name Jack
> XADD queue * name Rose

读取消息

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

  • [COUNT count] :每次读取消息的最大数量。
  • [BLOCK milliseconds] :当没有消息时,是否阻塞和阻塞时长。
  • STREAMS key [key ...] :从哪个队列读取消息,Key 就是队列名。
  • ID [ID ...] :起始ID,只返回大于该 ID 的消息;0 代表从第一个消息开始,$ 代表从最新的消息开始(多条消息在 Queue 中,读取到的也是最新的一条,存在漏读)。
# 从 0 开始读取 queue 队列中的 1 个消息
> XREAD COUNT 1 STREAMS queue 0

# 从 1 开始读取 queue 队列中的 2 个消息
> XREAD COUNT 2 STREAMS queue 1

# 采用阻塞的方式读取最新消息
> XREAD COUNT 1 STREAMS queue $

STREAM 消息队列的特点

  • 永久保存在队列中,消息可回溯。
  • 一个消息可以被多个消费者读取。
  • 可以阻塞读取。
  • 有消息漏读风险。

10.3.1 Stream 的消费者组模式

消费者组(Consumer Group)将多个消费者划分到一个组中,监听同一个队列。具备以下特点:

  • 消息分流:队列中的消息会分流给组内不同的消费者,消费者之间是竞争关系,从而加快消息处理的速度并且避免消息堆积。
  • 消息标示:消费者组会维护一个标示(记录最后一个被处理的消息),即使消费者宕机重启,还会从标示之后读取消息,确保每一个消息都会被消费。(解决漏读问题)
  • 消息确认:消费者获取消息后,消息处于 pending 状态并被存入到 pending-list。处理完成后通过 XACK 命令确认消息,标记消息为已处理,才会从 pending-list 中移除。(保证获取到消息后至少消费该消息一次)

创建消费组

XGROUP CREATE key groupName ID [MKSTREAM]

  • key :队列名称。

  • groupName :消费组名称。

  • ID :起始 ID 标识,0 代表队列中的第一个消息,$ 代表队列中的最新消息。

  • [MKSTREAM] :队列中不存在时自动创建队列。

# 删除指定的消费组
XGROUP DESTROY key groupName

# 为指定的消费组添加消费者
XGROUP CREATECONSUMER key groupName consumerName

# 删除消费组中的指定消费者
XGROUP DELCONSUMER key groupName consumerName

从消费者组中读取消息

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

  • group:消费组名称。
  • consumer:消费者名称,若消费者不存在则自动创建一个消费者。
  • count:本次查询的最大数量。
  • BLOCK milliseconds:当没有消息时的最长等待时间。
  • STREAMS key:指定队列名称。
  • ID:获取消息的起始ID。> 代表从下一个未消费的消息开始(建议使用)。其他则是根据 ID 从 pending-list 中获取已消费但未确认的消息,例如 0,从 pending-list 中的第一个消息开始。

XACK key group ID [ID ...]:确认消息,处理完消息后必须确认消息。

# 发送消息到队列
> XADD queue * name Jack
> xadd queue * name Rose
# 读取队列中的消息
> XREAD COUNT 2 STREAMS queue 0

# 创建消费者组
> XGROUP CREATE queue queueGroup 0

# 从 queueGroup 消费者组中读取消息
# 消费者为 consumerOne(若不存在自动创建)、每次读取 1 条消息、阻塞时间为 2s、从下一个未消费消息开始。
> XREADGROUP GROUP queueGroup consumerOne COUNT 1 BLOCK 2000 STREAMS queue >
) 1) "queue"
   2) 1) "name"
   2) 2) "Jack"

# 消费者为 consumerTwo
> XREADGROUP GROUP queueGroup consumerTwo COUNT 1 BLOCK 2000 STREAMS queue >
) 1) "queue"
   2) 1) "name"
   2) 2) "Rose"

# 消费者为 consumerThree
> XREADGROUP GROUP queueGroup consumerThree COUNT 1 BLOCK 2000 STREAMS queue >
(nil)
(2.04s)

STREAM 消息队列的 XREADGROUP 的特点

  • 永久保存在队列中,消息可回溯。
  • 多消费者争抢消息,加快读取速度。
  • 可以阻塞读取。
  • 没有消息漏读风险。
  • 有消息确认机制,能够保证消息至少被消费一次。

10.3.2 基于 Stream 消息队列实现异步秒杀

创建一个 名为 stream.orders Stream 类型的消息队列

> XGROUP CREATE stream.orders orderGroup 0 MKSTREAM

修改 Lua 脚本,在认定有抢购资格后直接向 stream.orders 中添加消息( voucherId、userId、orderId)。

local seckillVoucherId = ARGV[1];
local userId = ARGV[2];
local orderId = ARGV[3]

local stockKey = "seckill:stock:" .. voucherId
local orderKey = "seckill:order:" .. voucherId

-- 判断库存是否充足
if (redis.call('get', stockKey) < 1) then
    return 1;
end;

-- 判断用户是否下过单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2;
end;

-- 执行到此处说明库存充足且用户未下过单:扣减库存并将用户 ID 存入 Set
redis.call('hincrby', stockKey, -1);
redis.call('sadd', orderKey, userId);
-- 发送消息到 stream.orders 队列中(消息唯一 ID 由 Redis 自动生成)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId);
return 0;
@Override
public CommonResult<Long> seckillVoucher(Long voucherId) {
    // 1. 判断秒杀是否开始或结束
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    ThrowUtils.throwIf(seckillVoucher == null, ErrorCode.NOT_FOUND_ERROR);
    LocalDateTime now = LocalDateTime.now();
    ThrowUtils.throwIf(now.isBefore(seckillVoucher.getBeginTime()), ErrorCode.OPERATION_ERROR, "秒杀尚未开始");
    ThrowUtils.throwIf(now.isAfter(seckillVoucher.getEndTime()), ErrorCode.OPERATION_ERROR, "秒杀已经结束");

    // 2. 判断用户是否有购买资格 —— 库存充足且该用户未下过单,即 Lua 脚本的执行结果为 0。
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");
    Long executeResult = stringRedisTemplate.execute(
            SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );
    int result = executeResult.intValue();
    if (result != 0) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, result == 1 ? "库存不足" : "请勿重复下单");
    }

    // 3. 获取代理对象后赋值给 proxy
    proxy = (VoucherOrderService) AopContext.currentProxy();

    // 4. 直接返回订单号告诉用户下单成功,业务结束。(异步操作数据库下单)
    return CommonResult.success(orderId);
}

项目启动时,开启一个线程任务,尝试获取 stream.orders 中的消息,完成下单。

private class VoucherOrderHandler implements Runnable {
    String queueName = "stream.orders";
    String groupName = "orderGroup";
    String consumerName = "consumerOne";

    @Override
    public void run() {
        while (true) {
            try {
                // 1. 获取消息队列中的订单信息(消费者 consumerOne 从 orderGroup 消费组读取 stream.orders 队列,每次读 1 条消息、阻塞时间 2s、从下一个未消费的消息开始)
                // XREAD GROUP orderGroup consumerOne COUNT 1 BLOCK 2000 STREAMS stream.orders >
                List<MapRecord<String, Object, Object>> readingList = stringRedisTemplate.opsForStream().read(
                        Consumer.from(groupName, consumerName),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                if (CollectionUtil.isEmpty(readingList)) {
                    // 获取失败说明没有消息,继续下一次循环
                    continue;
                }

                // 3. 解析消息中的订单信息(MapRecord:String 代表消息ID;两个 Object 代表消息队列中的 Key-Value)
                MapRecord<String, Object, Object> record = readingList.get(0);
                Map<Object, Object> recordValue = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(recordValue, new VoucherOrder(), true);

                // 4. 下单并确认消息(XACK stream.orders orderGroup id)
                handleVoucherOrder(voucherOrder);
                stringRedisTemplate.opsForStream().acknowledge(groupName, consumerName, record.getId());
            } catch (Exception e) {
                log.error("订单处理异常", e);
                handlePendingMessages();
            }
        }
    }

    private void handlePendingMessages() {
        while (true) {
            try {
                // 1. 获取 pending-list 中的订单信息
                // XREAD GROUP orderGroup consumerOne COUNT 1 STREAM stream.orders 0
                List<MapRecord<String, Object, Object>> readingList = stringRedisTemplate.opsForStream().read(
                        Consumer.from(groupName, consumerName),
                        StreamReadOptions.empty().count(1),
                        StreamOffset.create(queueName, ReadOffset.from("0"))
                );

                if (CollectionUtil.isEmpty(readingList)) {
                    // 获取失败说明没有消息,继续下一次循环
                    continue;
                }

                // 3. 解析消息中的订单信息
                MapRecord<String, Object, Object> record = readingList.get(0);
                Map<Object, Object> recordValue = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(recordValue, new VoucherOrder(), true);
                
                // 4. 下单并确认消息(XACK stream.orders orderGroup id)
                handleVoucherOrder(voucherOrder);
                stringRedisTemplate.opsForStream().acknowledge(queueName, groupName, record.getId());
            } catch (Exception e) {
                log.error("订单处理异常(pending-list)", e);
                try {
                    // 稍微休眠一下再进行循环
                    Thread.sleep(20);
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        // userId 存储在 ThreadLocal 中、代理对象在主线程中,在新开启的线程中无法获取到这些信息。
        Long userId = voucherOrder.getUserId();
        RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId);
        boolean isLocked = lock.tryLock();
        if (!isLocked) {
        		throw new BusinessException(ErrorCode.SYSTEM_ERROR, "获取锁失败");
				}
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            lock.unlock();
        }
    }
}

11. 点赞相关

11.1 发布并查看笔记

tb_blog:笔记表,包括商铺id、用户id、标题、文字、图片、点赞数量、评论数量等字段。

tb_blog_comments:其他用户对笔记的评论。

上传文件 & 发布笔记

/**
 * 上传文件
 */
@PostMapping("/blog")
public CommonResult uploadImage(@RequestParam("file") MultipartFile image) {
    try {
        String originalFilename = image.getOriginalFilename();
        // 生成新文件名
        String suffix = StrUtil.subAfter(originalFilename, ".", true);
        String fileName = UUID.randomUUID().toString(true) + StrUtil.DOT + suffix;
        // 保存文件(SystemConstants.IMAGE_UPLOAD_DIR = "/opt/homebrew/var/www/hmdp/imgs/blogs/")
        image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR + fileName));
        log.debug("文件上传成功,{}", fileName);
        return CommonResult.success(fileName);
    } catch (IOException e) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "文件上传失败");
    }
}

/**
 * 发布笔记
 */
@PostMapping
public CommonResult<Long> publishBlog(@RequestBody Blog blog) {
    ThrowUtils.throwIf(blog == null, ErrorCode.PARAMS_ERROR);
    blog.setUserId(UserHolder.getUser().getId());
    boolean result = blogService.save(blog);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return CommonResult.success(blog.getId());
}

查看笔记详情页

笔记详情页包括笔记信息和用户信息:因此可以在 Blog 实体类中添加两个属性并标注 @TableField(exist = false) 注解,表示该注解标注的属性不属于 tb_blog 表中的字段。

/**
 * 用户图标
 */
@TableField(exist = false)
private String icon;

/**
 * 用户姓名
 */
@TableField(exist = false)
private String name;
/**
 * 根据 id 获取 Blog 详情(包括笔记信息和用户信息)
 */
@GetMapping("/{id}")
public CommonResult<Blog> getBlogDetailById(@PathVariable("id") Long id) {
    return blogService.getBlogDetailById(id);
}
@Override
public CommonResult<Blog> getBlogDetailById(Long id) {
    // 根据 id 查询 Blog
    Blog blog = this.getById(id);
    ThrowUtils.throwIf(blog == null, ErrorCode.NOT_FOUND_ERROR);
    // 设置 Blog 中用户相关的属性值
    this.setUserInfo4Blog(blog);
    return CommonResult.success(blog);
}

/**
 * 设置 Blog 中用户相关的属性值
 */
private void setUserInfo4Blog(Blog blog) {
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

11.2 点赞

  1. 在 Blog 实体类添加一个 isLike 属性,标识是否被当前用户点赞。若当前用户已点赞,则点赞按钮高亮显示,前端通过判断 isLike 的值实现。

    /**
     * 是否点赞
     */
    @TableField(exist = false)
    private Boolean isLike;
    
  2. 修改 getBlogById()getHotBlogs() 方法,新增一个 判断当前登录用户是否点赞过某 Blog。

  3. 用户点赞时判断该用户是否点过赞:未点过赞则点赞数 +1,已点过赞则点赞数 -1。一个用户只能点赞一次,再次点赞则取消点赞。

    • 利用 Redis 的 Set 集合实现。Set 集合存储的 Key 为 BlogId,Value 为 userId。

修改 getBlogById()getHotBlogs() 方法

/**
 * 按照点赞数降序排序,分页查询 Blog(包括笔记信息和用户信息)
 */
@Override
public CommonResult<List<Blog>> getHotBlogs(Integer current) {
    // 分页查询 Blog
    Page<Blog> pageInfo = new Page<>(current, SystemConstants.MAX_PAGE_SIZE);
    Page<Blog> blogPage = this.lambdaQuery()
            .orderByDesc(Blog::getLiked)
            .page(pageInfo);
    List<Blog> records = blogPage.getRecords();
    ThrowUtils.throwIf(records == null, ErrorCode.NOT_FOUND_ERROR);

    records.forEach(blog -> {
        // 设置 Blog 中用户相关的属性值
        this.setUserInfo4Blog(blog);
        // 判断当前登录用户是否点赞过 Blog
        this.isBlogLiked(blog);
    });
    return CommonResult.success(records);
}

/**
 * 根据 id 获取 Blog 详情(包括笔记信息和用户信息)
 */
@Override
public CommonResult<Blog> getBlogDetailById(Long id) {
    // 根据 id 查询 Blog
    Blog blog = this.getById(id);
    ThrowUtils.throwIf(blog == null, ErrorCode.NOT_FOUND_ERROR);

    // 设置 Blog 中用户相关的属性值
    this.setUserInfo4Blog(blog);
    // 判断当前登录用户是否点赞过 Blog
    this.isBlogLiked(blog);
    return CommonResult.success(blog);
}

/**
 * 设置 Blog 中用户相关的属性值
 */
private void setUserInfo4Blog(Blog blog) {
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

/**
 * 判断当前登录用户是否点赞过 Blog
 */
public void isBlogLiked(Blog blog) {
    String blogLikedKey = BLOG_LIKED_KEY + blog.getId();
    UserDTO user = UserHolder.getUser();
    // 未登录时 user 为 null,无需查询当前用户是否点赞过
    if (user == null) {
        return;
    }
    Boolean result = stringRedisTemplate.opsForSet().isMember(blogLikedKey, user.getId().toString());
    // result 是 Boolean 类型,存在自动拆箱,通过 BooleanUtil 防止空指针。
    blog.setIsLike(BooleanUtil.isTrue(result));
}

实现点赞功能

@Override
public CommonResult<String> likeBlog(Long id) {
    // 1. 判断当前用户是否点过赞
    String blogLikedKey = BLOG_LIKED_KEY + id;
    Long userId = UserHolder.getUser().getId();
    Boolean isMember = stringRedisTemplate.opsForSet().isMember(blogLikedKey, userId.toString());
    // 2. 未点过赞
    boolean result = false;
    if (BooleanUtil.isFalse(isMember)) {
        result = this.lambdaUpdate()
                .eq(Blog::getId, id)
                .setSql("liked = liked + 1")
                .update();
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        stringRedisTemplate.opsForSet().add(blogLikedKey, userId.toString());
        return CommonResult.success("点赞成功");
    } else {
        // 3. 点过赞则取消点赞
        result = this.lambdaUpdate()
                .eq(Blog::getId, id)
                .setSql("liked = liked - 1")
                .update();
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        stringRedisTemplate.opsForSet().remove(blogLikedKey, userId.toString());
        return CommonResult.success("取消点赞成功");
    }
}

11.3 点赞排行榜

笔记的详情页面中显示:最早给该笔记点赞的 5 个人。使用 ZSet 实现,Key - BlogId、Value - UserId、Score - 时间戳

/**
 * 判断当前登录用户是否点赞过 Blog
 */
public void isBlogLiked(Blog blog) {
    String blogLikedKey = BLOG_LIKED_KEY + blog.getId();
    UserDTO user = UserHolder.getUser();
    // 未登录时 user 为 null,无需查询当前用户是否点赞过
    if (user == null) {
        return;
    }
    Double score = stringRedisTemplate.opsForZSet().score(blogLikedKey, user.getId().toString());
    // ZSCORE key member:获取 ZSet 中指定元素的 score 值,不存在则代表未点过赞。
    blog.setIsLike(score != null);
}
/**
 * 实现点赞功能
 */
@Override
public CommonResult<String> likeBlog(Long id) {
    // 1. 判断当前用户是否点过赞
    String blogLikedKey = BLOG_LIKED_KEY + id;
    Long userId = UserHolder.getUser().getId();
    // ZSCORE key member:获取 ZSet 中指定元素的 score 值,不存在则代表未点过赞。
    Double score = stringRedisTemplate.opsForZSet().score(blogLikedKey, userId.toString());

    // 2. 未点过赞
    boolean result = false;
    if (score == null) {
        result = this.lambdaUpdate()
                .eq(Blog::getId, id)
                .setSql("liked = liked + 1")
                .update();
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        stringRedisTemplate.opsForZSet().add(blogLikedKey, userId.toString(), System.currentTimeMillis());
        return CommonResult.success("点赞成功");
    } else {
        // 3. 点过赞则取消点赞
        result = this.lambdaUpdate()
                .eq(Blog::getId, id)
                .setSql("liked = liked - 1")
                .update();
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        stringRedisTemplate.opsForZSet().remove(blogLikedKey, userId.toString());
        return CommonResult.success("取消点赞成功");
    }
}

获取最早点赞的 5 个用户

# 查询结果顺序为:1、2、5
select id from tb_user where id in (5, 2, 1);

# 查询结果顺序为:5、2、1
select id from tb_user where id in (5, 2, 1) ORDER BY FIELD(id, 5, 2, 1);
/**
 * 获取最早点赞的 5 个用户
 */
@GetMapping("/likes/{id}")
public CommonResult<List<UserDTO>> getTopFiveUserLikedBlog(@PathVariable("id") Long id) {
    return blogService.getTopFiveUserLikedBlog(id);
}
@Override
public CommonResult<List<UserDTO>> getTopFiveUserLikedBlog(Long id) {
    String blogLikedKey = BLOG_LIKED_KEY + id;

    // 1. 从 Redis 中查询点赞该 Blog 的前 5 位用户的 id
    Set<String> topFive = stringRedisTemplate.opsForZSet().range(blogLikedKey, 0, 4);
    if (CollectionUtil.isEmpty(topFive)) {
        return CommonResult.success(Collections.emptyList());
    }

    // 2. 根据 id 查询用户信息,避免泄露敏感信息返回 UserDTO。
    List<Long> userIdList = topFive.stream().map(userIdStr -> Long.parseLong(userIdStr)).collect(Collectors.toList());
    String userIdStr = StrUtil.join(",", userIdList);
    List<UserDTO> userDTOList = userService.lambdaQuery()
            .in(User::getId, userIdList)
            .last("ORDER BY FIELD(id, " + userIdStr + ")")
            .list()
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    return CommonResult.success(userDTOList);
}

12. 关注相关

12.1 关注和取关

/**
 * 关注或取关
 * @param followUserId 关注、取关的用户ID
 * @param isFollowed 是否关注
 */
@PutMapping("/{id}/{isFollowed}")
public CommonResult<String> followOrNot(@PathVariable("id") Long followUserId, @PathVariable("isFollowed") Boolean isFollowed) {
    return followService.followOrNot(followUserId, isFollowed);
}

/**
 * 判断是否关注该用户
 * @param followUserId 关注用户的ID
 */
@GetMapping("/or/not/{id}")
public CommonResult<Boolean> isFollowed(@PathVariable("id") Long followUserId) {
    return followService.isFollowed(followUserId);
}
@Override
public CommonResult<String> followOrNot(Long followUserId, Boolean isFollowed) {
    Long userId = UserHolder.getUser().getId();
    boolean result = false;
    if (BooleanUtil.isTrue(isFollowed)) {
        // 关注
        Follow follow = new Follow();
        follow.setFollowUserId(followUserId);
        follow.setUserId(userId);
        result = this.save(follow);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return CommonResult.success("关注成功");
    } else {
        // 取关
        result = this.remove(new LambdaQueryWrapper<Follow>().eq(Follow::getFollowUserId, followUserId).eq(Follow::getUserId, userId));
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return CommonResult.success("取消关注成功");
    }
}

@Override
public CommonResult<Boolean> isFollowed(Long followUserId) {
    Integer count = this.lambdaQuery().eq(Follow::getFollowUserId, followUserId).eq(Follow::getUserId, UserHolder.getUser().getId()).count();
    return CommonResult.success(count > 0);
}

12.2 共同关注

关注时,以当前用户 ID 为 Key,关注用户 ID 为 Value 存入 Redis。取关时,将其从 Redis 中删除。

使用 Set 存储即可实现共同关注功能,Set 中有 SINTER - 交集SDIFFER - 差集SUNION - 并集 命令。

// 修改关注、取关功能
@Override
public CommonResult<String> followOrNot(Long followUserId, Boolean isFollowed) {
    Long userId = UserHolder.getUser().getId();
    String key = "follow:" + userId;
    boolean result = false;
    if (BooleanUtil.isTrue(isFollowed)) {
        // 关注
        Follow follow = new Follow();
        follow.setFollowUserId(followUserId);
        follow.setUserId(userId);
        result = this.save(follow);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);

        // userId 为 Key、followUserId 为 Value 存入 Redis
        stringRedisTemplate.opsForSet().add(key, followUserId.toString());
        return CommonResult.success("关注成功");
    } else {
        // 取关
        result = this.remove(new LambdaQueryWrapper<Follow>().eq(Follow::getFollowUserId, followUserId).eq(Follow::getUserId, userId));
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);

        // 从 Redis 中删除
        stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        return CommonResult.success("取消关注成功");
    }
}

此外,还需要编写以下几个接口。

/**
 * 根据 id 查询用户
 */
@GetMapping("/{id}")
public CommonResult<UserDTO> getUserById(@PathVariable("id") Long id) {
    User user = userService.getById(id);
    if (user == null) {
        return CommonResult.success(null);
    }
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    return CommonResult.success(userDTO);
}

/**
 * 查询当前用户的 Blog
 */
@GetMapping("/of/me")
public CommonResult<List<Blog>> queryMyBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
    UserDTO userDTO = UserHolder.getUser();
    ThrowUtils.throwIf(userDTO == null, ErrorCode.OPERATION_ERROR);
    Page<Blog> blogPage = blogService.lambdaQuery().eq(Blog::getUserId, userDTO.getId()).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
    ThrowUtils.throwIf(blogPage == null, ErrorCode.OPERATION_ERROR);
    return CommonResult.success(blogPage.getRecords());
}

/**
 * 查询指定用户的 Blog
 */
@GetMapping("/of/user")
public CommonResult<List<Blog>> queryMyBlog(@RequestParam(value = "current", defaultValue = "1") Integer current,
                                            @RequestParam(value = "id") Long id) {
    Page<Blog> blogPage = blogService.lambdaQuery().eq(Blog::getUserId, id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
    ThrowUtils.throwIf(blogPage == null, ErrorCode.OPERATION_ERROR);
    return CommonResult.success(blogPage.getRecords());
}

使用 SINTER key [key ...] 求出两个用户间的共同关注。

/**
 * 获取两个用户之间的共同关注用户
 * @param followUserId 关注用户的ID
 */
@GetMapping("/common/{id}")
public CommonResult<List<UserDTO>> commonFollow(@PathVariable("id") Long followUserId) {
    return followService.commonFollow(followUserId);
}
@Override
public CommonResult<List<UserDTO>> commonFollow(Long followUserId) {
    Long userId = UserHolder.getUser().getId();
    String selfKey = "follow:" + userId;
    String aimKey = "follow:" + followUserId;

    // 获取两个用户之间的交集
    Set<String> intersectIds = stringRedisTemplate.opsForSet().intersect(selfKey, aimKey);
    
    // 无交集
    if (CollectionUtil.isEmpty(intersectIds)) {
        return CommonResult.success(Collections.emptyList());
    }
    
    // 返回交集部分的用户信息
    List<User> userList = userService.listByIds(intersectIds);
    if (CollectionUtil.isEmpty(userList)) {
        return CommonResult.success(Collections.emptyList());
    }
    List<UserDTO> userDTOList = userList.stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
    return CommonResult.success(userDTOList);
}

12.3 关注推送 - Feed 流

关注推送也叫做 Feed 流,下拉刷新即可获取推送的信息。例如:微博、微信朋友圈、B站等。

12.3.1 Feed 流的实现方案

Feed 流的两种常见模式:

  • Timeline:不做内容筛选,按照内容发布时间排序。例如朋友圈。
    • 优点:信息全面,不会有所缺失,实现较为简单。
    • 缺点:信息噪音较多,内容不一定是用户感兴趣的。
  • 智能排序:利用智能算法屏蔽违规内容、用户不感兴趣的内容。
    • 优点:投喂用户感兴趣的信息,用户粘度很高。
    • 缺点:如果算法不精准,可能会起到反作用。

在本例的个人主页中,基于关注实现 Feed 流,采用 Timeline 模式。该模式的实现方案有三种:拉模式、推模式、推拉结合

拉模式,也叫读扩散。(很少使用)

  1. 张三和李四发送消息后,会将消息保存在自己的发件箱中。
  2. 赵六读取消息时,会读取自己的收件箱。
  3. 此时系统会从赵六关注的人的发件箱中拉取全部消息到其收件箱中,然后按照时间戳进行排序。
  • 优点:节省空间,读取消息时不会重复读取,读取完后可以将其收件箱清除。
  • 缺点:有延迟,当用户读取数据时才去关注的人的发件箱中读取,若关注了大量用户则会拉取海量的内容,导致服务器压力巨大。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第11张图片

推模式,也叫写扩散。(适用于粉丝量较少,千万级以内)

当张三发送一个消息时,会主动将张三的内容发送到其粉丝的收件箱中,此时粉丝读取则无需再去临时拉取。

  • 优点:时效快,无需临时拉取。
  • 缺点:内存压力大,假设用户的粉丝量巨大,则需要发送海量的数据到粉丝的收件箱。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第12张图片

推拉结合,也叫读写混合,兼具读扩散和写扩散的优点。

  • 发件人方
    • 发件人粉丝量小,采用写扩散(推)的方式,直接将消息写入到粉丝的收件箱中。
    • 发件人粉丝量大,采用读扩散(拉)的方式,将数据写入到一个发件箱中,然后再写一份到活跃粉丝的收件箱中。
  • 收件人方
    • 收件人是活跃粉丝,不论发件人的粉丝量多或少,都直接写入到其收件箱中。
    • 收件人是普通粉丝,上线不是很频繁,等他们上线时再去发件箱中拉取消息。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第13张图片

12.3.2 Feed 流的滚动分页

Feed 流中的数据会不断的更新,数据的下标也在变化,因此不能采用传统的分页模式。

传统分页

  • t1 时刻,10 条消息,limit 1, 5 读取到 10、9、8、7、6 五条数据。
  • t2 时刻,新增一条消息。
  • t3 时刻,11 条消息,limit 5, 5 读取的 6、5、4、3、2 五条数据。(数组的头部新增了数据,导致读取到了重复的数据
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第14张图片

滚动分页

记录每次查询的最后一条消息,下次查询时从该位置开始读取数据。(因为是倒序进行查询,起始数据为 ∞ 无穷大)

  • t1 时刻,10 条消息,limit ∞, 5 读取到 10、9、8、7、6 五条数据,并且记录读取到的最后一条数据 lastId = 6
  • t2 时刻,新增一条消息。
  • t3 时刻,11 条消息,limit lastId, 5 读取到 5、4、3、2、1 五条数据。

刷新即可获取到最新发布的消息,因为起始数据为 ♾️,即 limit ∞, n

通过 ZSet 实现:按照 score 值(时间戳)从大到小进行范围查询,每一次查询后记录最小的时间戳,从之前记录的时间戳开始查

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hx85EoBd-1683509109925)(https://itsawaysu.oss-cn-shanghai.aliyuncs.com/note/%E6%BB%9A%E5%8A%A8%E5%88%86%E9%A1%B5.jpg)]

12.3.3 代码实现

  1. 修改发布 Blog 业务:保存 Blog 到数据库的同时,推送消息到粉丝的收件箱。
  2. 收件箱中的 Blog 根据时间戳排序,使用 Redis 的 ZSet 数据结构实现。
  3. 实现分页查询查询收件箱中的数据。

发布 Blog 业务:保存 Blog 到数据库的同时,推送消息到粉丝的收件箱。

/**
 * 发布笔记(保存 Blog 到数据库的同时,推送消息到粉丝的收件箱)
 */
@Override
public CommonResult<Long> publishBlog(Blog blog) {
    // 1. 保存 Blog 到数据库
    ThrowUtils.throwIf(blog == null, ErrorCode.PARAMS_ERROR);
    UserDTO user = UserHolder.getUser();
    ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);
    blog.setUserId(user.getId());
    boolean result = this.save(blog);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);

    // 2. 查询该 Blogger 的粉丝
    List<Follow> fansList = followService.lambdaQuery().eq(Follow::getFollowUserId, user.getId()).list();
    ThrowUtils.throwIf(CollectionUtil.isEmpty(fansList), ErrorCode.NOT_FOUND_ERROR);
    
    // 3. 推送 Blog 给所有粉丝
    for (Follow follow : fansList) {
      	// Key 用于标识不同粉丝,每个粉丝都有一个收件箱;Value 存储 BlogId;Score 存储时间戳。
      	String key = FEED_KEY + follow.getUserId();
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    return CommonResult.success(blog.getId());
}

分页查询收件箱:在个人主页的「关注」中,查询并展示推送的 Blog。

> ZADD zset 1 m1 2 m2 3 m3 4 m4 5 m5 5 m6
(integer) 8
> ZREVRANGE zset 0 -1 WITHSCORES
 1) "m6"
 2) "5"
 3) "m5"
 4) "5"
 5) "m4"
 6) "4"
 7) "m3"
 8) "3"
 9) "m2"
10) "2"
11) "m1"
12) "1"

# ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
# max:起始为当前时间戳,之后为上一次查询的最小时间戳。
# offset:偏移量。起始为 0,之后为上一次查询结果中,与最小值相同的元素个数。
# count:查询的数量。
> ZREVRANGEBYSCORE zset 999 0 WITHSCORES LIMIT 0 3
1) "m6"
2) "5"
3) "m5"
4) "5"

# m6 和 m5 的 score 都是 5,偏移量为 2 才能查到 score 为 4 的数据。
> ZREVRANGEBYSCORE zset 5 0 WITHSCORES LIMIT 2 2
1) "m4"
2) "4"
3) "m3"
4) "3"

> ZREVRANGEBYSCORE zset 3 0 WITHSCORES LIMIT 1 2
1) "m2"
2) "2"
3) "m1"
4) "1"
  1. 第一次查询的 lastId 为当前时间戳,每次查询后 lastId 为上一次查询中最小的时间戳。
  2. 偏移量 offset 为上一次查询中最小时间戳的个数,下一次查询时需要跳过这些已经查询过的数据。
// 滚动分页返回值实体类
@Data
public class ScrollResult implements Serializable {
    public static final long serialVersionUID = 1L;
    private List<?> list;
    private Long minTime;
    private Integer offset;
}
/**
 * 获取当前用户收件箱中的 Blog(关注的人发布的 Blog)
 * @param max 上次查询的最小时间戳(第一次查询为当前时间戳)
 * @param offset 偏移量(第一次查询为 0)
 * @return Blog 集合 + 本次查询的最小时间戳 + 偏移量
 */
@GetMapping("/of/follow")
public CommonResult<ScrollResult> getBlogsOfIdols(@RequestParam("lastId") Long max,
                                                  @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
    return blogService.getBlogsOfIdols(lastId, offset);
}
@Override
public CommonResult<ScrollResult> getBlogsOfIdols(Long max, Integer offset) {
    // 1. 查询当前用户的收件箱
    // ZREVRANGEBYSCORE key max min LIMIT offset count
    String key = FEED_KEY + UserHolder.getUser().getId();
    Set<ZSetOperations.TypedTuple<String>> tupleSet = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    ThrowUtils.throwIf(CollectionUtil.isEmpty(tupleSet), ErrorCode.NOT_FOUND_ERROR);

    // 2. 解析数据(Key - feed:userId、Value - BlogId、Score - timestamp),解析得到 blogId、timestamp、offset。
    ArrayList<Long> blogIdList = new ArrayList<>();
    long minTime = 0;
    int nextOffset = 1;
    for (ZSetOperations.TypedTuple<String> tuple : tupleSet) {
        blogIdList.add(Long.parseLong(tuple.getValue()));
        // 循环到最后一次将其赋值给 timestamp 即可拿到最小时间戳。
        long time = tuple.getScore().longValue();

        // 假设时间戳为:2 2 1
        // 2 != 0 --> minTime=5; nextOffset = 1;
        // 2 == 2 --> minTime=4; nextOffset = 2;
        // 2 != 1 --> minTime=4; nextOffset = 1;
        if (time == minTime) {
            nextOffset ++;
        } else {
            minTime = time;
            nextOffset = 1;
        }
    }
    // 3. 根据 BlogId 获取 Blog 并设置相关信息
    String blogIdStr = StrUtil.join(",", blogIdList);
    List<Blog> blogList = this.lambdaQuery()
            .in(Blog::getId, blogIdStr)
            .last("ORDER BY FIELD(id, " + blogIdStr + ")")
            .list();
    for (Blog blog : blogList) {
        // 设置 Blog 中用户相关的属性值
        this.setUserInfo4Blog(blog);
        // 判断当前登录用户是否点赞过 Blog
        this.isBlogLiked(blog);
    }

    // 4.封装为 ScrollResult 并返回
    ScrollResult scrollResult = new ScrollResult();
    scrollResult.setList(blogList);
    scrollResult.setMinTime(minTime);
    scrollResult.setOffset(nextOffset);
    return CommonResult.success(scrollResult);
}

13. GEO 附近搜索

13.1 GEO 数据结构

GEO Geolocation,代表地理位置,允许存储地理坐标。GEO 底层是 ZSET,可以使用 ZSET 的命令操作 GEO。

GEOADD key longitude latitude member [longitude latitude member ...]:添加一个地理空间信息,包含经度(longitude)、纬度(latitude)、值(member)。

GEODIST key member1 member2 [unit]:计算指定的两点之间的距离。

GEOHASH key member [member ...]:将指定 member 的坐标转为 hash 字符串形式并返回。

GEOPOS key member [member ...]:返回指定 member 的坐标(经度 + 纬度)。

# 添加一个地理空间信息(longitude、latitude、member)
> GEOADD China:City 116.40 39.90 Beijing
> GEOADD China:City 121.47 31.23 Shanghai 106.50 29.53 Chongqing 114.08 22.547 Shenzhen 120.15 30.28 Hangzhou 125.15 42.93 Xian 102.71 25.04 Kunming

# 计算指定的两点之间的距离
> GEODIST China:City Beijing Shanghai km
"1067.3788"
> GEODIST China:City Shanghai Kunming km
"1961.3500"

# 坐标转为 Hash 字符串:降低内存存储压力,会损失一些精度,但是仍然指向同一个地区。
> GEOHASH China:City Beijing Shanghai Kunming
1) "wx4fbxxfke0"
2) "wtw3sj5zbj0"
3) "wk3n3nrhs60"

# 返回指定 member 的坐标(经度 + 纬度)
> GEOPOS China:City Beijing
1) 1) "116.39999896287918091"
   2) "39.90000009167092543"
> GEOPOS China:City Shanghai Kunming Hangzhou
1) 1) "121.47000163793563843"
   2) "31.22999903975783553"
2) 1) "102.70999878644943237"
   2) "25.03999958679589355"
3) 1) "120.15000075101852417"
   2) "30.2800007575645509"

GEOSEARCH:在指定范围内搜索 member,并按照与指定之间的距离顺序后返回,范围内可以是圆形或矩形。(GEOSEARCHSTORE 与 GEOSEARCH 功能一致,GEOSEARCHSTORE 可以将结果存储到一个指定的 Key)

GEOSEARCH key 
	# FROMMEMBER:从 member 中选一个作为参照。FROMLONLAT:指定坐标作为参照。
	[FROMMEMBER member] [FROMLONLAT longitude latitude] 
	# BYRADIUS:按照圆进行搜索。BYBOX:按照矩形进行搜索
	[BYRADIUS radius [unit]] [BYBOX width height [unit]] 
	# 查询多少条
	[COUNT count [ANY]] 
	# WITHDIST:距离。
	[WITHDIST]
> GEOSEARCH China:City FROMLONLAT 116.397904 39.909005 BYRADIUS 1000 km WITHDIST
1) 1) "Beijing"
   2) "1.0174"
2) 1) "Xian"
   2) "803.0689"

> GEOSEARCH China:City FROMLONLAT 116.397904 39.909005 BYBOX 2000 2000 km WITHDIST
1) 1) "Shanghai"
   2) "1068.3526"
2) 1) "Beijing"
   2) "1.0174"
3) 1) "Xian"
   2) "803.0689

> GEOSEARCH China:City FROMMEMBER Beijing BYBOX 2000 2000 km WITHDIST
1) 1) "Shanghai"
   2) "1067.3788"
2) 1) "Beijing"
   2) "0.0000"
3) 1) "Xian"
   2) "803.3746"

13.2 附近商家搜索

将数据库中的数据导入到 Redis 中:按照商铺类型分组,类型相同的商家作为一组,以 typeId 为 Key,商铺地址为 Value。

@Test
void loadShopData() {
    List<Shop> shopList = shopService.list();
    // 1. 店铺按照 TypeId 分组
    Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
    // 2. 分批写入 Redis
    for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
        Long typeId = entry.getKey();
        String key = RedisConstants.SHOP_GEO_KEY + typeId;
        shopList = entry.getValue();
        for (Shop shop : shopList) {
            // GEOADD key longitude latitude member
            stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
        }
    }
}

注意:spring-data-redis 2.3.9 版本不支持 Redis 6.2 提供的 GEOSEARCH 命令。

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.datagroupId>
            <artifactId>spring-data-redisartifactId>
        exclusion>
        <exclusion>
            <groupId>lettuce-coregroupId>
            <artifactId>io.lettuceartifactId>
        exclusion>
    exclusions>
dependency>

<dependency>
    <groupId>org.springframework.datagroupId>
    <artifactId>spring-data-redisartifactId>
    <version>2.6.2version>
dependency>
<dependency>
    <groupId>io.lettucegroupId>
    <artifactId>lettuce-coreartifactId>
    <version>6.1.6.RELEASEversion>
dependency>

根据店铺类型分页查询店铺信息(按照距离排序)

/**
 * 根据店铺类型分页查询店铺信息(按照距离排序)
 * @param typeId  店铺类型
 * @param current 当前页码
 * @param x       经度
 * @param y       纬度
 * @return 店铺列表
 */
@GetMapping("/of/type")
public CommonResult<List<Shop>> getShopsByTypeOrderByDistance(
        @RequestParam("typeId") Integer typeId,
        @RequestParam(value = "current", defaultValue = "1") Integer current,
        @RequestParam(value = "x", required = false) Double x,
        @RequestParam(value = "y", required = false) Double y) {
    return shopService.getShopsByTypeOrderByDistance(typeId, current, x, y);
}
@Override
public CommonResult<List<Shop>> getShopsByTypeOrderByDistance(Integer typeId, Integer current, Double x, Double y) {
    // 1. 判断是否需要根据坐标排序(不需要则直接从数据库中查询)
    if (x == null || y == null) {
        Page<Shop> shopPage = this.lambdaQuery()
                .eq(Shop::getTypeId, typeId)
                .page(new Page<>(current, DEFAULT_PAGE_SIZE));
        ThrowUtils.throwIf(shopPage == null, ErrorCode.NOT_FOUND_ERROR);
        return CommonResult.success(shopPage.getRecords());
    }

    // 2. 计算分页参数
    int start = (current - 1) *DEFAULT_PAGE_SIZE;
    int end = current * DEFAULT_PAGE_SIZE;

    // 3. GEOSEARCH key BYLONLAT x y BYRADIUS 5000 mi WITHDISTANCE(查询 Redis,获取 shopId 和 distance)
    String key = SHOP_GEO_KEY + typeId;
    GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults = stringRedisTemplate.opsForGeo().search(
            key,
            GeoReference.fromCoordinate(x, y),
            new Distance(5000),
            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
    );
    ThrowUtils.throwIf(CollectionUtil.isEmpty(geoResults), ErrorCode.NOT_FOUND_ERROR);

    // 4. 解析出 shopId
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = geoResults.getContent();
  	if (content.size() < start) {
    		return CommonResult.success(Collections.emptyList());
    }
    List<Long> shopIdList = new ArrayList<>(content.size());
    Map<String, Distance> distanceMap = new HashMap<>(content.size());
    // 截取 start ~ end 部分
    content.stream().skip(start).forEach(result -> {
        String shopId = result.getContent().getName();
        shopIdList.add(Long.valueOf(shopId));
        Distance distance = result.getDistance();
        distanceMap.put(shopId, distance);
    });

    // 5. 根据 shopId 查询 Shop
    String shopIdStr = StrUtil.join(", ", shopIdList);
    List<Shop> shopList = lambdaQuery().in(Shop::getId, shopIdList).last("ORDER BY FIELD(id, " + shopIdStr + ")").list();
    for (Shop shop : shopList) {
        Distance distance = distanceMap.get(shop.getId().toString());
        if (distance != null) {
            shop.setDistance(distance.getValue());
        }
    }
    return CommonResult.success(shopList);
}

14. BitMap 签到

14.1 BitMap 数据结构

CREATE TABLE `tb_sign` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint unsigned NOT NULL COMMENT '用户id',
  `year` year NOT NULL COMMENT '签到年份',
  `month` tinyint NOT NULL COMMENT '签到月份',
  `date` date NOT NULL COMMENT '签到的日期',
  `is_backup` tinyint unsigned DEFAULT NULL COMMENT '是否补签',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

用户签到一次就是一条记录,若有 1000 万用户,平均每人每年的签到次数为 10 次,这张表的数据量为 1 亿条;每次签到需要使用 (8 + 8 + 1 + 1 + 3 + 1)22 个字节的内存,则一个月需要 600 多字节,存储压力过大。

解决方案:签到表,1 表示签到,0 表示未签到。

每一个 Bit 位对应当月的一天,形成映射关系;用 0 和 1 标识业务状态,这种思路被称为 位图(BitMap)

Redis 中的 BitMap 底层基于 String 数据结构,最大上限为 512 M,转换为 Bit 则是 2^32 个 Bit 位。

Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第15张图片

BitMap 的操作命令

SETBIT key offset value :向指定位置 offset 存入一个 0 或 1。

GETBIT key offset :获取指定位置 offset 的 Bit 值。

BITCOUNT key [start end] :统计 BitMap 中值为 1 的 Bit 位的数量。

# Redis 中存储的二进制:11001010
> SETBIT bm 0 1
> SETBIT bm 1 1
> SETBIT bm 4 1
> SETBIT bm 6 1

> GETBIT bm 1
(integer) 1
> GETBIT bm 3
(integer) 0
> GETBIT bm 5
(integer) 1

# 统计 BitMap 中值为 1 的 Bit 位的数量
> BITCOUNT bm
(integer) 4

BITFIELD key [GET type offset] :批量读取 offset 个 BIT 位,返回值为十进制。(u 为无符号)

# Redis 中存储的二进制:11001010

# u2 -> 11
> BITFIELD bm GET u2 0
1) (integer) 3
# u3 -> 110
> BITFIELD bm GET u3 0
1) (integer) 6
# u4 -> 1100
> BITFIELD bm GET u4 0
1) (integer) 12

BITPOS key bit [start] [end] :查找 Bit 数组中指定范围内的第一个 0 或 1 出现的位置。

# 第一个 1 出现的位置
> BITPOS bm 1
(integer) 0

# 第一个 0 出现的位置
> BITPOS bm 0
(integer) 2

14.2 签到

/**
 * 签到
 */
@PostMapping("/sign")
public CommonResult<String> sign() {
    return userService.sign();
}
@Override
public CommonResult<String> sign() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    String date = DateTimeFormatter.ofPattern(":yyyyMM").format(now);
		// sign:1:202305
		String key = USER_SIGN_KEY + userId + date;
  	int dayOfMonth = now.getDayOfMonth();

    // Key - sign:1:202305(用户每个月的签到信息)、offset - 当月的哪一天(哪一个 BIT 位)、Value - 1 / 0。
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return CommonResult.success("签到成功");
}

14.3 统计连续签到天数

从最后一次签到向前统计,直到遇到第一次未签到为止;计算总的签到次数,就是连续签到天数。

  1. 首先,获取本月的所有签到数据 BITFIELD key GET u[dayOfMonth] 0
  2. 从后向前遍历每个 Bit 位,每获得一个非 0 的数字 +1,遇到 0 则停止。

遍历 BitMap:与 1 进行与运算,每与一次就将签到结果右移一位,实现遍历。

1011
   1
# 得到 1
		
# 右移 1 位
101
  1
# 得到 1

# 右移 1 位
10
 1
# 得到 0
/**
 * 统计本月当前用户截止当前时间连续签到的天数
 */
@GetMapping("/sign/count")
public CommonResult<Integer> serialSignCount4CurrentMonth() {
    return userService.serialSignCount4CurrentMonth();
}
@Override
public CommonResult<Integer> serialSignCount4CurrentMonth() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    String date = DateTimeFormatter.ofPattern(":yyyyMM").format(now);
    String key = USER_SIGN_KEY + userId + date;
    // 本月截止当前的签到记录,返回的是一个十进制数字。(当前是本月的第几天,就查询几个 BIT 位)
    int dayOfMonth = now.getDayOfMonth();
    List<Long> signCount = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
    );
    // 没有任何结果
    if (CollectionUtil.isEmpty(signCount)) {
        return CommonResult.success(0);
    }
    // List 中只有一条数据,直接取出作为结果
    Long num = signCount.get(0);
    if (num == 0 || null == num) {
        return CommonResult.success(0);
    }
    // 与 1 进行与运算,每与一次就将签到结果右移一位,实现遍历。
    int count = 0;
    while (true) {
        if ((num & 1) == 0) {
            break;
        } else {
            count++;
        }
        // 右移一位,抛弃最后一个 Bit 位,继续下一个 Bit 位。
        num = num >> 1;
    }
    return CommonResult.success(count);
}

15. HyperLogLog UV 统计

  • UV, Unique Visitor :独立访客量,一天内同一个用户多次访问该网站,只记录一次。
    • 需要判断用户是否已访问过,需要保存访问过的用户信息,比较麻烦。
  • PV, Page View :页面访问量,用户每访问网站的一个页面,记录一个 PV。(数据量很大)

HyperLogLog 数据结构

HyperLogLog(HLL) 用于确定非常大的集合的基数,而不需要存储其所有值。

  • 基数(不重复的元素):数据集 {1,3,5,7,5,7} 的基数集为 {1,3,5,7,8}
  • Redis 中的 HyperLogLog 是基于 String 数据结构实现的,单个 HLL 的内存永远小于 16 KB,内存占用非常非常低。
  • 但是它的测量存在小于 0.81% 的误差,不过对于 UV 统计而言,几乎可以忽略。

PFADD & PFCOUNT & PFMERGE

> pfadd hll e1 e2 e3 e4 e5

> PFCOUNT hll
(integer) 5

> pfadd hll e1 e2 e3 e4 e5
(integer) 0
> PFCOUNT hll
(integer) 5

> pfadd set1 e1 e2 e3 e4 e5
> pfadd set2 e4 e5 e6 e7 e8
# 合并 set1、set2 得到 set3
> pfmerge set3 set1 set2
> pfcount set3
(integer) 8

测试百万级数据的统计

利用单元测试,向 HyperLogLog 中添加 100 万条数据,查看内存占用和统计效果:

@Test
void millionDataHyperLogLogTest() {
    String[] users = new String[1000];
    int j = 0;
    for (int i = 0; i < 1000000; i++) {
        j = i % 1000;
        users[j] = "user_" + i;
        // 分批导入,每 1000 条数据写入一次
        if (j == 999) {
            stringRedisTemplate.opsForHyperLogLog().add("hll", users);
        }
    }
    Long hllSize = stringRedisTemplate.opsForHyperLogLog().size("hll");
    System.out.println("size = " + hllSize);
}

通过 info memory 查看测试前后的内存占用:(1118960 - 1106056) / 1024 = 12.6KB

Comment
├── config :存放项目依赖相关配置;
│   ├── RedisConfiguration:创建单例 Redisson 客户端。
│   └── WebMvcConfiguration:配置了登录、自动刷新登录 Token 的拦截器。
│
├── controller :存放 Restful 风格的 API 接口。
│
├── interceptor :登录拦截器 & 自动刷新 Redis 登录 Token 有效期。
│
├── mapper :存放操作数据库的代码。
│
├── service :存放业务逻辑处理代码。
│   ├── BlogService:基于 Redis 实现点赞、按时间排序的点赞排行榜;基于 Redis 实现拉模式的 Feed 流。
│   ├── FollowService:基于 Redis 集合实现关注、共同关注。
│   ├── ShopService:基于 Redis 缓存优化店铺查询性能;基于 Redis GEO 实现附近店铺按距离排序。
│   ├── UserService: 基于 Redis 实现短信登录(分布式 Session)。
│   ├── VoucherOrderService:基于 Redis 分布式锁、Redis + Lua 两种方式,结合消息队列,共同实现秒杀和一人一单功能。
│   └── VoucherService :添加优惠券,并将库存保存在 Redis 中,为秒杀做准备。
│
└── utils :存放项目内通用的工具类。
    ├── RedisIdWorker.java :基于 Redis 的全局唯一自增 ID 生成器。
    ├── SimpleDistributedLockBasedOnRedis.java :简单的 Redis 锁实现,了解即可,一般用 Redisson。
    └── UserHolder.java :线程内缓存用户信息。

16. Ops

16.1 Redis 持久化

Redis 是基于内存的数据库,服务宕机会导致数据丢失。可以通过数据库恢复数据。但是数据库有性能瓶颈,大量的数据恢复会给数据库带来巨大压力。此外,数据库性能不如 Redis,导致程序响应慢。因此,Redis 需要实现数据的持久化,避免从数据库中恢复数据。

Redis 持久化:防止数据丢失,以及服务重启时能够恢复数据。Redis 的持久化通过 RDBAOF 实现。

16.1.1 RDB

RDB 全称 Redis DataBase Backup file(Redis 数据备份文件 / Redis 数据快照):将内存中的数据生成快照保存到磁盘上。当 Redis 宕机重启后,从磁盘中读取快照文件并恢复数据。(RDB 文件默认保存在当前运行目录中)

触发 RDB 持久化的方式:手动触发自动触发

手动触发:savebgsave 命令。

save 命令:阻塞当前 Redis 服务器(阻塞所有命令),直到 RDB 持久化完成。对于内存占用较大的实例,会造成长时间的阻塞,线上环境不建议使用。

bgsave 命令:fork 主进程创建子进程,子进程共享主进程的内存数据,由子进程执行 RDB 持久化将内存数据写入临时 RDB 文件,临时 RDB 文件替换旧的 RDB 文件即可。(阻塞只发生在 fork 阶段,时间很短,几乎不影响主进程)

自动触发

RDB 默认开启,在 Redis 停机时会触发完成一次持久化。(宕机不会)

生产环境下一般会设置周期性执行条件:通过在 redis.conf 中配置 save m n,即在 m 秒内有 n 次修改时,自动触发 bgsave 生成 RDB 文件。

# 在 900 秒内,有 1 个 Key 修改则执行 bgsave。
save 9000 1
save 300 10
save 60 10000

# 压缩设置为 no,因为压缩会占用更多的 CPU 资源
rdbcompression no

16.1.2 AOF

AOF 全称 Append Only File(追加文件):Redis 处理的每一个写命令,都会记录到 AOF 文件中,可以看做命令的日志文件。

只要从头到尾执行一次 AOF 文件中的所有写命令,即可恢复 AOF 文件所记录的数据。

# Redis
> set hello world

# AOF
$3
set
$5
hello
$5
world

AOF 日志采用写后日志,即 先写内存,后写日志

  • 避免额外的检查开销:向 AOF 中记录日志时,不会对命令进行语法检查。(先记录日志再执行命令,日志中可能存错误命令,恢复数据时可能出错)
  • 不会阻塞当前的写操作
  • 但是,命令执行完成、写入日志之前宕机会导致丢失数据。

AOF 持久化配置

AOF 默认情况下未开启,通过 appendonly 参数开启。

# 是否开启 AOF 功能
appendonly yes

# AOF 文件名称
appendfilename "appendonly.aof"

因为对文件进行写入并不会马上同步到磁盘上,而是先存储到缓冲区。所以通过 AOF 持久化的同步设置,设置命令同步到磁盘文件上的时机。

# 每执行一条写命令,立即记录到 AOF 文件中
appendfsync always
# 写命令执行完先放入 AOF 缓冲区,然后每隔 1 秒将缓冲区中的数据写入到 AOF 文件中(默认方案)
appendfsync everysec
# 写命令执行完先放入 AOF 缓冲区,由操作系统决定如何将缓冲区内容写到磁盘
appendfsync no
同步选项 同步频率 优点 缺点
always 每个 Redis 写命令都要同步写入磁盘 可靠性高,几乎不会丢失数据 性能较差
everysec 每秒执行一次同步 性能很好 出现宕机最多会丢失 1 秒内产生的数据
no 操作系统决定何时同步 性能最好 可靠性差,可能丢失大量数据

AOF 重写

随着 Redis 不断运行,AOF 文件的体积不断增长,占用更多的磁盘空间,则恢复时间可能会比较长。

# AOF 会记录对同一个 Key 的多次写操作,但只有最后一次操作有意义。
# 因为 Key 最后被删除,因此前三次 set 操作是无意义的。
> set name Jack;
> set name Allen;
> del name;

通过 BGREWRITEAOF 命令重写 AOF 文件:移除 AOF 文件中冗余命令,减小 AOF 文件的体积。AOF 重写产生了一个新的 AOF 文件,和原有的 AOF 文件所保存的数据一样,但体积更小。

自动重写 AOF 文件:在 Redis 配置文件中配置自动重写 AOF 文件的触发阈值。

# AOF 文件比上次文件增长超过 100% 时触发重写
auto-aof-rewrite-percentage 100
# AOF 文件体积超过 64mb 时触发重写
auto-aof-rewrite-min-size 64mb

16.1.3 RDB 和 AOF

RDB AOF
持久化方式 定时对整个内存做快照 记录每一次执行的命令
数据完整性 不完整,两次快照之间会丢失数据 相对完整,取决于同步策略
文件大小 文件体积小 记录命令,文件体积很大
宕机恢复速度 很快(直接将数据加载到内存) 慢(执行 AOF 中记录的命令)
数据恢复优先级 低,因为数据完整性不如 AOF 优先采用 AOF 恢复数据(数据完整性更高)
使用场景 可容忍数分钟的数据丢失、更快的启动速度 数据安全性要求极高

Redis 4.0 支持 RDB 和 AOF 的混合持久化。默认是关闭的,需要配置:

aof-user-rdb-preamble yes

16.2 Redis 主从集群

单节点的 Redis 并发能力有限,可以通过搭建主从集群提高 Redis 的并发能力,实现读写分离。

读写分离:Redis 主从架构中,Master 节点负责处理写请求,Slave 节点只处理读请求。

主从同步:Master 节点接收到写请求并处理后,告知 Slave 节点数据发生了改变,Master 节点将写操作同步给 Slave节点,保持主从节点数据一致。

全量同步:主从第一次建立连接时会执行 全量同步,将主节点的所有数据都拷贝给从节点。全量同步需要进行一次 RDB,然后将 RDB 文件通过网络传输给 Slave,成本太高。因此只有第一次为全量同步,其它多数为增量同步。

  1. Slave 请求同步,Master 判断是否为第一次同步;第一次同步则返回 Master 版本信息给 Slave。
  2. Master 执行 bgsave 将内存的数据生成 RDB 快照文件,然后将其发送给 Slave。
  3. Slave 清空本地数据,加载 Master 发送的 RDB 文件保持主从节点数据一致。
# --net host								使用宿主机的 IP 和端口
# --privileged=true 				获取宿主机 root 用户权限
# --cluster-enabled yes 		开启 Redis 集群
# --appendonly yes 					开启 Redis AOF 持久化
docker run -d --name redis-node-1 --net host --privileged=true -v /docker/redis/share/redis-node-1:/data redis:6.2.7 --cluster-enabled yes --appendonly yes --port 6381
docker run -d --name redis-node-2 --net host --privileged=true -v /docker/redis/share/redis-node-2:/data redis:6.2.7 --cluster-enabled yes --appendonly yes --port 6382
docker run -d --name redis-node-3 --net host --privileged=true -v /docker/redis/share/redis-node-3:/data redis:6.2.7 --cluster-enabled yes --appendonly yes --port 6383
docker run -d --name redis-node-4 --net host --privileged=true -v /docker/redis/share/redis-node-4:/data redis:6.2.7 --cluster-enabled yes --appendonly yes --port 6384
docker run -d --name redis-node-5 --net host --privileged=true -v /docker/redis/share/redis-node-5:/data redis:6.2.7 --cluster-enabled yes --appendonly yes --port 6385
docker run -d --name redis-node-6 --net host --privileged=true -v /docker/redis/share/redis-node-6:/data redis:6.2.7 --cluster-enabled yes --appendonly yes --port 6386

[root@VM-8-5-centos /]# docker ps
CONTAINER ID   IMAGE         COMMAND                  STATUS          NAMES
101572a0bca2   redis:6.2.7   "docker-entrypoint.s…"   Up 3 seconds    redis-node-6
f064876acbf8   redis:6.2.7   "docker-entrypoint.s…"   Up 7 seconds    redis-node-5
510f6204c893   redis:6.2.7   "docker-entrypoint.s…"   Up 12 seconds   redis-node-4
15573dd8d2e9   redis:6.2.7   "docker-entrypoint.s…"   Up 16 seconds   redis-node-3
66705fdcc00d   redis:6.2.7   "docker-entrypoint.s…"   Up 20 seconds   redis-node-2
9e3a4eb91b53   redis:6.2.7   "docker-entrypoint.s…"   Up 24 seconds   redis-node-1

进入 redis-node-1 容器为 6 台机器构建集群关系

# 进入 redis-node-1 容器
docker exec -it redis-node-1 /bin/bash

# --cluster-replicas 1:为每个 master 节点创建一个 slave 节点(1:主节点数/从节点数的比例,按照先后顺序区分主从节点)
redis-cli --cluster create 10.0.8.5:6381 10.0.8.5:6382 10.0.8.5:6383 10.0.8.5:6384 10.0.8.5:6385 10.0.8.5:6386 --cluster-replicas 1

主节点:6381(0-5460)、6382(5461-10922)、6383(10923-16383);从节点:6384、6385、6386。

查看集群状态

[root@VM-8-5-centos ~]# docker exec -it redis-node-1 /bin/bash
root@VM-8-5-centos:/data# redis-cli -p 6381
127.0.0.1:6381> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:3639
cluster_stats_messages_pong_sent:3626
cluster_stats_messages_sent:7265
cluster_stats_messages_ping_received:3621
cluster_stats_messages_pong_received:3639
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:7265

127.0.0.1:6381> cluster nodes
d3e6283a3c79525793b5e3b98e047afef091aa5e 10.0.8.5:6381@16381 myself,master - 0 1669902909000 1 connected 0-5460
c7b13199fab912864f017152e21087410aaa0d57 10.0.8.5:6382@16382 master - 0 1669902912010 2 connected 5461-10922
79f11a738ffad25ea1254ddac0fbc5d722d701a3 10.0.8.5:6383@16383 master - 0 1669902913013 3 connected 10923-16383
fcc55a534821ebc59b822e99cfbcb08a3342bf85 10.0.8.5:6384@16384 slave c7b13199fab912864f017152e21087410aaa0d57 0 1669902914016 2 connected
041dedb520107124a587919ea5e1e567aec91802 10.0.8.5:6385@16385 slave 79f11a738ffad25ea1254ddac0fbc5d722d701a3 0 1669902915018 3 connected
4d357a9680744b62c005a600df262ee4ee5760df 10.0.8.5:6386@16386 slave d3e6283a3c79525793b5e3b98e047afef091aa5e 0 1669902914000 1 connected

# 主从节点的对应关系
M 6381 - S 6386
M 6382 - S 6384
M 6383 - S 6385

16.3 哨兵机制

Slave 节点宕机恢复后可以找 Master 节点同步数据,Redis 提供了 哨兵机制(Sentinel) 解决 Master 节点宕机的问题。

  1. 监控集群:Redis 中多个 Sentinel 组成集群,持续监控 Master、Slave 是否按预期工作。

    • 使用 Sentinel 集群监控 Redis 主从架构可以保证:Sentinel 自身的高可用、多个 Sentinel 共同完成故障判断防止误判
  2. 故障转移:Master 节点宕机时自动选择一个最优的 Slave 节点切换为 Master 节点,故障实例恢复后主从进行了切换。

  3. 配置中心:Client 连接 Redis 集群时先连接到 Sentinel 集群,通过 Sentinel 查询 Master 节点的地址后再连接到 Master 节点进行数据交换。

  4. 消息通知:Sentinel 将故障转移的结果推送给 Client,无需重启即可自动完成节点切换。

监控功能

Sentinel 集群基于 心跳机制 检测服务状态,每隔 1 秒向集群的每个实例发送 ping 命令。

主观下限(Subject Down):若某个 Sentinel 节点发现某实例未在规定时间内响应,则认为该实例 主观下线

客观下线(Objective Down):若超过指定数量(quorum)的 Sentinel 都认为该实例主观下线,则该实例 客观下线。(quorum 的值最好设置为超过 Sentinel 实例的一半)

选举新的 Master

一旦发现 Master 宕机,Sentinel 需要在 Slave 中选择一个作为新的 Slave:

  1. 首先判断 Slave 与 Master 断开时间长短,超过指定值(down-after-milliseconds * 10)则会排除该 Slave。
  2. 判断 Slave 节点的 slave-priority 值,越小优先级越高,0 则不会参与选举。
  3. slave-priority 一样则判断 Slave 的 offset 值,越大说明数据越新,优先级越高。
  4. 最后判断 Slave 运行 ID 大小(Redis 自动生成),越小优先级越高。

故障转移

假设 Master(7001)、Slave(7001)、Slave(7002),Master 7001 宕机,Slave 7002 被选举为新的 Master:

  1. 首先,Sentinel 向 Slave 7002 发送 slaveof no one 命令,让该节点成为 Master。
  2. 其次,Sentinel 向其他所有 Slave 发送 slaveof 127.0.0.1 7002 命令,让这些 Slave 成为新 Master 的从节点,开始从 Master 上同步数据。
  3. 最后,Sentinel 将故障节点标记为 Slave,当故障节点恢复后会自动成为新的 Master 的从节点。

16.4 Redis 分布式存储方案

单 Master 架构中 Master 和 Slave 的数据一样的、能容纳的数据量也一样。数据量超过 Master 的内存时,Redis 会使用 LRU 算法清除部分数据。无法容纳更多数据,只能通过 Redis Cluster(Redis 分布式解决方案)解决单 Master 架构的内存、并发、流量等瓶颈

通过 Redis 使用分布式存储,一般有三种解决方案:哈希取余分区、一致性哈希算法分区、哈希槽分区

哈希取余分区

n 个 Redis 实例构成集群,每次读写操作都需要通过 hash(key) % n 计算数据映射到哪一个节点上。

  • 优点:简单粗暴、直接有效,起到负载均衡、分而治之的作用。
  • 缺点:对节点进行扩容或缩容比较麻烦,原来的取模公示会发生变化,映射关系需要全部重新进行计算。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第16张图片

一致性哈希算法分区

一致性哈希算法主要是为了解决 分布式缓存的数据变动和映射的问题。当服务器个数发生变动时,尽量不影响客户端与服务器的映射关系。

  1. Hash 环:圆环的正上方的点代表 0,0 右侧的第一个点为 1,以此类推 2、3、4 … 直到 232 - 1;0 的左侧第一个点代表 232 - 1。这个由 2^32 个点组成的圆环称为 Hash 环
  2. 节点映射:通过 hash(服务器 IP) % 2^32 得到 0 到 232 -1 之间的整数,Hash 环上必定有一个点与之对应。可以使用这个整数代替服务器。
    • 假设有 3 个节点 Node1、Node1、Node3,经过计算后,确定其在环上的位置。
  3. Key 落到服务器上的落键规则:存储一个键值对时,通过 hash(key) 计算 Key 的哈希值 ,确定该 Key 在环上的位置。从该位置顺时针行走,第一台遇到的服务器就是其应该定位到的服务器,并将该键值对存储到该节点上。(A - Node2、B - Node3、C D - Node1。)
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第17张图片

扩展性:在 Node 3 和 Node1 之间新增 Node4,只有 Node3 到 Node1 之间的映射关系需要重新计算。(A - Node2、B - Node3、C - Node4、D - Node1)

容错性:假设 Node1 宕机,Request A、B、C 不会受到影响,只有 D 会被重新定位到 Node2。(D、A - Node2、B - Node3、C - Node4)

数据倾斜问题:节点太少会因为分布不均匀而造成数据倾斜(缓存的对象大部分集中在某一台服务器上)。

哈希槽分区

RedisCluster 使用 16384 个槽(Slot) 管理一段整数集合。若有 5 个节点,每个 Master 节点负责管理一部分 Slot,每个节点管理大约 3276(16384 / 5)个槽。

向 RedisCluster 添加一个 Key 时

  1. 对每个 Key 通过使用 CRC16 算法得到哈希值,使用哈希值对 16384 进行取模(slot = CRC16(key) / 16384)得到对应的 Slot 编号。(这个 Key 应该分布到哪个 Hash Slot)
  2. Client 连接集群时,会获取集群 Slot 配置信息,通过 Key 对应的 Slot 编号从而确定该存储该 Key 的节点。

优点

  1. 解耦数据和节点之间的关系:增加一个 Master 只需将其他 Master 的 Slot 分一部分给新的 Master 管理;移除一个 Master 只需要将该 Master 管理的 Slot 分配给其他 Master。
  2. 某个 Master 挂掉不影响整个集群:因为请求是到 Slot 而不是到 Master。但 Slot 迁移完成之前,请求挂掉的节点也不行。
  3. Slot 迁移过程很快、节点自身维护 Slot 的映射关系、支持 Slot、Node、Key 之间的映射关系的查询。
Redis 笔记(黑马点评 —— 基础篇 + 实战篇)_第18张图片

你可能感兴趣的:(Redis,java,后端,面试,redis,分布式)