黑马程序员Redis入门到实战教程
SQL VS NoSQL
SQL 结构化
NoSQL 非结构化
redis-cli -h 127.0.0.1 -p 6379 -a 123456Redis
单线程 每个命令具有原子性
通用命令
redis-cli -h 127.0.0.1 -p 6379 -a 123456KEYS
查询
KEYS pattern可用*通配符
127.0.0.1:6379> KEYS *
1) "name"
127.0.0.1:6379> KEYS n*
1) "name"DEL
删除
DEL key [key ...]EXISTS
判断key是否存在
EXISTS key [key ...]EXPIRE
设置有效期
EXPIRE key secondsTTL
查看有效期
TTL key- -1 永久有效
- -2 不存在
数据类型
String
格式
string 字符串
int 整数型
float 浮点型
SET
添加或修改已经存在的一个String类型的键值对
127.0.0.1:6379> SET name hbc
OKGET
根据key获取String类型的value
127.0.0.1:6379> GET name
"hbc"MSET
批量添加多个String类型的键值对
127.0.0.1:6379> MSET no 2020113276 name hbc
OKMGET
根据多个key获取多个String类型的value
127.0.0.1:6379> MGET no name
1) "2020113276"
2) "hbc"INCR
让一个整形的key自增1
127.0.0.1:6379> SET num1 2023
OK
127.0.0.1:6379> INCR num1
(integer) 2024INCRBY
让一个整形的key自增并指定步长
127.0.0.1:6379> INCRBY num1 2
(integer) 2026INCRBYFLOAT
让一个浮点类型的数字自增并指定步长 (步长必须指定)
127.0.0.1:6379> SET num2 10.1
OK
127.0.0.1:6379> INCRBYFLOAT num2 0.5
"10.6"127.0.0.1:6379> SET num2 2023.11
OK
127.0.0.1:6379> INCRBYFLOAT num2 0.1
"2023.20999999999999996"SETNX
添加一个string类型的键值对,前提是这个key不存在,否则不执行
127.0.0.1:6379> set name hbc
OK
127.0.0.1:6379> SETNX name hbd
(integer) 0SETEX
添加一个string类型的键值对,并且指定有效期
127.0.0.1:6379> SETEX name 15 hbc
OK
127.0.0.1:6379> TTL name
(integer) 13
127.0.0.1:6379> TTL name
(integer) 11Key的层级
Redis的key允许有多个单词形成层级结构,单词之间用:隔开
[项目名]:[业务名]:[类型]:[id]
- heima:user:1
- heima:product:1
如果Value是一个Java对象,例如一个User对象,则可以将对象序列化为 JSON 字符串后存储
| KEY | VALUE |
|---|---|
| heima:user:1 | {"id":1, "name": "Jack", "age": 21} |
| heima:product:1 | {"id":1,"name": "小米11", "price":4999} |
127.0.0.1:6379> set heima:user:1 '{"id":1, "name": "Jack", "age": 21}'
OK
127.0.0.1:6379> set heima:product:1 '{"id":1,"name": "小米11", "price":4999}'
OK
Hash
| KEY | VALUE | |
|---|---|---|
| filed | value | |
| heima:user:1 | name | HBC |
| age | 18 | |
| heima:user:2 | name | FNN |
| age | 18 | |
HSET
添加或修改hash类型key的field的值
HSET key field value [field value ...]127.0.0.1:6379> HSET heima:user:3 name kokomi
(integer) 1
127.0.0.1:6379> HSET heima:user:3 age 18 sex woman
(integer) 2
HGET
获取一个hash类型key的field的值
HGET key field127.0.0.1:6379> HGET heima:user:3 name
"kokomi"HMSET
批量添加多个hash类型key的field的值
HMSET key field value [field value ...]127.0.0.1:6379> HMSET heima:user:4 name Klee age 18 sex woman
OKHMGET
批量添加多个hash类型key的field的值
HMGET key field [field ...]127.0.0.1:6379> HMGET heima:user:4 name age sex
1) "Klee"
2) "18"
3) "woman"HGETALL
获取一个hash类型的key中的所有的field和value
HGETALL key127.0.0.1:6379> HGETALL heima:user:3
1) "name"
2) "kokomi"
3) "age"
4) "18"
5) "sex"
6) "woman"HKEYS
获取一个hash类型的key中所有的field
HKEYS key127.0.0.1:6379> HKEYS heima:user:3
1) "name"
2) "age"
3) "sex"HVALS
获取一个hash类型的key中的所有的value
HVALS key127.0.0.1:6379> HVALS
1) "kokomi"
2) "18"
3) "woman"HINCRBY
让一个hash类型的key的字段值自增并指定步长
HINCRBY key field increment127.0.0.1:6379> HINCRBY heima:user:3 age 2
(integer) 20
127.0.0.1:6379> HGETALL heima:user:3
1) "name"
2) "kokomi"
3) "age"
4) "20"
5) "sex"
6) "woman"HSETNX
添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
HSETNX key field value127.0.0.1:6379> HSETNX heima:user:3 name KokoMi
(integer) 0List
与Java的linkedList类似,可以看做是双向链表结构
- 有序
- 可以重复
| 左(Left) | 队首 | 0 | 1 | 2 | 队尾 | 右(Right) |
|---|
LPUSH
向列表左侧插入一个或多个元素
LPUSH key element [element ...]127.0.0.1:6379> LPUSH num 1 2 3
(integer) 3| 3 | 2 | 1 |
|---|
LPOP
移除并返回列表左侧的第一个元素,没有则返回nil
LPOP key [count]127.0.0.1:6379> LPOP num
"3"| 2 | 1 |
|---|
RPUSH
向列表右侧插入一个或多个元素
RPUSH key element [element ...]127.0.0.1:6379> RPUSH num 4 5
(integer) 4| 2 | 1 | 4 | 5 |
|---|
RPOP
移除并返回列表右侧的第一个元素
RPOP key [count]127.0.0.1:6379> RPOP num
"5"| 2 | 1 | 4 |
|---|
LRANGE
返回一段角标范围内所有元素
返回但不移除元素,角标从0开始
LRANGE key start stop127.0.0.1:6379> LRANGE num 1 2
1) "1"
2) "4"BLPOP
Blocking 阻塞
与LPOP类似,但在没有元素时等待指定时间,不会直接返回nil
时间为秒
BLPOP key [key ...] timeout127.0.0.1:6379> BLPOP num3 50127.0.0.1:6379> LPUSH num3 666
(integer) 1127.0.0.1:6379> BLPOP num3 50
1) "num3"
2) "666"
(4.53s)BRPOP
模拟栈
入口出口在同一边
- LPUSH和LPOP (左进左出)
- RPUSH和RPOP (右进右出)
模拟队列
入口出口在不同一边
- LPUSH和RPOP (左进右出)
- RPUSH和LPOP (右进左出)
模拟阻塞队列
入口出口在不同一边
出队时采用BLPOP或BRPOP
Set
与Java的HashSet类似,可以看做是一个value为null的HashMap
无序 hash算法计算角标
元素不可重复
查找快
比Java的HashSet多功能,支持交集,并集,差集
SADD
向Set中添加一个或多个元素
SADD key member [member ...]127.0.0.1:6379> SADD s1 A B C
(integer) 3SCARD
返回Set中元素个数
SCARD key127.0.0.1:6379> SCARD s1
(integer) 3SMEMBERS
member 成员
获取Set中的所有元素
SMEMBERS key127.0.0.1:6379> SMEMBERS s1
1) "C"
2) "A"
3) "B"SISMEMBER
S-IS-MEMBER
判断一个元素是否存在于Set中
127.0.0.1:6379> SISMEMBER key member127.0.0.1:6379> SISMEMBER s1 C
(integer) 1
127.0.0.1:6379> SISMEMBER s1 D
(integer) 0SREM
remove 移除
移除Set中的指定元素
SREM key member [member ...]127.0.0.1:6379> SREM s1 C
(integer) 1
127.0.0.1:6379> SISMEMBER s1 C
(integer) 0SINTER
SINTER key [key ...]求key1和key2交集
| s1 | A | B | C |
|---|---|---|---|
| s2 | B | C | D |
127.0.0.1:6379> SINTER s1 s2
1) "C"
2) "B"SDIFF
SDIFF key [key ...]求key1和key2差集
127.0.0.1:6379> SDIFF s1 s2
1) "A"
127.0.0.1:6379> SDIFF s2 s1
1) "D"SUNION
SUNION key [key ...]求key1和key2并集
127.0.0.1:6379> SUNION s1 s2
1) "A"
2) "C"
3) "D"
4) "B"练习
张三的好友有:李四、王五、赵六
李四的好友有:王五、麻子、二狗
127.0.0.1:6379> SADD zs lisi wangwu zhaoliu
(integer) 3
127.0.0.1:6379> SADD ls wangwu mazi ergou
(integer) 3利用Set的命令实现下列功能:
计算张三的好友有几人
127.0.0.1:6379> SCARD zs (integer) 3计算张三和李四有哪些共同好友
127.0.0.1:6379> SINTER zs ls 1) "wangwu"查询哪些人是张三的好友却不是李四的好友
127.0.0.1:6379> SDIFF zs ls 1) "zhaoliu" 2) "lisi"查询张三和李四的好友总共有哪些人
127.0.0.1:6379> SUNION zs ls 1) "ergou" 2) "lisi" 3) "wangwu" 4) "zhaoliu" 5) "mazi"判断李四是否是张三的好友
127.0.0.1:6379> SISMEMBER zs lisi (integer) 1判断张三是否是李四的好友
127.0.0.1:6379> SISMEMBER ls zhangsan (integer) 0将李四从张三的好友列表中移除
127.0.0.1:6379> SREM zs lisi (integer) 1 127.0.0.1:6379> SMEMBERS zs 1) "zhaoliu" 2) "wangwu"
SortedSet
可排序的Set集合,与Java的TreeSet有些类似,但底层数据结构差别很大。
SortedSet每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加hash表。
- 可排序
- 元素不重复
- 查询速度快
适合做排行榜
排名默认升序
在命令的Z后面添加REV reverse 反转
ZADD
添加一个或多个元素到sorted set,如果已经存在则更新其score值
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]ZREM
删除sorted set中的指定元素的score值
ZREM key member [member ...]ZSCORE
获取sorted set 中指定元素的score值
ZSCORE key memberZRANK
获取sorted set 中的指定元素的排名
ZRANK key memberZCARD
获取sorted set中的元素个数
ZCARD keyZCOUNT
统计score值在给定范围内所有元素的个数
ZCOUNT key min maxZINCRBY
ZINCRBY key increment member让sorted set 中的指定元素自增,步长为指定的increment值
ZRANGE
按照score排序后,获取指定排名范围内的元素
ZRANGE key min max [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES]ZRANGEBYSCORE
按照score排序后,获取指定score范围内的元素
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]ZDIFF
差集
ZDIFF numkeys key [key ...] [WITHSCORES]ZINTER
交集
ZINTER numkeys key [key ...] [WEIGHTS weight] [AGGREGATE SUM|MIN|MAX] [WITHSCORES]ZUNION
并集
ZUNION numkeys key [key ...] [WEIGHTS weight] [AGGREGATE SUM|MIN|MAX] [WITHSCORES]练习
将班级的下列学生得分存入Redis的SortedSet中:
Jack 85, Lucy 89, Rose 82, Tom 95,Jerry 78,Amy 92,Miles 76
ZADD student 85 Jack 89 Lucy 82 Rose 95 Tom 78 Jerry 92 Amy 76 Miles
127.0.0.1:6379> ZADD student 85 Jack 89 Lucy 82 Rose 95 Tom 78 Jerry 92 Amy 76 Miles
(integer) 7删除Tom同学
127.0.0.1:6379> ZREM student Tom (integer) 1获取Amy同学的分数
127.0.0.1:6379> ZSCORE student Amy "92"获取Rose同学的排名
- 默认升序
127.0.0.1:6379> ZRANK student Rose (integer) 2- 降序
127.0.0.1:6379> ZREVRANK student Rose (integer) 3查询80分以下有几个学生
127.0.0.1:6379> ZCOUNT student 0 80 (integer) 2查出成绩80分以下的所有同学
127.0.0.1:6379> ZRANGEBYSCORE student 0 80 1) "Miles" 2) "Jerry"给Amy同学加2分
127.0.0.1:6379> ZINCRBY student 2 Amy "94"查出成绩前3名的同学
127.0.0.1:6379> ZREVRANGE student 0 2 1) "Amy" 2) "Lucy" 3) "Jack"
SringData Redis
Template要定义好自己的序列化工具
- key用普通字符串
- value可以随意(json可以自动完成序列化和反序列化)
value使用json序列化会携带class信息占用空间,为了节省空间,统一使用string序列化器,在需要序列化时由程序员手动指定类
Spring默认提供了一个StringRedisTemplate类,它的key和value的序列化方式默认就是String方式。省去了我们自定义RedisTemplate的过程
@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON工具 (类似fastjson)
private static final ObjectMapper mapper = new ObjectMapper();
@Test
void testStringTemplate() throws JsonProcessingException {
// 准备对象
User user = new User("虎哥", 18);
// 手动序列化
String json = mapper.writeValueAsString(user);
// 写入一条数据到redis
stringRedisTemplate.opsForValue().set("user:200", json);
// 读取数据
String val = stringRedisTemplate.opsForValue().get("user:200");
// 手动反序列化(放入字符串和字节码对象)
User user1 = mapper.readValue(val, User.class);
System.out.println("user1 = " + user1);
}黑马点评
登录
使用session校验登录状态
session id保存在cookie中
保存到ThreadLocal线程域对象,每一个请求都是一个线程
前端传JSON要用@RequestBody注解
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return Result.fail("功能未完成");
}mybatisplus的ServiceImpl已经写好了一些单表简单的操作,可以直接使用
package com.hmdp.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
// 不符合
return Result.fail("手机号码格式错误!");
}
// 校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
log.debug("cacheCode:{}-code:{}",cacheCode,code);
if (cacheCode == null || !cacheCode.toString().equals(code)){
// 不一致
return Result.fail("验证码错误!");
}
// 一致,则查询用户
// query()是MybatisPlus提供的
User user = query().eq("phone", phone).one();
// 判断用户是否存在
if (user == null){
// 不存在 创建新用户并保存
user = createUserWithPhone(phone);
}
// 保存用户信息到session中
// session.setAttribute("user",user);
// 使用工具把user属性复制到UserDTO中
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
private User createUserWithPhone(String phone) {
// 创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+ RandomUtil.randomString(10));
// 保存用户到数据库
save(user);
return user;
}
}拦截器
编写拦截器
ThreadLocal
public class LoginInterceptor implements HandlerInterceptor {
// 处理请求之前
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取session
HttpSession session = request.getSession();
// 获取session中的用户
Object user = session.getAttribute("user");
// 判断session中用户是否存在
if (user == null){
// 不存在用户 拦截 返回401状态码
response.setStatus(401);
return false;
}
// 存在 保存用户信息到ThreadLocal线程对象域
// Object user要强转
UserHolder.saveUser((UserDTO) user);
// 放行到Controller true
return true;
}
// 请求处理完毕后
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户,避免内存泄露
UserHolder.removeUser();
}
}配置拦截器
在MVC的config里
@Configuration
拦截器注册器
拦截路径,排除
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// 添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// InterceptorRegistry 拦截器注册器
// excludePathPatterns 排除路径
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}负载均衡集群的session共享
多台tomcat数据拷贝 空间浪费 时间延迟
Redis集群
验证码保存到redis
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
// 不符合
return Result.fail("手机号码格式错误!");
}
// 符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 将验证码保存到session
// session.setAttribute("code", code);
// 将验证码保存到redis
// login:code:phone 业务:类型:id 2, TimeUnit.MINUTES 2分钟过期
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 发送验证码
log.debug("发送验证码成功,验证码:{}", code);
return Result.ok();
}登录时保持用户到Redis
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
// 不符合
return Result.fail("手机号码格式错误!");
}
// 从redis获取并校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
log.debug("cacheCode:{}-code:{}",cacheCode,code);
// if (cacheCode == null || !cacheCode.toString().equals(code)){
if (cacheCode == null || !cacheCode.equals(code)){
// 不一致
return Result.fail("验证码错误!");
}
// 一致,则查询用户
// query()是MybatisPlus提供的
User user = query().eq("phone", phone).one();
// 判断用户是否存在
if (user == null){
// 不存在 创建新用户并保存
user = createUserWithPhone(phone);
}
// 保存用户信息到redis中
// 随机生成token作为登录令牌 用UUID作为token 专业 JWT JSON Web Token
String token = UUID.randomUUID().toString(true);
// 将User对象转换为HashMap
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userDTOMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
);
// 存到redis 一次存完防止与数据库多次交互 不支持同时设置有效期
String tokenKey = LOGIN_USER_KEY + token;
// java.lang.ClassCastException: java.lang.Long cannot be cast to java.lang.String
// UserDTO的id是Long类型,但stringRedisTemplate期望这个Map的值都是String类型的
/*userDTOMap.forEach((key, value) -> {
if (value instanceof Long) {
userDTOMap.put(key, String.valueOf(value));
}
});*/
stringRedisTemplate.opsForHash().putAll(tokenKey,userDTOMap);
// 设置有效期 session有效期是30分钟内使用会重新刷新有效期 redis可以在拦截器刷新
stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}拦截器刷新token有效期
https://blog.csdn.net/m0_45406092/article/details/115209508
https://www.jb51.net/article/229563.htm
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
// 这里不能使用@Autowired这种自动注入,要用构造函数,因为这个类是自己配置时手动new出来的,
// 不是通过component构建出来的,
// 这个类不是spring创建的而是我们自己new的
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 处理请求之前
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从请求头获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
// 不存在,拦截 返回401
response.setStatus(401);
return false;
}
// 用token获取redis中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userDTOMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 判断session中用户是否存在
// userDTOMap不用判断null,entries如果为空userDTOMap会赋值为null
if (userDTOMap.isEmpty()){
// 不存在用户 拦截 返回401状态码
response.setStatus(401);
return false;
}
// 将hashmap转换为UserDTO
// fillBeanWithMap将map填充到bean false-不忽略转换中的错误
UserDTO userDTO = BeanUtil.fillBeanWithMap(userDTOMap, new UserDTO(), false);
// 存在 保存用户信息到ThreadLocal线程对象域
UserHolder.saveUser(userDTO);
// 刷新token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 放行到Controller
return true;
}
}配置拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// @Configuration说明spring会构建这个类的对象,可以使用自动注入
@Resource
private StringRedisTemplate stringRedisTemplate;
// 添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// InterceptorRegistry 拦截器注册器
// excludePathPatterns 排除路径
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login"
);
}
}非登录功能拦截器
解决前面登录功能才刷新token时间的问题
拦截器1-拦截所有路径
RefreshTokenInterceptorpublic class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
// 这里不能使用@Autowired这种自动注入,要用构造函数,因为这个类是自己配置时手动new出来的,
// 不是通过component构建出来的,
// 这个类不是spring创建的而是我们自己new的
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 处理请求之前
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从请求头获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
// 不存在,直接往下 让下一次判断是否需要登录拦截
return true;
}
// 用token获取redis中的用户
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userDTOMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 判断session中用户是否存在
// userDTOMap不用判断null,entries如果为空userDTOMap会赋值为null
if (userDTOMap.isEmpty()){
// 不存在,直接往下 让下一次判断是否需要登录拦截
return true;
}
// 将hashmap转换为UserDTO
// fillBeanWithMap将map填充到bean false-不忽略转换中的错误
UserDTO userDTO = BeanUtil.fillBeanWithMap(userDTOMap, new UserDTO(), false);
// 存在 保存用户信息到ThreadLocal线程对象域
UserHolder.saveUser(userDTO);
// 刷新token有效期
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
// 放行到Controller
return true;
}
// 请求处理完毕后
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户,避免内存泄露
UserHolder.removeUser();
}
}拦截器2-拦截需要登录的路径
LoginInterceptorpublic class LoginInterceptor implements HandlerInterceptor {
// 处理请求之前
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断是否需要拦截 (ThreadLocal域中是否有用户)
if (UserHolder.getUser() == null){
// 没有用户,需要拦截,设置状态码
response.setStatus(401);
return false;
}
// 放行到Controller
return true;
}
}配置拦截器
添加新拦截器
设置拦截器顺序
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// @Configuration说明spring会构建这个类的对象,可以使用自动注入
@Resource
private StringRedisTemplate stringRedisTemplate;
// 添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// InterceptorRegistry 拦截器注册器
// excludePathPatterns 排除路径
// order(0) 默认0,越低越先执行,一样就看添加顺序
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login"
).order(1);
// 所有路径刷新token拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}缓存
数据交换的缓冲区
缓存作用
- 降低后端的负载
- 提高读写效率,降低响应时间
缓存成本
- 数据一致性
- 代码维护成本
- 运维成本-集群
缓存更新策略
内存淘汰
内存不足时自动淘汰部分数据-redis自带
超时剔除
设置过期时间
主动更新
修改数据库同时更新缓存
- 一致性:好
- 维护成本:高
业务场景
低一致性需求
例如店铺类型的查询缓存
使用内存淘汰机制
高一致性需求
例如店铺详情查询的缓存
主动更新,并以超时剔除作为兜底方案
主动更新
- 先删除缓存再操作数据库
- 先操作数据库再删除缓存√
先操作数据库再删除缓存比先删除缓存再操作数据库出现错误的几率小
确保数据库和缓存操作的原子性
缓存穿透
客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
别有用心的人会攻击数据库
缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 消耗内存
- 短期不一致(过期时间设置2分钟)
布隆过滤(面试)
判断位置是0还是1 (自带的布隆过滤:bigMap)
优点:内存占用少,没有多余的key
缺点:
实现复杂
存在误判可能(布隆过滤器认为不存在一定不存在,认为存在时可能不存在)
增加id复杂度,校验id格式
加强用户权限校验
热点参数限流

