(NoSQL, Not Only SQL) 非关系型数据库
关系型数据库:以 表格 的形式存在,以 行和列 的形式存取数据,一系列的行和列被称为表,无数张表组成了 数据库。支持复杂的 SQL 查询,能够体现出数据之间、表之间的关联关系;也支持事务,便于提交或者回滚。
非关系型数据库:以 key-value
的形式存在,可以想象成电话本的形式,人名(key)对应电话号码(value)。不需要写一些复杂的 SQL 语句,不需要经过 SQL 的重重解析,性能很高;可扩展性也比较强,数据之间没有耦合性,需要新加字段就直接增加一个 key-value
键值对即可。
Redis 是 速度极快的、基于内存的,键值型 NoSQL 数据库。
为什么这么快?
支持多种数据类型,包括 String、Hash、List、Set、ZSet 等。
支持数据的持久化,支持 RDB 和 AOF 两种持久化机制。
支持主从集群、分片集群。
Redis 的数据类型
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"}'
String 的三种类型:字符串、整型、浮点型。
Java 的 String 是不可变的,无法修改。Redis 的 String 是动态的,可以修改的。Redis 的 String 在内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。如图所示,当前字符串实际分配的空间为 capacity,一般高于实际的字符串长度 len。当字符串长度小于 1M 时,扩容是对现有空间的成倍增长;如果长度超过 1M 时,扩容一次只会多增加 1M 的空间。String 的最大长度为 512M。
相关操作
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)
Hash 的 value 可以看作一个 Map 集合,Key-Value 形式,只不过 Value 是一个 Map,也就是 Key-Map(Key-Map
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
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。
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"
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"
String
set key value NX EX
)。Hash
Hash:value 可以看作一个 Map 集合,Key-Value 形式,只不过 Value 是一个 Map,也就是 Key-Map(Key-Map
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"
SpringData 是 Spring 中数据操作的模块,包含对各种数据库的集成,其中对 Redis 的集成模块就叫做 SpringDataRedis。
SpringDataRedis 提供了对不同 Redis 客户端的整合(Lettuce 和 Jedis),通过 RedisTemplate 统一 API 操作 Redis。
通过 RedisTemplate 的 opsForValue()
、opsForHash()
、opsForList()
、opsForSet()
、opsForZSet()
方法可以操作 String、Hash、List、Set、ZSet 类型的数据。
导入 spring-boot-starter-data-redis
和 commons-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"
通过以上操作可以发现: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
}
为了节省内存空间,Spring 提供了一个 StringRedisTemplate,它的 key 和 value 的序列化方式默认就是 String,统一使用 String 序列化器。
当需要存储 Java 对象时,手动完成对象的序列化和反序列化。
@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
}
后端代码导入
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);
前端发送请求,提交手机号。
校验手机号是否合格,合格则生成验证码并保存到 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("验证码发送成功");
}
前端发送请求,提交手机号和验证码。
/**
* 登录功能
* @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;
}
登录拦截器负责拦截需要登录的请求:从 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。
Session 的原理
对于分布式系统而言,服务器之间是隔离的,Session 是不共享的,存在 Session 共享问题。
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
编码后的内容(分为三个部分,每个部分之间用 .
连接)
普通 Token 和 JWT 的区别主要体现在 签发 Token 和 验证 Token。
Redis + JWT
/**
* 发送手机验证码并将验证码保存到 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;
}
}
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)
}
)
默认情况下,30 分钟后未访问 Session 会自动销毁。对于 Token 而言,也需要一个这样的机制,即 只要有访问就刷新(重置) Token 的有效期,通过拦截器实现。
@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);
}
}
缓存:一种介于数据永久存储介质和数据应用之间的临时存储介质,也就是 存储在内存中的临时数据,读写性能很高。缓存通过减少 IO 的方式来提高程序的执行效率。
客户端发送请求,根据请求参数先从 Redis 中获取数据。
/**
* 根据 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);
}
/**
* 展示商铺类型(缓存)
*/
@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);
}
数据过期策略:对数据设置 TTL 时间后,超过了 TTL 时间就需要将数据从 Redis 中删除,可以按照不同规则删除。删除规则就是的数据过期策略。
Redis 的过期删除策略:惰性删除 + 定期删除,两种策略配合使用。
惰性删除:获取该 Key 时检查是否过期,未过期则返回,过期则删除。
set name Jack 10
# 获取时判断该 Key 是否过期,过期则删除
get name
定期删除:每隔一段时间对 Redis 中的一部分 Key 进行检查,将其中过期的 Key 删除。(随着时间推移可以遍历一遍 Redis 中存储的 Key)
定期删除可以通过限制删除执行的时长和频率减少删除操作对 CPU 的影响,也能释放过期 Key 占用的内存。但是难以确定删除执行的时长和频率。
数据淘汰策略:当 Redis 中的内存不足时,此时再向 Redis 中添加新 Key,Redis 就会按照某种规则将内存中的数据淘汰(删除)。这种删除数据的规则就是数据淘汰策略。
Redis 支持 8 种数据淘汰策略
若数据库中有 1000 条万数据,Redis 中只能缓存 20 万条数据,如何保证 Redis 中的数据都是热点数据:使用 allkeys-lru 数据淘汰策略。
操作缓存和数据库时需要考虑的三个问题
最佳方案
低一致性需求:使用 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茶餐厅"
}
**客户端请求的数据在 Redis 和数据库中都不存在,缓存永远不会生效,请求永远都打在数据库上。**如果并发的发起大量的这种请求(恶意攻击),每次请求都查询数据库,可能导致数据库宕机(数据库能够承载的并发远不如 Redis)。
客户端请求的数据在 Redis 和数据库中都不存在,为了防止不断的请求:将 空值
缓存到 Redis 中并且设置 TTL 时间后,返回给该请求。
空值
,会造成额外的内存消耗。(设置 TTL 可以缓解)@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);
}
布隆过滤器(Bloom Filter):一个很长的二进制数组(初始化值为 0),通过一系列的 Hash 函数判断该数据是否存在。 布隆过滤器的运行速度快、内存占用小,但是存在误判的可能。
请求进来先查询布隆过滤器,不存在则直接返回,存在则查询 Redis;命中则返回,否则查询数据库并写入 Redis 后返回。(预热缓存的同时需要预热布隆过滤器)
大量缓存同时过期,或者 Redis 服务宕机;导致大量的请求直接访问数据库,造成数据库瞬间压力过大、宕机。
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_THIRTY + RandomUtil.randomInt(30), TimeUnit.MINUTES);
缓存击穿问题也叫热点 Key 问题:一个被高并发访问的 Key 突然失效,在这个失效的瞬间大量请求穿过缓存直接请求数据库,给数据库带来巨大的冲击。
缓存击穿整体过程:
缓存击穿的解决方案:互斥锁(一致性)、逻辑过期(可用性)。
互斥锁的测试:使用 Jmeter 测试,创建线程组,线程数设置为 1000,Ramp-Up 时间为 5 秒。QPS(Query Per Second)为 200。
逻辑过期的测试:线程数设置为 1000,Ramp-Up 时间为 1 秒,先预热数据,过期后修改数据库,此时会经历短暂的数据不一致(重建缓存)。
@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);
}
利用 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);
}
无需考虑缓存雪崩(Redis 宕机除外)、缓存穿透问题:缓存何时过期通过代码控制而非 TTL。需要进行数据预热,缓存未命中时直接返回空。
缓存预热(将热点数据提前存储到 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);
}
@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);
}
每个店铺都可以发布优惠券,保存到 tb_voucher
表中;当用户抢购时,生成订单并保存到 tb_voucher_order
表中。
订单表如果使用数据库自增 ID,会存在以下问题:
全局唯一 ID 的特点
全局唯一 ID 的组成(存储数值类型占用空间更小,使用 long 存储,8 byte,64 bit)
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));
}
优惠券分为普通券和秒杀券,普通券可以任意购买,特价券需要秒杀抢购。
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
/**
* 秒杀下单优惠券
* @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());
}
Jmeter 测试:添加线程组,线程数 200,Ram-Up 时间 0,循环 1 次;请求 localhost:8081/voucher-order/seckill/秒杀券id
;添加 HTTP 信息头管理器设置 Authorization。
通过 Jmeter 测试发现秒杀优惠券的库存为 负数,生成的订单数量超过 100 份。出现了 超卖问题。
超卖问题:假设库存为 1,有线程1、2、3,时刻 t1、t2、t3、t4。
悲观锁,也就是互斥锁,互斥锁的同步方式是悲观的。
将资源锁定,只供一个线程调用,而阻塞其他线程。
乐观锁(通过 版本号机制 & CAS 实现)
不会将资源锁定,但是在 更新时会判断在此期间有没有其他线程更新该数据。
版本号机制
一般是在数据库表中加上一个 version
字段表示 数据被修改的次数。数据被修改时 version
值加 1。
version
值。version
值未发生变化:则提交更新并且 version
值加 1。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;
方式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();
@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());
}
单人下单(一个用户),高并发的情况下:该用户的 10 个线程同时执行到 查询该用户 ID 和秒杀券对应的订单数量
,10 个线程查询到的值都为 0,即未下单。于是会出现一个用户下 10 单的情况。
**此处仍需加锁,乐观锁适合更新操作,插入操作需要选择悲观锁。**若直接在方法上添加 synchronized
关键字,会让锁的范围(粒度)过大,导致性能较差。因此,采用 一个用户一把锁 的方式。
一个用户一把锁
首先,需要保证锁必须是同一把。
**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()) {
...
}
}
其次,事务是在方法执行完毕后由 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);
}
}
最后,方法的调用没有经过动态代理,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());
}
-Dserver.port=8082
即可。nginx -s reload
Reload 重新加载配置文件。Nginx 启动时会解析配置文件,得到需要监听的端口和 IP 地址。Nginx 监听的 IP 为 localhost、端口为 80、路径为 /api。发送 localhost:8080/api/**
请求,会被反向代理到 http://backend
,在 backend 中配置了 127.0.0.1:8081
和 127.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 和 ServerB 的锁对象写的一样(都是 userId.toString().itern()
),但是在不同的 JVM 中,不是同一个锁对象。因此,同一个服务器中的线程可以实现互斥,不同服务器中的线程无法实现互斥。导致 synchronized
锁失效,分布式锁 可以解决。
ServerA 和 ServerB 中各自有一个线程监视器,保证锁对象的范围应用于该服务器中的所有线程,从而实现服务器内所有线程的互斥。分布式锁提供的就是一个外部的线程监视器,让锁对象的范围扩大为 多进程可见。
分布式锁:满足分布式系统或集群模式下的 多进程可见并互斥的锁。核心思想就是 所有线程都使用同一把锁,让程序串行执行。分布式锁需要满足的条件:
获取锁:互斥(确保只有一个线程获取到锁);非阻塞(只尝试一次,成功返回 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。
误删问题的逻辑说明
# 线程 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
解决方案:在线程释放锁时,判断当前这把锁是否属于自己,如果不属于自己,就不会进行锁的释放(删除)。
# 线程 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 的分布式锁的实现(解决误删问题)
相较于最开始分布式锁的实现,只需要增加一个功能:释放锁时需要判断当前锁是否属于自己。(而集群环境下不同 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
请求。
lock:用户ID
作为 Key、线程标识作为 Value 被存入 Redis 中,然后在 Redis 中将锁删除(模拟业务阻塞后超时释放锁)。lock:用户ID
作为 Key、线程标识作为 Value 被存入 Redis 中。分布式锁的原子性问题
因此,需要保证 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 脚本的编写
-- 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]
);
}
集群环境下在不同的 JVM 中,锁对象即使写法一样,但是不是同一个锁对象。同一个服务器中的线程可以实现互斥,不同服务器中的线程无法实现互斥。导致 synchronized
锁失效。
分布式锁:满足分布式系统或集群模式下的 多进程可见并互斥的锁。核心思想就是 所有线程都使用同一把锁,让程序串行执行。
SET key NX EX
获取锁并设设置 TTL 时间。UUID-线程ID
,线程 ID 区分同一个服务器中的不同线程,UUID 区分不同服务器。基于
SETNX
实现的分布式锁存在以下问题
不可重入:同一个线程无法多次获取同一把锁。可重入锁的意义在于防止死锁,synchronized
和 Lock
锁都是可重入的。
不可重试:目前的分布式锁只能尝试一次,合理的情况应该是一个线程在获取锁失败后,能够再次尝试获取锁。
超时释放:业务执行耗时较长,TTL 时间的设置太短,会导致锁的释放,存在安全隐患。
主从一致性:为了提高 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();
}
}
可重入锁的意义在于防止死锁,synchronized
和 Lock
锁都是可重入的。可重入锁借助于一个 state
变量来记录重入的状态。(采用 Hash 结构存储锁:Key 存储锁名称、Field 存储线程标识、Value 存储 state)
假设在方法 A 中调用方法 B,两个方法的执行都需要先获取到锁:
state = 1
,A 调用 B 并记录重入状态 state = 2
。B 执行完后修改重入状态为 state = 1
,A 执行完后 state = 0
,此时可以释放锁。重入流程
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])
发布一个锁释放的消息,唤醒其他等待获取锁的线程,也就是下面可重试原理中的一部分。
/**
* 尝试获取锁
* 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。重试原理
time
减去获取锁过程中消耗的时间,若 time < 0
,返回 false,获取锁失败。time > 0
,不会立即重试,而是订阅一个 其他线程释放锁的消息。(释放锁的代码中有一个 redis.call('publish', ...)
脚本,表示在释放锁后发布锁释放消息)time
是否大于 0,若小于 0 则直接返回 false,获取锁失败。time
是否大于 0,若小于 0 则直接返回 false,获取锁失败。若大于 0,则开始重试获取锁。time
减去获取锁过程中消耗的时间并判断 time
是否大于 0。大于 0,则判断 ttl
与 time
的值,谁更小则等待更小的那一个时间。time
和 ttl
不小于 0),或者 time
或 ttl
小于 0 则表示获取锁失败。获取到锁后,开启一个线程对锁的 TTL 时间进行续约(也就是看门狗线程)。
TTL / 3
秒后执行续约(TTL 默认为 30),将 TTL 重置为 30 秒。续约成功则递归调用自己,TimerTask 并且在 10s 后触发;循环往复,不停的续约。(保证锁永不过期)unlock
释放锁方法中有一个方法用于取消看门狗线程的定时任务。注意:只有 leaseTime 为 -1 时,才会开启 WatchDog 线程执行定时任务。也就是获取锁时不指定 TTL 时间 —— lock.tryLock(1, TimeUnit.SECONDS);
。
为了提高 Redis 的可用性,一般会搭建一个主从集群:向主节点写数据时,主节点异步的将数据同步给从节点。
为解决上述问题,Redisson 提出了 MutiLock 锁:获取锁时需要将数据写入到每一个节点中,全部写入成才算获取锁成功。
搭建主从集群,获取锁时将数据写入到每一个主节点中:若某个主节点宕机并且尚未完成主从同步,从节点会变成新的主节点(当前节点没有存入锁)。此时某个线程获取锁是无法获取到的,因为获取锁时必须将数据写入到所有的主节点才能成功获取到。
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...");
}
}
}
m1
和 m2
的 tryLock
处,Redis 集群中的三个节点中都存储了 Hash 结构的锁数据,并且 state 分别为 1 和 2。m2
的 unlock
处,Redis 集群中的三个节点中都存储了 Hash 结构的锁数据,并且 state 为 1。m1
的 unlock
处,Redis 集群中的三个节点中的锁数据都被删除了。setnx
的互斥性、ex
避免死锁、释放锁时判断线程标识避免误删。用户发送请求到 Nginx,Nginx 访问 Tomcat,Tomcat 中的程序串行执行:
以上每一步操作都是串行执行的(按照代码顺序从上到下),并且 1、3、5、6 的操作都需要与数据库进行交互,从而导致程序执行的很慢。
秒杀优化方案
新增秒杀券的同时将秒杀券信息保存到 Redis 中,然后将 2、4 中逻辑判断的操作放到 Redis 中:优惠券库存充足并且该用户未下过单。
2、4 中的逻辑判断执行成功就代表该用户可以下单,直接返回下单成功给用户,然后再开启一个线程执行耗时较久的下单操作。
秒杀优化的实现思路
秒杀券库存使用 String 存储(Key - 优惠券标识,Value - 库存),秒杀券的订单使用 Set 存储(Key - 订单标识,Value - 用户 ID)。
秒杀优化的代码实现
新增秒杀券的同时将其存储到 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;
// 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, "下单失败");
}
消息队列(MQ, Message Queue):消息传输过程中保存消息的容器。
参与消息传递的双方称为 生产者 和 消费者,生产者负责发送消息,消费者负责处理消息。队列 Queue 是一种先进先出(FIFO)的数据结构,所以消费消息时也是按照顺序来消费的。
Redis 提供了三种消息队列的实现方式:List 结构、Pub/Sub、Stream。
Redis 的 List 数据结构是一个双向链表,可以通过 LPUSH + RPOP 或 RPUSH + LPOP 实现。
编写消费逻辑时,需要不断地从队列中拉取消息进行处理,一般是一个 死循环。当队列中没有消息的时候,消费者执行 RPOP 或 LPOP 时会返回 NULL,并不会像阻塞队列那样阻塞并等待消息。而是不断的拉取消息,从而造成 CPU空转。
通过 BLPOP 或 BRPOP 可以实现阻塞效果。(B means Block)该命令还支持传入一个超时时间:0 表示不设置超时,直到有新消息才返回;否则在指定的超时时间后仍未获取到消息后返回 NULL。
PubSub(发布订阅)可以实现 重复消费,消费者可以订阅一个或多个 channel,生产者向对应 channel 发送消息后,所有订阅者都能收到相关消息。
Pub/Sub 的实现十分简单,不基于任何数据结构,也没有任何的数据存储;只是单纯的为生产者和消费者建立 数据转发通道,将符合规则的数据从一端发到另一端。
Redis 提供了 PUBLISH、SUBSCRIBE 命令实现发布和订阅。
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.* 的消费者能获取到消息
消费者必须在生产者发布消息之前订阅队列,否则消息会丢失。
优点 :支持 多生产者、多消费者处理消息。
缺点 :不支持数据持久化,无法避免消息丢失,消息堆积也会导致数据丢失。
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 消息队列的特点
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备以下特点:
创建消费组
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 的特点
创建一个 名为 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();
}
}
}
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());
}
在 Blog 实体类添加一个 isLike
属性,标识是否被当前用户点赞。若当前用户已点赞,则点赞按钮高亮显示,前端通过判断 isLike
的值实现。
/**
* 是否点赞
*/
@TableField(exist = false)
private Boolean isLike;
修改 getBlogById()
和 getHotBlogs()
方法,新增一个 判断当前登录用户是否点赞过某 Blog。
用户点赞时判断该用户是否点过赞:未点过赞则点赞数 +1,已点过赞则点赞数 -1。一个用户只能点赞一次,再次点赞则取消点赞。
修改
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("取消点赞成功");
}
}
笔记的详情页面中显示:最早给该笔记点赞的 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);
}
/**
* 关注或取关
* @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);
}
关注时,以当前用户 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);
}
关注推送也叫做 Feed 流,下拉刷新即可获取推送的信息。例如:微博、微信朋友圈、B站等。
Feed 流的两种常见模式:
在本例的个人主页中,基于关注实现 Feed 流,采用 Timeline 模式。该模式的实现方案有三种:拉模式、推模式、推拉结合。
拉模式,也叫读扩散。(很少使用)
推模式,也叫写扩散。(适用于粉丝量较少,千万级以内)
当张三发送一个消息时,会主动将张三的内容发送到其粉丝的收件箱中,此时粉丝读取则无需再去临时拉取。
推拉结合,也叫读写混合,兼具读扩散和写扩散的优点。
Feed 流中的数据会不断的更新,数据的下标也在变化,因此不能采用传统的分页模式。
传统分页
limit 1, 5
读取到 10、9、8、7、6 五条数据。limit 5, 5
读取的 6、5、4、3、2 五条数据。(数组的头部新增了数据,导致读取到了重复的数据)滚动分页
记录每次查询的最后一条消息,下次查询时从该位置开始读取数据。(因为是倒序进行查询,起始数据为 ∞ 无穷大)
limit ∞, 5
读取到 10、9、8、7、6 五条数据,并且记录读取到的最后一条数据 lastId = 6
。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)]
发布 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"
lastId
为当前时间戳,每次查询后 lastId
为上一次查询中最小的时间戳。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);
}
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"
将数据库中的数据导入到 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);
}
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 位。
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
/**
* 签到
*/
@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("签到成功");
}
从最后一次签到向前统计,直到遇到第一次未签到为止;计算总的签到次数,就是连续签到天数。
BITFIELD key GET u[dayOfMonth] 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);
}
HyperLogLog 数据结构
HyperLogLog(HLL) 用于确定非常大的集合的基数,而不需要存储其所有值。
{1,3,5,7,5,7}
的基数集为 {1,3,5,7,8}
。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 :线程内缓存用户信息。
Redis 是基于内存的数据库,服务宕机会导致数据丢失。可以通过数据库恢复数据。但是数据库有性能瓶颈,大量的数据恢复会给数据库带来巨大压力。此外,数据库性能不如 Redis,导致程序响应慢。因此,Redis 需要实现数据的持久化,避免从数据库中恢复数据。
Redis 持久化:防止数据丢失,以及服务重启时能够恢复数据。Redis 的持久化通过 RDB 和 AOF 实现。
RDB 全称 Redis DataBase Backup file(Redis 数据备份文件 / Redis 数据快照):将内存中的数据生成快照保存到磁盘上。当 Redis 宕机重启后,从磁盘中读取快照文件并恢复数据。(RDB 文件默认保存在当前运行目录中)
触发 RDB 持久化的方式:手动触发 和 自动触发。
手动触发:
save
和bgsave
命令。
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
AOF 全称 Append Only File(追加文件):Redis 处理的每一个写命令,都会记录到 AOF 文件中,可以看做命令的日志文件。
只要从头到尾执行一次 AOF 文件中的所有写命令,即可恢复 AOF 文件所记录的数据。
# Redis
> set hello world
# AOF
$3
set
$5
hello
$5
world
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
RDB | AOF | |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
数据完整性 | 不完整,两次快照之间会丢失数据 | 相对完整,取决于同步策略 |
文件大小 | 文件体积小 | 记录命令,文件体积很大 |
宕机恢复速度 | 很快(直接将数据加载到内存) | 慢(执行 AOF 中记录的命令) |
数据恢复优先级 | 低,因为数据完整性不如 AOF | 优先采用 AOF 恢复数据(数据完整性更高) |
使用场景 | 可容忍数分钟的数据丢失、更快的启动速度 | 数据安全性要求极高 |
Redis 4.0 支持 RDB 和 AOF 的混合持久化。默认是关闭的,需要配置:
aof-user-rdb-preamble yes
单节点的 Redis 并发能力有限,可以通过搭建主从集群提高 Redis 的并发能力,实现读写分离。
读写分离:Redis 主从架构中,Master 节点负责处理写请求,Slave 节点只处理读请求。
主从同步:Master 节点接收到写请求并处理后,告知 Slave 节点数据发生了改变,Master 节点将写操作同步给 Slave节点,保持主从节点数据一致。
全量同步:主从第一次建立连接时会执行 全量同步,将主节点的所有数据都拷贝给从节点。全量同步需要进行一次 RDB,然后将 RDB 文件通过网络传输给 Slave,成本太高。因此只有第一次为全量同步,其它多数为增量同步。
# --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
Slave 节点宕机恢复后可以找 Master 节点同步数据,Redis 提供了 哨兵机制(Sentinel) 解决 Master 节点宕机的问题。
监控集群:Redis 中多个 Sentinel 组成集群,持续监控 Master、Slave 是否按预期工作。
故障转移:Master 节点宕机时自动选择一个最优的 Slave 节点切换为 Master 节点,故障实例恢复后主从进行了切换。
配置中心:Client 连接 Redis 集群时先连接到 Sentinel 集群,通过 Sentinel 查询 Master 节点的地址后再连接到 Master 节点进行数据交换。
消息通知:Sentinel 将故障转移的结果推送给 Client,无需重启即可自动完成节点切换。
监控功能
Sentinel 集群基于 心跳机制 检测服务状态,每隔 1 秒向集群的每个实例发送 ping 命令。
主观下限(Subject Down):若某个 Sentinel 节点发现某实例未在规定时间内响应,则认为该实例 主观下线。
客观下线(Objective Down):若超过指定数量(quorum)的 Sentinel 都认为该实例主观下线,则该实例 客观下线。(quorum 的值最好设置为超过 Sentinel 实例的一半)
选举新的 Master
一旦发现 Master 宕机,Sentinel 需要在 Slave 中选择一个作为新的 Slave:
故障转移
假设 Master(7001)、Slave(7001)、Slave(7002),Master 7001 宕机,Slave 7002 被选举为新的 Master:
slaveof no one
命令,让该节点成为 Master。slaveof 127.0.0.1 7002
命令,让这些 Slave 成为新 Master 的从节点,开始从 Master 上同步数据。单 Master 架构中 Master 和 Slave 的数据一样的、能容纳的数据量也一样。数据量超过 Master 的内存时,Redis 会使用 LRU 算法清除部分数据。无法容纳更多数据,只能通过 Redis Cluster(Redis 分布式解决方案)解决单 Master 架构的内存、并发、流量等瓶颈。
通过 Redis 使用分布式存储,一般有三种解决方案:哈希取余分区、一致性哈希算法分区、哈希槽分区。
n 个 Redis 实例构成集群,每次读写操作都需要通过 hash(key) % n
计算数据映射到哪一个节点上。
一致性哈希算法主要是为了解决 分布式缓存的数据变动和映射的问题。当服务器个数发生变动时,尽量不影响客户端与服务器的映射关系。
hash(服务器 IP) % 2^32
得到 0 到 232 -1 之间的整数,Hash 环上必定有一个点与之对应。可以使用这个整数代替服务器。
hash(key)
计算 Key 的哈希值 ,确定该 Key 在环上的位置。从该位置顺时针行走,第一台遇到的服务器就是其应该定位到的服务器,并将该键值对存储到该节点上。(A - Node2、B - Node3、C D - Node1。)扩展性:在 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 时
slot = CRC16(key) / 16384
)得到对应的 Slot 编号。(这个 Key 应该分布到哪个 Hash Slot)优点