缓存雪崩
同一时间段内大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
- 给不同的key的TTL添加随机值(防止同时删除key和请求mysql数据库)
- Redis集群(哨兵)
- 给缓存业务降级限流,保值数据库健康
- 给业务添加多级缓存(Nginx,DVM)
缓存击穿(热点key重建耗时长)
等(几百ms)
也叫热点key问题,就是一个被高并发访问并且**缓存重建业务较复杂(几百ms)**的key突然失效了,无数的请求会瞬间给数据库带来巨大的冲击。比如举办某种活动时。

互斥锁
- 优点
- 没有额外的内存消耗
- 保证一致性
- 实现简单
- 缺点
- 线程需要等待,性能受影响
- 可能有死锁风险
- 优点
逻辑过期+互斥锁
- 优点
- 线程无需等待,性能好
- 缺点
- 不保证一致性
- 额外内存消耗(存过期时间)
- 实现复杂
- 优点
一致性 可用性 之间抉择,看具体情况


缓存商铺信息
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 先从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断商铺信息是否存在Redis中
if (StrUtil.isNotBlank(shopJson)){
// 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 不存在,到mysql数据库查询
Shop shop = getById(id);
// 判断数据库里有没有信息
if (shop == null) {
// 不存在
return Result.fail("店铺不存在!");
}
// 存在,保存到Redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
}更新商铺信息
保证操作的原子性
果是分布式系统,可能更新和删除不是同一个系统做,通过MQ通知另外一个系统, TCC方案
@Override
@Transactional // 事物
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 更新MySQL数据库
updateById(shop);
// 删除Redis缓存
// 如果是分布式系统,可能更新和删除不是同一个系统做,通过MQ通知另外一个系统, TCC方案
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}缓存商铺空值信息
ShopServiceImpl@Override
public Result queryById(Long id) {
// 先从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断商铺信息是否存在Redis中
if (StrUtil.isNotBlank(shopJson)){
// 存在(有真实数据),直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 没有具体数据,判断命中对象是否为空(对象null和值null)
if (shopJson != null) {
// 返回一个错误信息
return Result.fail("店铺不存在!");
}
// 不存在,到mysql数据库查询
Shop shop = getById(id);
// 判断数据库里有没有信息
if (shop == null) {
// 不存在 将空值写入缓存
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 存在,保存到Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}商铺信息互斥锁
多个线程并行执行只能有一个成功
给锁加过期时间,防止错误情况下未能释放造成死锁
ShopServiceImpl
@Override
public Result queryById(Long id) {
// 先从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断商铺信息是否存在Redis中
if (StrUtil.isNotBlank(shopJson)){
// 存在(有真实数据),直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 没有具体数据,判断命中对象是否为空(对象null和值null)
if (shopJson != null) {
// 返回一个错误信息
return Result.fail("店铺不存在!");
}
// 实现缓存重建
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 判断是否获取锁成功
if (!isLock) {
// 获取锁失败,休眠稍后再继续
Thread.sleep(50);
// 递归
return queryById(id);
}
// 获取锁成功
// 不存在,到mysql数据库查询
shop = getById(id);
// 数据库再本地会很快,模拟重建时延
Thread.sleep(200);
// 判断数据库里有没有信息
if (shop == null) {
// 不存在 将空值写入缓存
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 存在,保存到Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
// 抛出
throw new RuntimeException(e);
}finally {
// 释放互斥锁
unlock(lockKey);
}
return Result.ok(shop);
}
// 比正常服务时间长10-20倍
// 官方文档说在null when used in pipeline / transaction这两种情况下返回值为空, 再去拆箱成Boolean就会报空指针
private boolean tryLock(String key) {
// 如果Redis中不存在名为key的键,那么就设置该键的值为"1",并设定过期时间为10秒,同时返回true;如果该键已经存在,则不做任何操作,返回false
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 这个Boolean是boolean的包装类,而方法返回的是基本数据类型,包装类是可以为null,但基本数据类型不能为null
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}商铺信息逻辑过期
RedisData
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}ShopServiceImpl
// 线程池 10个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);// 预热
public void saveShopToRedis(Long id,Long expireSeconds) throws InterruptedException {
// 查询店铺数据
Shop shop = getById(id);
// 模拟延迟
Thread.sleep(200);
// 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop); // 缓存数据
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); // 当前时间+过期时间
// 写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}
// 比正常服务时间长10-20倍
// 官方文档说在null when used in pipeline / transaction这两种情况下返回值为空, 再去拆箱成Boolean就会报空指针
private boolean tryLock(String key) {
// 如果Redis中不存在名为key的键,那么就设置该键的值为"1",并设定过期时间为10秒,同时返回true;如果该键已经存在,则不做任何操作,返回false
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 这个Boolean是boolean的包装类,而方法返回的是基本数据类型,包装类是可以为null,但基本数据类型不能为null
return BooleanUtil.isTrue(flag);
}@Override
public Result queryById(Long id) {
// 先从redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 判断商铺信息是否存在Redis中
if (StrUtil.isBlank(shopJson)){
// 未命中,不存在,已经预先加载到缓存了,不存在说明不是该活动的要的缓存
return Result.fail("店铺不存在!");
}
// 命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 从RedisData拿出Shop
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 从RedisData拿出过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期 如果expireTime在LocalDateTime.now()之后,这意味着Redis数据尚未过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,返回店铺信息
return Result.ok(shop);
}
// 过期,需要实现缓存重建
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 判断是否获取锁成功
if (isLock) {
// 获取锁成功,开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 缓存重建
this.saveShopToRedis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 返回过期的店铺信息
return Result.ok(shop);
}
// 比正常服务时间长10-20倍
// 官方文档说在null when used in pipeline / transaction这两种情况下返回值为空, 再去拆箱成Boolean就会报空指针
private boolean tryLock(String key) {
// 如果Redis中不存在名为key的键,那么就设置该键的值为"1",并设定过期时间为10秒,同时返回true;如果该键已经存在,则不做任何操作,返回false
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 这个Boolean是boolean的包装类,而方法返回的是基本数据类型,包装类是可以为null,但基本数据类型不能为null
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}封装工具
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
解决缓存穿透和缓存击穿
utils/CacheClient.java
@Slf4j
@Component // 这个bean由spring维护
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
// 用构造函数注入,用@Resource也可以
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
// 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
// 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
/*这个方法签名中的<R, ID>是泛型参数。<R>和<ID>代表了不同的类型。
<R>:表示返回值的类型,即queryWithPassThrough方法会返回一个类型为R的对象。
<ID>:表示查询使用的标识符的类型,例如在数据库中查找对象时可能需要使用一个唯一的id来定位。
这个方法接受四个参数:
String keyPrefix: 这是一个字符串,可能是用于生成缓存键的一个前缀。
ID id: 这个参数的类型由泛型参数<ID>决定,通常用于唯一标识你要查询的数据。
Class<R> type: 这个参数用来指定你期望返回的对象的类型。你需要提供一个类的Class对象,例如如果你希望返回的是一个Person对象,那么你应该传入Person.class。
Function<ID, R> dbFallback: 这是一个函数式接口,它接受一个ID类型的参数,并返回一个R类型的值。*/
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 先从redis查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//判断商铺信息是否存在Redis中
if (StrUtil.isNotBlank(json)){
// 存在(有真实数据),直接返回
return JSONUtil.toBean(json, type);
}
// 没有具体数据,判断命中对象是否为空(对象null和值null)
if (json != null) {
// 返回一个错误信息
return null;
}
// 不存在,到mysql数据库查询,使用传进来的函数
R r = dbFallback.apply(id);
// 判断数据库里有没有信息
if (r == null) {
// 不存在 将空值写入缓存
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回一个错误信息
return null;
}
// 存在,保存到Redis
this.set(key,r,time,unit);
return r;
}
// 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
// 线程池 10个线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 先从redis查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 判断商铺信息是否存在Redis中
if (StrUtil.isBlank(json)){
// 未命中,不存在,已经预先加载到缓存了,不存在说明不是该活动的要的缓存
return null;
}
// 命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 从RedisData拿出Shop
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
// 从RedisData拿出过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期 如果expireTime在LocalDateTime.now()之后,这意味着Redis数据尚未过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,返回店铺信息
return r;
}
// 过期,需要实现缓存重建
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 判断是否获取锁成功
if (isLock) {
// 获取锁成功,开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 缓存重建
// 查询MySQL数据库
R r1 = dbFallback.apply(id);
// 写入redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 返回过期的店铺信息
return r;
}
// 比正常服务时间长10-20倍
// 官方文档说在null when used in pipeline / transaction这两种情况下返回值为空, 再去拆箱成Boolean就会报空指针
private boolean tryLock(String key) {
// 如果Redis中不存在名为key的键,那么就设置该键的值为"1",并设定过期时间为10秒,同时返回true;如果该键已经存在,则不做任何操作,返回false
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 这个Boolean是boolean的包装类,而方法返回的是基本数据类型,包装类是可以为null,但基本数据类型不能为null
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}ShopServiceImpl
@Override
public Result queryById(Long id) {
// 解决缓存穿透
// Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// this::getById是Lambda表达式【id2 -> getById(id2)】的简写 这是一个方法引用,指向当前类中的一个名为getById的方法。
// 互斥锁解决缓存击穿
// 逻辑过期解决缓存击穿
// Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
// 去test预热
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 返回过期的店铺信息
return Result.ok(shop);
}全局ID
唯一
高可用 不能挂,不然别的业务无法进行
高性能
递增 整体性
安全性 规律性不能太明显
不直接使用Redis自增的数值,再拼些其他信息
使用Long类型,比String省空间

符号位 1bit,表示正负永远为0
时间戳 31bit,以秒为单位,可以使用69年
序列号 32bit,秒内计数器,每秒最多2^32个ID
utils/RedisIdWorker
@Component
public class RedisIdWorker {
// 开始时间戳 2022-01-01 08:00:00
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号的位数
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成id
* @param keyPrefix key前缀
* @return 全局唯一id
*/
public long nextId(String keyPrefix) {
// 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 生成序列号
// 获取当天日期
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 让redis对当天key进行自增 icr-incre
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 拼接时间戳和序列号 timestamp count
// 不是String类型不能直接拼接
// timestamp左移32位再或运算
return timestamp << COUNT_BITS | count;
}
}测试
@Resource
private RedisIdWorker redisIdWorker;
// 创建一个包含500个线程的固定大小线程池
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
// 初始化一个CountDownLatch对象,计数值为300
CountDownLatch latch = new CountDownLatch(300);
// 定义一个Runnable任务,用于生成订单ID并输出到控制台
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id=" + id);
}
// 当循环结束时,递减latch的计数值
latch.countDown();
};
// 记录当前时间(测试开始)
long begin = System.currentTimeMillis();
// 提交300个任务到线程池中执行
for (int i = 0; i < 300; i++) {
es.submit(task);
}
// 等待所有任务完成
latch.await();
// 记录当前时间(测试结束)
long end = System.currentTimeMillis();
// 输出整个测试过程所花费的时间
System.out.println("time = " + (end - begin));
}秒杀券
添加秒杀券
- Postman
- 地址
http://localhost:8081/voucher/seckill - 请求方式 POST
- 请求头 Authorization 值到浏览器复制
- 请求体Body->raw->JSON
- 地址
结束日期再开始日期当天,开始时间设置在当前时间
{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五可用",
"rules":"全场通用\\n无需预约\\n可无限叠加",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2023-12-10T00:00:00",
"endTime":"2023-12-10T23:59:59"
}超卖 (多卖)
在库存为1时,查询库存到扣减库存之间会会有其他线程过来查询

悲观锁
- 添加同步锁,让线程串行执行
- 性能一般
乐观锁
- 修改时判断与之前查询的库存是否相等
- 缺点 库存>1时也会判断,查询=扣减时多线程只有一个人成功会失败率大增
- 修改时判断与现在的库存是否>0
Json断言 上面设置success 期望值写true 记得勾选添加期望值
依赖了innoDB存储引擎行级锁的机制,可以把update语句中的where stock>0查询操作和更新的操作看成原子操作
分段锁 分表-把100个名额分到10张表,让用户分别到10张表查
一人一单
乐观锁是在更新数据时用,新增数据用不了不能判断数据有没有修改过
查询订单、判断订单、新建订单一整段逻辑加上悲观锁
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 未开始 开始时间在当前时间之后
return Result.fail("秒杀尚未开始");
}
// 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已结束 结束时间在当前时间之前
return Result.fail("秒杀尚已结束");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
/*给createVoucherOrder加了事务,没有给seckillVoucher加事务
seckillVoucher是this.createVoucherOrder来调用,可以省去
this.拿到的是当前VoucherOrderServiceImpl对象,而不是他的代理对象
Transactional事务要想生效是sping对VoucherOrderServiceImpl做了动态代理,拿到了他的代理对象,用他做的事物处理
this指的是非代理对象,也就是目标对象,也就是说没有事务功能 (spring事务功能失效的一个原因)
需要拿到事务的代理对象才行
AopContext.currentProxy()可以拿到当前对象的代理对象
VoucherOrderServiceImpl代理对象是IVoucherOrderService
代理对象是spring创建的
要添加依赖(aspectjweaver)和启动类注解@EnableAspectJAutoProxy暴露代理对象*/
Long userId = UserHolder.getUser().getId();
// 先提交事务再释放锁,确保线程安全 (锁包住事务)
// 同一个用户加一把锁,锁定资源减小 用string对象作为锁,字符串常量池的对象是唯一的,intern同名会去常量池取出来同一个而不是创建一个不一样的对象
// 给函数加锁
synchronized (userId.toString().intern()) {
// 获取代理对象 (事务有关的代理对象)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional // 事务,由spring提交
public Result createVoucherOrder(Long voucherId) {
// 一人一单
Long userId = UserHolder.getUser().getId();
// 查询订单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
// 判断订单是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(userId);
// 代金券id
voucherOrder.setVoucherId(voucherId);
// 保持到mysql数据库
save(voucherOrder);
// 返回订单id
return Result.ok(orderId);
}
}运行多台同一项目tomcat
底部工具调出

添加服务 springboot

选择Application按ctrl+D
VM选项填-Dserver.port=8082覆盖yml配置文件的端口

修改nginx配置负载均衡
#proxy_pass反向代理到...
#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;
}
}重新加载配置
nginx.exe -s reload测试
http://localhost:8080/api/voucher/list/1集群模式下的一人一单并发安全问题
每个JVM有自己的锁 锁监视器
需要用同一个锁
跨JVM/进程的锁
分布式锁
分布式系统或集群模式下多进程(JVM)可见并且互斥的锁
实现方式
基于MySQL
基于Redis
基于Zookper
基于Redis的分布式锁
获取锁
互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回true,失败返回false
阻塞:获取锁失败后等待锁释放继续获取锁(实现麻烦,不用)
# NX可以打到setnx效果,EX是超时时间 SET lock thread1 NX EX 10
释放锁
手动释放
超时释放(防止业务超时或该服务宕机未释放锁造成死锁)
DEL key
误删锁

获取锁时存入UUID
UUID区分不同服务也就是JVM再用线程ID拼接
释放锁时判断线程标识
判断锁的value是不是自己的
线程id集群情况下有可能会重复,上次面试就被坑了,可以使用进程id+线程id生成全局唯一id
分布式锁的原子性
判断线程标识一致后,线程遇到阻塞(JVM的full GC会阻塞业务代码)

判断锁和删除锁得设计成原子性操作
可以用乐观锁,但太复杂
Lua脚本
Lua调用Redis
Lua数组角标从1开始
锁在脚本中删除,保证原子性
Redisson优化秒杀
引入
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>配置
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.10.87:6379").setPassword("123456");
// 创建RedissonClient对象
return Redisson.create(config);
}
}VoucherOrderServiceImpl
@Resource
private RedissonClient redissonClient;
@Override
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 未开始 开始时间在当前时间之后
return Result.fail("秒杀尚未开始");
}
// 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已结束 结束时间在当前时间之前
return Result.fail("秒杀尚已结束");
}
// 判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁
boolean isLock = lock.tryLock(); // 不需要重复获取
// 判断是否获取成功
if (!isLock) {
// 失败
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象 (事务有关的代理对象)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
@Transactional // 事务,由spring提交
public Result createVoucherOrder(Long voucherId) {
// 一人一单
Long userId = UserHolder.getUser().getId();
// 查询订单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
// 判断订单是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(userId);
// 代金券id
voucherOrder.setVoucherId(voucherId);
// 保持到mysql数据库
save(voucherOrder);
// 返回订单id
return Result.ok(orderId);
}Redisson
基于setnx实现的分布式锁存在以下问题
不可重入-同一线程无法多次获取同一把锁
不可重试-获取锁只尝试一次就返回false,没有重试机制
超时释放-锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放,存在安全隐患
主从一致性-如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
主写从读
可重入

