说明:
本项目仅在此展示服务层的功能
笔记小结:
Redis命令:
在发送短信验证码功能中,使用了Redis的
String
命令集的set
方法完成验证码保存在短信验证码登录、注册功能中,使用了Redis的
Hash
命令集的putAll
方法完成了登录用户的信息保存在校验验证登录状态功能中,使用了Reids的
Hash
命令集的entries
对登录用户信息的非空校验以及身份保存功能实现疑难点:
- 在Hutool工具中,进行Bean类型转换Map类型的
BeanUtil、beanToMap
方法,其中一个属性使用了CopyOptions.create().ignoreNullValue().setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
进行参数类型的转换
说明:
通过Session实现登录,分为发送如上步骤。发送短信验证码、短信验证码登录与注册、校验登录状态通过将验证码和用户保存在Session域中,实现会话的管理等操作
步骤一:发送验证码
UserServiceImpl
类的sendCode
方法/**
* @param phone 手机号
* @param session session域
* @return Result风格结果
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码、手机号到session
session.setAttribute("code", code);
session.setAttribute("phone", phone);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{},", code);
return Result.ok();
}
步骤二:实现登录
UserServiceImpl
类的login
方法/**
* @param loginForm 封装登录用户的DTO
* @param session session域
* @return eoken
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone) || ObjectUtil.notEqual(phone, session.getAttribute("phone").toString())) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号错误!");
}
// 3.校验验证码
String code = loginForm.getCode();
if (RegexUtils.isCodeInvalid(code) || ObjectUtil.notEqual(code, session.getAttribute("code").toString())) {
// 4.如果不符合,返回错误信息
return Result.fail("验证码错误!");
}
// 5.判断用户是否存在
LambdaQueryWrapper<User> lambdaQuery = new LambdaQueryWrapper<>();
lambdaQuery.eq(User::getPhone, phone);
User user = userMapper.selectOne(lambdaQuery);
// 6.用户不存在,创建用户,保存到数据库
if (ObjectUtil.isNull(user)) {
user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
userMapper.insert(user);
}
// 7.保存用户到session
// 此处保存用户session信息时,使用Hutool工具的拷贝字节流的方式将属性值存入UserDTO类中,防止过多的用户信息发送给前端,造成安全问题
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
补充:
DTO是什么意思,参考网址:(43条消息) Java深入了解DTO及如何使用DTO_dto是什么_visant的博客-CSDN博客
补充:
LoginFormDTO
类@Data public class LoginFormDTO { private String phone; private String code; private String password; }
步骤三:校验登录状态
1.创建LoginInterceptor
拦截器
/*
* 定义拦登录拦截器并实现逻辑,在拦截器中,记录用户身份信息
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取session
HttpSession session = request.getSession();
// 2.获取session中的用户
UserDTO user = (UserDTO) session.getAttribute("user");
// 3.判断用户是否存在
boolean result = ObjectUtil.isNull(user);
// 4.不存在,拦截
if (result) {
// 返回401状态码
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(user);
// 6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 当拦截器完成之后,将内存中ThreadLocal(对线程内)里面保存的用户信息清除,释放内存空间,避免浪费
UserHolder.removeUser();
}
}
2.创建MvcConfig
配置文件
/*
* 创建配置类,并注册登录拦截器
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 配置相应的放行逻辑
registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
@Bean
LoginInterceptor getLoginInterceptor() {
return new LoginInterceptor();
}
}
说明:
根据系统架构,当需要通过tomcat搭建集群,如此实现会出现session共享问题的出现。同一个tomcat与另一个tomcat之间,并不能共享session域
说明:
保存验证码信息到Redis中,而不再是Session域中,便于解决多台Tomcat服务器访问Redis服务解决Session共享域问题
说明:
此处存入Redis的Token不建议直接使用手机号来作为Key,因为Token将来是会返回给前端,若用手机号会造成信息泄露的风险
说明:
实现思路:
将发送短信验证码时,将验证码存入Redis。校验验证码时,从Redis中取出并校验。登录成功后,将Token存入Redis,当校验用户身份时,从Redis取出并校验
步骤一:导入依赖
1.修改Pom.xml
文件,添加如下依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<scope>runtimescope>
<version>5.1.47version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
<version>3.4.1version>
dependency>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.8.16version>
dependency>
说明:
Hutool是一个小而全的Java工具类库,通过静态方法封装,降低相关API的学习成本,提高工作效率,使Java拥有函数式语言般的优雅,让Java语言也可以“甜甜的”。
步骤二:编写Pom.xml
配置文件
server:
port: 8081
spring:
application:
name: hmdp
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/db1?useSSL=false&serverTimezone=UTC
username: root
password: qweasdzxc
redis:
host: 10.13.164.55
port: 6379
password: qweasdzxc
lettuce:
pool:
max-active: 10
max-idle: 10
min-idle: 1
time-between-eviction-runs: 10s
jackson:
default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
level:
com.hmdp: debug
步骤三:封装Result风格结果集
1.在dto包下创建Result类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
说明:
封装Result结果集,将控制层结果进行统一处理与返回
步骤四:创建工具类
1.创建系统常量类
util
包下创建系统常量SystemConstants
类public class SystemConstants {
public static final String IMAGE_UPLOAD_DIR = "D:\\lesson\\nginx-1.18.0\\html\\hmdp\\imgs\\";
public static final String USER_NICK_NAME_PREFIX = "user_";
public static final int DEFAULT_PAGE_SIZE = 5;
public static final int MAX_PAGE_SIZE = 10;
}
2.创建校验工具类
2.1创建常用正则表达式
util
包下创建格式校验RegexPatterns
类public abstract class RegexPatterns {
/**
* 手机号正则
*/
public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
/**
* 邮箱正则
*/
public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
/**
* 密码正则。4~32位的字母、数字、下划线
*/
public static final String PASSWORD_REGEX = "^\\w{4,32}$";
/**
* 验证码正则, 6位数字或字母
*/
public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";
}
2.2创建校验规则
util
包下创建参数校验RegexUtils
类public class RegexUtils {
/**
* 是否是无效手机格式
*
* @param phone 要校验的手机号
* @return true:符合,false:不符合
*/
public static boolean isPhoneInvalid(String phone) {
return mismatch(phone, RegexPatterns.PHONE_REGEX);
}
/**
* 是否是无效邮箱格式
*
* @param email 要校验的邮箱
* @return true:符合,false:不符合
*/
public static boolean isEmailInvalid(String email) {
return mismatch(email, RegexPatterns.EMAIL_REGEX);
}
/**
* 是否是无效验证码格式
*
* @param code 要校验的验证码
* @return true:符合,false:不符合
*/
public static boolean isCodeInvalid(String code) {
return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
}
// 校验是否不符合正则格式
private static boolean mismatch(String str, String regex) {
if (StrUtil.isBlank(str)) {
return true;
}
return !str.matches(regex);
}
}
步骤五:实现发送短信验证码业务
1.在UserServiceImpl
实现类中创建发送sendCode
方法
@Autowired
StringRedisTemplate stringRedisTemplate; //利用StringRedisTemplate实现对Redis的操作
@Autowired
UserMapper userMapper;
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到redis
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{},", code);
return Result.ok();
}
步骤六:实现登录业务
1.在UserServiceImpl
类中创建发送login
方法
@Autowired
UserMapper userMapper;
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从Redis获取校验验证码
String verCode = loginForm.getCode();
String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (RegexUtils.isCodeInvalid(verCode) || ObjectUtil.notEqual(code, verCode)) {
// 4.如果不符合,返回错误信息
return Result.fail("验证码错误!");
}
// 5.判断用户是否存在
LambdaQueryWrapper<User> lambdaQuery = new LambdaQueryWrapper<>();
lambdaQuery.eq(User::getPhone, phone);
User user = userMapper.selectOne(lambdaQuery);
// 6.用户不存在,创建用户,保存到数据库
if (ObjectUtil.isNull(user)) {
user = new User(phone, USER_NICK_NAME_PREFIX + RandomUtil.randomString(5));
userMapper.insert(user);
}
// 7.保存用户到redis
// 7.1使用Hutool的UUID方法随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2将User对象转换为HashMap,便于存储Redis
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 此处通过Hutool工具在将Bean数据转换为Map
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().ignoreNullValue()
.setFieldValueEditor(new BiFunction<String, Object, Object>() {
@Override
public Object apply(String fieldName, Object fieldValue) {
return fieldValue.toString(); // 因为UserDTO实体类的ID为Long类型,不能直接存入Redis,需要转换为其余类型
}
}));
// 7.3利用Redis的hash方式,存储用户信息
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4设置有效期
stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回结果
return Result.ok(token); //此处需要将token返回给前端,前端添加到请求头的authorization中
}
注意:
说明:
此处设置Token的有效期,也就是意味着每次登录的Token时间过期,就会强制让用户退出,而重新登录。因此,此时需要找地方设置刷新Token的时间。让用户在30分钟内处于活跃状态就延长Token的有效期时间即可。请继续查看步骤以下步骤
补充:
UserDTO
类@Data public class UserDTO { private Long id; private String nickName; private String icon; }
步骤七:校验登录状态
说明:
添加新的拦截器,拦截所有路径,对所有访问所有路径下的资源进行Redis中的Session域刷新时间的处理
1.添加RefreshTokenInterceptor
拦截器
public class RefreshTokenInterceptor implements HandlerInterceptor {
@Autowired
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 tokenKey = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
// 4.不存在则放行,交给LoginInterceptor拦截器进行处理
return true;
}
// 5.将查询到的Hash数据转换为UserDTO对象(便于存储到ThreadLocal中)-Hutool(BeanUtil.fillBeanWithMap)
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新Token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 当执行完Controller内的方法后,对线程内的用户信息进行删除
UserHolder.removeUser();
}
}
说明:
此处需要刷新Token的有效期,让用户处于在线状态
2.修改LoginInterceptor
拦截器
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截用户
if (ObjectUtil.isNull(UserHolder.getUser())) {
// 没有,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 放行
return true;
}
}
3.修改MvcConfig
配置类
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 设置多个拦截器的先后顺序,让拦截器的执行时机变得有序
registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
registry.addInterceptor(getRefreshTokenInterceptor()).addPathPatterns("/**").order(0);
}
@Bean
LoginInterceptor getLoginInterceptor() {
return new LoginInterceptor();
}
@Bean
RefreshTokenInterceptor getRefreshTokenInterceptor() {
return new RefreshTokenInterceptor();
}
}
当通过Redis来记录并刷新用户的登录状态,可以便于集群中Tomcat对Redis的操作信息进行共享,从而解决了Session保存、Session校验问题
笔记小结:
Redis命令:
在添加缓存的功能中,使用了Redis的
String
命令集的set
、get
方法完成商户数据的缓存与查询,提高系统响应速度在缓存更新策略功能中,实现超时剔除时使用了Redis的
Stirng
命令集的set
方法使用了超时过期的属性。实现主动更新时,使用了Redis的delete
命令移除Key在缓存穿透功能中,使用了Redis的
String
命令集的set
方法,利用技巧保存空值,来解决缓存穿透在缓存击穿功能中,使用了Redis的
String
命令集的setIfAbsent
方法,实现了互斥锁的加锁功能实现疑难点:
- 实现缓存更新策略功能时,使用超时剔除,实现较低的一致性需求。使用主动更新,进行先删除数据库再删除缓存的流程,实现较高的一致性需求
- 实现缓存穿透功能中,实现互斥锁方案时,使用双重检验锁,进行二次检查,避免重复加载数据
- 实现缓存穿透功能中,实现逻辑过期方案时,巧用
RedisData
类,在不修改原类的情况下进行商品类的成员属性额外添加。利用Executors.newFixedThreadPool
方法进行多线程池子的创建,以及任务提交- 在Hutool工具中,使用
StrUtil.isNotBlank
方法,完成商铺信息的存在判断。使用RandomUtil.randomLong
方法,添加随机值,解决缓存雪崩。使用JSONUtil.toBean
方法,将对象反序列化。使用JSONUtil.toJsonStr
方法,将对象序列化
含义:
缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。
作用:
应用场景:
补充:
缓存可以给我们带来很多优点,但是更容易出现缓存击穿、缓存雪崩等问题
说明:
- 当未使用缓存进行数据缓冲时,客户端想要获取数据,会直接通过服务端查询数据库进行获得。这种方式,当客户端的请求达到高并发时,服务端的性能就会越来越下降。主要是因为磁盘的读写速度,因为读写磁盘次数很频繁,因此会影响服务端的性能
- 当客户端想要获取数据时请求服务端,服务端首先通过Redis进行数据的获取,可大大减少数据库的读写次数,从而大大的提高了服务端的性能。主要是因为磁盘的读写速度,因为读写磁盘次数大大减少,因此会影响服务端的性能
说明:
实现思路:
客户端发送请求,先从Redis中获取数据,若命中则返回,未命中则查询数据库。若数据库存在则写入Redis,并返回数据,不存在则直接返回报错
ShopServiceImpl
类中的queryById
方法/**
* @param id 商铺的ID
* @return 商铺
*/
@Override
public Result queryById(Long id) {
// 1.从Redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据Id查询数据库
Shop shop = getById(id);
if (ObjectUtil.isNull(shop)) {
// 5.不存在,返回错误
return Result.fail("店铺不存在!");
}
// 6.存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 7.返回结果
return Result.ok(shop);
}
对于缓存更新策略,根据一致性的需求分为以下三种方式
说明:在生活场景中
- 低一致性需求:建议使用内存淘汰机制。例如店铺类型的查询缓存
- 高一致性需求:建议使用主动更新机制,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
说明:
- Cache Aside Pattern,通过编码的方式来对数据库进行同时更新缓存,需要认为的控制
- Read/write Through Pattern,这样的服务代码开发成本太高
- Write Behind Caching Pattern,将对数据的大量操作存储在缓存中,等到一定时间后再对数据库进行操作。假如缓存宕机,则会造成数据丢失
说明:
- 先删缓存和后删缓存区别?
- 先删除缓存,再操作数据库出现异常记录较大。因为对操作系统数据库的读写速度大于对缓存的读写速度
- 先操作数据库,再删除缓存出现异常记录小。因为对缓存的读写速度小于对操作系统数据库的读写速度
说明:
实现思路:
修改商品实现类中的业务逻辑,根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
ShopServiceImpl
类中的queryById
方法/**
* @param id 商铺的ID
* @return 商铺
*/
@Override
public Result queryById(Long id) {
// 1.从Redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据Id查询数据库
Shop shop = getById(id);
if (ObjectUtil.isNull(shop)) {
// 5.不存在,返回错误
return Result.fail("店铺不存在!");
}
// 6.存在,写入Redis
// 为从Redis查询商品设置了商品的过期时间,实现缓存更新策略中的超时剔除的功能
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回结果
return Result.ok(shop);
}
说明:
实现思路:
修改商品实现类中的业务逻辑,根据id修改店铺时,先修改数据库,再删除缓存
ShopServiceImpl
类中的update
方法@Override
@Transactional// 因此本项目为单体项目,因此添加事务注解,即可实现事务的同步
public Result update(Shop shop) {
Long id = shop.getId();
if (ObjectUtil.isNull(id)) {
return Result.fail("商铺Id不能为空");
}
// 1.修改数据库
updateById(shop);
// 2.删除缓存
String key = CACHE_SHOP_KEY + id;
stringRedisTemplate.delete(key);
return Result.ok();
}
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,给数据库带来巨大压力
说明:
如果非法用户一直想后台发送垃圾请求,就会造成服务异常甚至崩溃
解决缓存穿透,有如下常用两种方式:
缓存空对象:
说明:
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗、可能造成短期的不一致(若插入了真实的数据,但结果被Redis已经缓存,则会出现数据不一致)
布隆过滤:
说明:
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂、存在误判可能(因为算出来的Hash值可能相同,就会误以为不存在的数据显示已存在)
补充:此外还有如下解决方式:
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
NULL
值说明:
实现思路:
若在Redis中进行命中的未null值,则直接返回错误信息、当将查询不存在的ID在Redis中进行null值的缓存,当缓存完毕后返回错误信息。这样下一次再次查询时就会减少查询数据库的次数
ShopServiceImpl
类的queryById
方法/**
* @param id 商铺的ID
* @return 商铺
*/
@Override
public Result queryById(Long id) {
// 1.从Redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {// StrUtil.isNotBlank方法会忽略null、空、换行符
// 3.存在,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.判断命中是否为Null值--此处用缓存Null值方案解决缓存穿透
if (ObjectUtil.isNotNull(shopJson)) { // 不是null值,就为空
return Result.fail("店铺不存在!");
}
// 5.未命中,根据Id查询数据库
Shop shop = getById(id);
// 5.1判断数据库中数据是否存在
if (ObjectUtil.isNull(shop)) {
// 5.2将未命中的数据进行空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 5.3命中,写入Redis--此处用超时剔除,解决缓存中的更新策略、用Key的TTL随机值,解决缓存雪崩
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 6.返回结果
return Result.ok(shop);
}
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
说明:缓存雪崩常用的解决方案
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略给业务添
- 加多级缓存
说明:
实现思路:
为Key的过期时间添加随机值
ShopServiceImpl
类的queryById
方法/**
* @param id 商铺的ID
* @return 商铺
*/
@Override
public Result queryById(Long id) {
// 1.从Redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,则直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.判断命中是否未Null值
if (ObjectUtil.isNull(shopJson)) {
return Result.fail("店铺不存在!");
}
// 5.不存在,根据Id查询数据库
Shop shop = getById(id);
if (ObjectUtil.isNull(shop)) {
// 6.将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 不存在,返回错误
return Result.fail("店铺不存在!");
}
// 7.存在,写入Redis-为商品增加随机的TTL值
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(10), TimeUnit.MINUTES);
// 8.返回结果
return Result.ok(shop);
}
说明:
此种方式解决缓存雪崩问题相对简单,随机增加TTL值即可。更高级的解决方式请继续往下看
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
说明:
- 在高并发的情况下,此时大量的线程未命中数据。且因为查询数据库业务的时间比较长,就会导致等待数据库业务查询时间过长。
- 若此时有大量的请求未命中数据,就会很多线程去访问数据库,给数据库带来巨大的冲击
常见的解决方案,有如下两种:
说明:
- 此种方案的解决方式是通过互斥锁的方式进行解决。也就是当一个线程在访问数据库时进行加锁,访问完毕再进行释放锁,此时其余线程若想访问数据库就不得不等到锁释放完毕后,再进行访问。减少了数据库的压力
- 通过互斥锁的方式来解决,实际上会影响服务器的性能。因为服务一直处于等待状态,所以获取数据缓慢
说明:
- 此种方案的解决方式是通过采用逻辑过期的方式进行解决。也就是当一个线程访问Redis中的数据,给Redis加上一个逻辑过期的字段。若有线程发现该数据逻辑上已过期,则开辟新的线程拿到锁去获取数据。旧的线程则返回旧的数据
说明:
- 互斥锁与逻辑过期解决方案各有优缺点
说明:
实现思路:
通过synchronized或者lock自带的锁不能够满足我们实现的业务逻辑,当线程拿不到锁时就等待一定的时间。我们需要通过自定义互斥锁的方式来实现自定义锁的逻辑。
步骤一:添加锁
ShopServiceImpl
中的tryLock
方法/**
* @param key Redis中的键
* @return 加锁是否成功
*/
private Boolean tryLock(String key) {
// 设置锁的过期时间,防止死锁
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "l", LOCK_SHOP_TTL, TimeUnit.MINUTES);
/*
注意:此处通过setIfAbsent方法,返回的结果的类型为boolean类型而不是Boolean类型。
Boolean是boolean的包装类,因此JDK17会进行拆箱。
拆箱可能会出现空指针异常,因此这里借用Hutool工具进行判别
* */
return BooleanUtil.isTrue(result);
}
步骤二:释放锁
ShopServiceImpl
中的unLock
方法/**
* @param key Redis中的键
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
步骤三:添加双重检查锁机制
ShopServiceImpl
中的queryById
方法@Override
public Result queryById(Long id) {
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (ObjectUtil.isNull(shop)) {
return Result.fail("店铺不存在!");
}
// 7.返回结果
return Result.ok(shop);
}
/**
* 互斥锁解决缓存击穿
*
* @param id 店铺的Id信息
* @return 店铺信息
*/
public Shop queryWithMutex(Long id) {
// 1.从Redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {// StrUtil.isNotBlank方法会忽略null、空、换行符
// 3.存在,则直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中是否为Null值--此处用缓存Null值方案解决缓存穿透
if (ObjectUtil.isNotNull(shopJson)) { // 不是null值,就为空
return null;
}
// 4.实现缓存重建
// 4.1获取互斥锁
String lockKey = "lock:shop" + id;
Shop shop = null;
try {
Boolean isLock = tryLock(lockKey);
// 4.2判断是否获取成功
if (!isLock) {
// 4.3失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
Thread.sleep(200);
// 4.5成功,再次检测Redis缓存是否存在
String shopJsons = stringRedisTemplate.opsForValue().get(key); //此处实现了双重检验锁
if (StrUtil.isNotBlank(shopJsons)) {
return JSONUtil.toBean(shopJsons, Shop.class);
}
// 4.6根据Id查询数据库
shop = getById(id);
if (ObjectUtil.isNull(shop)) {
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 5.不存在,返回错误
return null;
}
// 6.存在,写入Redis--此处用超时剔除,解决缓存中的更新策略、用Key的TTL随机值,解决缓存雪崩
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL + RandomUtil.randomLong(10), TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7.释放互斥锁
unLock(lockKey);
}
// 8.返回结果
return shop;
}
说明:在Redis中,为什么进行双重检查锁的时候,会进行第二次查看缓存的操作?
- 在使用双重检查锁(Double-Checked Locking)来实现对Redis缓存的访问时,第二次查看缓存的操作是为了确保在获取锁后,其他线程没有在此期间已经更新了缓存。
- 双重检查锁是一种常用的多线程并发控制技术,它可以在保证线程安全的前提下减少锁的使用次数,提高性能。在使用双重检查锁时,通常会先进行一次非同步的判断,如果缓存中存在需要的数据,则直接返回结果,避免获取锁的开销。但是,由于多线程的并发执行,可能存在以下情况:
- 线程A首先检查缓存,发现缓存为空,于是获取锁并开始加载数据到缓存。
- 此时,线程B也执行了第一次检查,发现缓存为空,于是也尝试获取锁。
- 线程B在获取锁之前,线程A已经完成了数据加载,并释放了锁。
- 线程B获取到了锁,但不知道线程A已经加载了数据到缓存,于是继续进行加载数据的操作。
- 为了避免线程B重复加载数据,第二次查看缓存的操作是必要的。在第二次查看缓存时,线程B再次检查缓存,如果发现缓存不为空,则说明在获取锁的过程中,其他线程已经加载了数据到缓存,此时线程B可以直接使用缓存中的数据,避免重复加载数据。
说明:
- 实现思路:
- 核心点是,旧的线程发现数据已过期依旧会返回旧数据,但与此同时会开启新的线程去更新数据
步骤一:逻辑过期时间
RedisData
类/**
* 用于封装Shop类,在Shop类现有的成员属性上添加新的成员属性(expireTime)
*/
@Data
public class RedisData<T> {
private LocalDateTime expireTime;
private T data; // 此数据类型定义为泛型,便于封装其余需要实现逻辑过期的类
}
说明:
- 为Shop类添加逻辑过期时间
步骤二:热点商品保存
ShopServiceImpl
的shopSave2Redis
方法/**
* 保存热点商品到Redis
*
* @param id 商铺Id
* @param expireSeconds 过期时间
*/
public void shopSave2Redis(Long id, Long expireSeconds) {
// 1.查询商品
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData<Shop> redisData = new RedisData<>();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.添加缓存
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
说明:
添加热点商品保存方法,将商品保存至Redis
补充:
- 在任一测试类中,通过模拟商品管理后台,进行热点商品的添加
@Autowired ShopServiceImpl shopService; /** * 通过模拟商品管理后台,添加热点商品 */ @Test public void testSave() { shopService.shopSave2Redis(1L, 10L); }
步骤三:添加逻辑过期
1.修改ShopServiceImpl
类中的queryById
方法
@Override
public Result queryById(Long id) {
// 缓存Null值解决缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
// 逻辑过期方式解决缓存击穿
Shop shop = queryWithLogicExpire(id);
if (ObjectUtil.isNull(shop)) {
return Result.fail("店铺不存在!");
}
// 7.返回结果
return Result.ok(shop);
}
2.创建线程池
/**
* 创建线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
说明:
- 创建线程池,让额外的线程能够查询并更新数据
3.实现逻辑过期方式,在ShopServiceImpl
类中添加queryWithLogicExpire
方法
/**
* 逻辑过期方式解决缓存击穿
*
* @param id 店铺的Id信息
* @return 店铺信息
*/
public Shop queryWithLogicExpire(Long id) {
// 1.从Redis查询商铺缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.未命中,直接返回空
return null;
}
// 4.命中,需要先把Json序列化为对象
RedisData<Shop> redisData = JSONUtil.toBean(shopJson, new TypeReference<RedisData<Shop>>() {
}.getType(), false);
Shop shop = redisData.getData();
// 5.检查缓存过期时间
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 5.1未过期,直接返回旧数据
return shop;
}
// 5.2已过期,缓存重建
// 6.缓存重建
// 6.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Boolean isLock = tryLock(lockKey);
if (isLock) {
// 6.2获取互斥锁成功,再次检查缓存过期时间
String result = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(result)) {
RedisData<Shop> redisData2 = JSONUtil.toBean(result, new TypeReference<RedisData<Shop>>() {
}.getType(), false);
Shop shop2 = redisData2.getData();
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return shop2;
}
}
// 6.3开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 6.3.1重建锁
this.shopSave2Redis(1L, 20L);// 这里便于测试设置时长为20秒。实际情况建议30分钟查一次
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 6.3.2释放锁
unLock(lockKey);
}
});
}
// 6.4获取互斥锁失败,直接返回过期数据
return shop;
}
说明:
此工具类借助Hutool工具、泛型等进行编写。此工具类经过调式、验证,准确无误
// 缓存Null值解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class,this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期方式解决缓存击穿
Shop shop = cacheClient.queryWithLogicExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
说明:
- 使用工具类,需要传入存入Redis中的Key以及所需要查询数据库返回值的类型
@Slf4j
@Component
@AllArgsConstructor
public class CacheClient {
@Autowired
StringRedisTemplate stringRedisTemplate;
/**
* 创建线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 加锁
*
* @param key Redis中的键
* @return 加锁是否成功
*/
private Boolean tryLock(String key) {
// 设置锁的过期时间,防止死锁
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "l", LOCK_SHOP_TTL, TimeUnit.MINUTES);
/*
注意:此处通过setIfAbsent方法,返回的结果的类型为boolean类型而不是Boolean类型。
Boolean是boolean的包装类,因此JDK17会进行拆箱。
拆箱可能会出现空指针异常,因此这里借用Hutool工具进行判别
* */
return BooleanUtil.isTrue(result);
}
/**
* 解锁
*
* @param key Redis中的键
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 设置存储在Redis中的键,并指定Redis中的过期时间
*
* @param key 键
* @param value 值
* @param time 时间
* @param unit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 设置存储在Redis中的键,并指定对象的逻辑过期时间
*
* @param key 键
* @param value 值
* @param time 过期时间
* @param unit 时间单位
*/
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit) {
RedisData<Object> redisData = new RedisData<>();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); // 此处设置RedisData对象的值
}
/**
* 缓存Null值解决缓存穿透
*
* @param keyPrefix 键的前缀
* @param id 存储键的前缀以及查询数据库的ID
* @param type 存储数据的类型
* @param dbFallback 获取数据库数据的逻辑
* @param time 过期时间
* @param unit 时间单位
* @param 返回值类型
* @param id类型
* @return 存储数据类的对象
*/
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 1.从Redis查询缓存数据
String key = keyPrefix + id;
String strJson = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
// 2.判断数据是否存在
if (StrUtil.isNotBlank(strJson)) {// StrUtil.isNotBlank方法会忽略null、空、换行符
// 3.存在,则直接返回
return JSONUtil.toBean(strJson, type);
}
// 4.不存在,则进一步判断
// 4.1判断命中是否为Null值--此处实现了缓存Null值方案(不是null值,就为空),缓解了缓存穿透问题的影响
if (ObjectUtil.isNotNull(strJson)) {
return null;
}
// 4.2查询数据库,获得返回值数据
R r = dbFallback.apply(id);
// 5.判断数据是否存在
if (ObjectUtil.isNull(r)) {
// 5.1不存在
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回空
return null;
}
// 5.2存在
// 写入Redis--此处实现了超时剔除功能,缓解了缓存中的更新策略的影响、实现了Key的TTL随机值,缓解了缓存雪崩问题的影响
this.set(key, JSONUtil.toJsonStr(r), time + RandomUtil.randomLong(5), unit);
// 6.返回结果
return r;
}
/**
* 逻辑过期方式解决缓存击穿
*
* @param keyPrefix 键的前缀
* @param id 存储键的前缀以及查询数据库的ID
* @param type 存储数据的类型
* @param dbFallback 获取数据库数据的逻辑
* @param time 过期时间
* @param unit 时间单位
* @param 返回值类型
* @param id类型
* @return 存储数据类的对象
*/
public <R, ID> R queryWithLogicExpire(String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 1.从Redis查询商铺缓存
String key = keyPrefix + id;
String jsonStr = stringRedisTemplate.opsForValue().get(key); //要么返回null、要么返回非空数据
// 2.判断数据是否命中
if (StrUtil.isBlank(jsonStr)) {
// 2.1未命中,直接返回空
return null;
}
// 2.2.命中,需要先把Json序列化为对象
RedisData<R> redisData = JSONUtil.toBean(jsonStr, new TypeReference<RedisData<R>>() {
}.getType(), false);
R r = redisData.getData(); // 注意,此时因实用Hutool工具进行类型转换,返回的R类型为JSONObject
R bean = JSONUtil.toBean((JSONObject) r, type);
// 3.检查缓存过期时间
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 3.1未过期,直接返回旧数据
return bean;
}
// 3.2已过期,缓存重建
// 4.缓存重建
// 4.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Boolean isLock = tryLock(lockKey);
if (isLock) {
// 4.2获取互斥锁成功,再次检查缓存过期时间
String jsonStr2 = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(jsonStr2)) {
RedisData<R> redisData2 = JSONUtil.toBean(jsonStr2, new TypeReference<RedisData<R>>() {
}.getType(), false);
R r2 = redisData2.getData();
if (redisData2.getExpireTime().isAfter(LocalDateTime.now())) {
return JSONUtil.toBean((JSONObject) r2, type);
}
}
// 6.3开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 6.3.1重建锁
// 查询商品
R r3 = dbFallback.apply(id); // 此处可用debug进行调试 log.debug("我成功执行");
// 添加缓存
this.setWithLogicExpire(key, r3, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 6.3.2释放锁
unLock(lockKey);
}
});
}
// 6.4获取互斥锁失败,直接返回过期数据
return bean;
}
}
笔记小结:
Redis命令:
- 在实现全局唯一ID的功能中,使用了Redis中的
String
命令,通过increment
方法,实现了生成ID的个数统计- 在实现分布式锁的功能中,使用了Redis中的
String
命令,通过setIfAbsent
方法,实现了分布式锁的获取- 在实现Redis优化秒杀的功能中,使用了Redis中的
String
命令,通过set
方法,实现了热卖商品的添加功能实现疑难点:
- 实现缓存更新策略功能时,使用超时剔除,实现较低的一致性需求。使用主动更新,进行先删除数据库再删除缓存的流程,实现较高的一致性需求
- 在解决库存超卖问题时,利用MyBatis-Plus工具中的
eq
方法,巧妙的实现了CAS方式来解决- 在实现一人一单功能时,巧妙利用
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
API获取了Spring框架代理的对象。以及synchronized
锁的巧妙利用。利用了toString
方法的intern()
方法确保了加锁对象的唯一- 在实现分布式锁的功能中,巧妙利用
Thread.currentThread().getId()
API获取当前线程的ID作为获取锁的键。修改释放锁的逻辑,巧妙进行释放锁前判断释放的锁是否为自己的锁,防止锁的误删- 在实现Redis优化秒杀的功能中,编写了
Lua
脚本保证操作原子性完成优惠券的下单功能。使用stringRedisTemplate.execute
方法执行Lua脚本。以及阻塞队列的使用,异步处理订单的功能。详细请查看本小节- 在使用
Redssion
工具时,对可重入锁原理、可重试原理、超时续约原理、主从一致问题的原理性的理解与运用- 在MyBatis-Plus工具中,使用了
update()
方法,以及联合使用.setSql
方法进行SQL语句的补充执行
优惠券秒杀是一种促销活动方式,通过限时限量地提供优惠券,吸引用户参与抢购,从而达到营销和销售的目的。下面是一种简单的优惠券秒杀的实现思路:
在实际开发中,还需要考虑并发访问和高并发场景下的性能优化,例如限制用户的秒杀频率、使用分布式缓存和消息队列等技术手段来提高系统的并发处理能力。
需要注意的是,优惠券秒杀是一种特殊的促销活动,需要综合考虑业务需求、系统设计和性能调优等方面因素,以确保系统的稳定性和用户体验。
每个店铺都可以发布优惠券:
说明:
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题。例如:1.id的规律性太明显、2.受单表数据量的限制
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性
说明:
- 唯一性,全局唯一,不会出现不同
- 高性能,能在很短的时间内生成所需要的ID
- 高可用,能够方便的实现主从复制等高级操作
- 递增性,能够符合一定的规律。安全性,不会被用户轻易猜到
说明:ID的组成部分
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年(31 位表示的时间戳可以表示的最大值是 2^31 - 1约等于69年)
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
注意:
生成全局唯一ID需要满足五个特性,确保系统性能
步骤一:自定义ID生成工具
RedisIdWorker
类@Component
public class RedisIdWorker {
@Resource
StringRedisTemplate stringRedisTemplate;
private final static long BEGIN_TIMESTAMP = 1076630400L; //秒级时间戳有10位,指定日期
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowTimeStamp = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowTimeStamp - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yy:MM:dd"));
// 2.2自增长
long count = stringRedisTemplate.opsForValue().increment("inc:" + keyPrefix + date); // 若所操作的键不存在,使用Increment可以自动的创建该键
// 3.拼接并返回
return timeStamp << 32 | count;
}
}
补充:
在 Redis 中,自增操作使用的数据类型是有符号的 64 位整数(signed 64-bit integer),也就是 int64 类型。因此,自增操作的上限是 9223372036854775807(2^63 - 1)。因此建议不同的Key使用不同的键
补充:
- 当使用了
<<
或者是|
运算符时,Java会自动将二进制转换为十进制,例如1076630400L
01000000 00101100 00010011 10000000
- 当二进制数向左移动32位时,会向右侧补0
01000000 00101100 00010011 10000000 00000000 00000000 00000000 00000000
- 详细参考:Java中的移位运算符 - 知乎 (zhihu.com)
步骤二:测试
Test
类private static final ExecutorService es = Executors.newFixedThreadPool(500);
@Test
public void testSize() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300); // 创建一个计数器,初始值为300
Runnable task = () -> { // 定义一个任务,生成唯一ID并打印
for (int i = 0; i < 100; i++) {
long code = redisIdWorker.nextId("code"); // 生成唯一ID
System.out.println(code); // 打印ID
}
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(end - begin); // 打印任务执行时间
}
说明:
- 此后台为Knife4j,详细实现过程请查看日志
- 需要通过后台先添加限时抢购的优惠券,优惠券添加成功后再进行实现操作
说明:
实现思路:判断时间异常、判断库存、操作库存、创建订单、返回订单ID,实现基本的优惠券的下单功能
步骤一:实现优惠券秒杀下单逻辑
VoucherOrderServiceImpl
类中的seckillVoucher
方法@Resource
private ISeckillVoucherService seckillVoucherService;
@Autowired
RedisIdWorker redisIdWorker;
@Override
@Transactional // 设置到多张表的操作,需要添加事务
/**
* 秒杀优惠券业务
*
* @param voucherId 优惠券的Id
* @return 订单信息
*/
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1").eq("voucher_id", voucherId).update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1设置订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2设置用户ID
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3设置优惠券ID
voucherOrder.setVoucherId(voucherId);
// 6.4保存订单
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
说明:
此时,查看订单库存stock。发现库存已经变为负数,说明出现库存超卖,此问题解决请看下节
说明:
正常情况下,当线程1发现库存大于零时,扣减库存。紧接着,线程2再次判断库存,发现库存依旧大于零,于是正常报错
说明:
当非正常情况下,当线程1查询库存正常之后,有其余的线程接着来进行查询,发现库存正常,它们依次进行库存扣减,就会出现超卖问题
说明:
- 由于乐观锁的修改方式是让线程串行执行,再商品查询缓存时就已经演示过,此处不再演示
- 乐观锁的修改方式,会在程序进行数据执行前检查是否有人进行数据的检查,看是否有人更新
说明:
每次执行SQL语句的修改前,查询版本是否跟查询到的版本号一致,若一致则失败
说明:
既然查询版本号可以发现数据有无修改,那么查询库存也可以发现是否数据有无修改,如此进行库存查询
补充:超卖问题解决
- 悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
- 乐观锁:不加锁,在更新时判断是否有其它线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
说明:
实现思路:
加锁,实现乐观锁,让更新数据时同时检查是否还有库存,若有则秒杀成功
步骤一:新增CAS查询校验
VoucherOrderServiceImpl
的seckillVoucher
方法@Override
@Transactional // 设置到多张表的操作,需要添加事务
/**
* 秒杀优惠券业务
*
* @param voucherId 优惠券的Id
* @return 控制层对此业务的处理结果
*/
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.扣减库存-使用CAS方式解决超卖问题
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1设置订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2设置用户ID
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3设置优惠券ID
voucherOrder.setVoucherId(voucherId);
// 6.4保存订单
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
说明:
- 扣减库存时,判别库存大于0而不是与查询库存时的数量对等进行比较。
- 通过对等进行比较的方式,会让系统觉得有库存安全问题,从而导致停止库存的购买
此时,会出现一个人购买多单的情况。若想要实现一人一单功能,请查看下节
同一用户,对同一个优惠券只能添加一单
说明:
实现思路:
当库存充足时,根据用户ID和优惠券的ID判断订单是否存在,防止出现一人多单的情况
步骤一:添加依赖
pom.xml
文件
<dependency>
<groupId>org.aspectjgroupId>
<artifactId>aspectjweaverartifactId>
dependency>
步骤二:实现一人一单功能
VoucherOrderServiceImpl
类的seckillVoucher
方法并添加createVoucherOrder
方法@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
/*
补充:在此处加锁,而不是在createVoucherOrder方法内加锁。此时,代码的逻辑变为事务执行完成之后再进行锁的释放,保证事务的成功提交。若在方法内加锁,代码的逻辑变为锁已经释放了,但是事务还没有执行完成,依旧会造成线程安全问题。
* */
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
/*
1.使用用户的ID作为锁对象是为了缩小加锁的范围,只为访问此方法的用户加锁。实现了不同用户加不同的锁,保证不同用户之间的并发性能。
2.toString()方法,会在底层new一个新的对象进行加锁,因此添加intern方法,使得率先寻找常量池中的字符串地址值,确保了加锁对象唯一。这样不同的对象就会加不同的锁 */
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
return proxy.createVoucherOrder(voucherId); // 用代理对象来调用此createVoucherOrder函数,因为此代理对象由Spring进行创建,因此该函数可被Spring进行管理。而不是用原生对象来调用此createVoucherOrder函数,例如this.createVoucherOrder。用原生对象来调用此函数,不能够触发@Transactional注解的功能
}
}
/*
@Transactional此注解的生效,是因为Spring对当前VoucherOrderServiceImpl类做了动态代理,从而拿到了VoucherOrderServiceImpl类的代理对象createVoucherOrder方法。因此用代理对象来做的动态代理,所以才能够实现事务管理的功能
*/
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
// 5.1获取用户ID
Long userId = UserHolder.getUser().getId();
// 5.2判断此订单是否存在
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
// 该用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存-使用CAS方式解决超卖问题
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1设置订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2设置用户ID
voucherOrder.setUserId(userId);
// 7.3设置优惠券ID
voucherOrder.setVoucherId(voucherId);
// 7.4保存订单
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
说明:
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
通过Nginx制作了负载均衡后,在不同的JVM中,使用的不同的锁监视器,因此单纯的通过悲观锁来对同一用户加锁会出现并发安全的问题。
若需要解决不同的锁监视器,请查看下一小节
说明:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
·分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:
说明:
- MySQL数据库都支持事务机制,当写入数据时,会自动的上锁,实现了互斥。MySQL支持主从模式。性能比Redis稍微差一点
- Redis使用Setnx实现互斥,唯一不足的方式就是安全性不够高超时时,容易出现死锁
- Zookeeper运用了内部节点的机制,利用唯一性和有序性来实现。性能有很强的一致性,因此主从机制会让性能变得更差一些
基于Redis的分布式锁
说明:
实现分布式锁的时候,只需要将锁监视器让每个JVM能够进行看到即可
实现分布式锁时需要实现的两个基本方法,获取锁以及释放锁
获取锁分为互斥的方式,确保只能有一个线程获取锁。以及非阻塞的方式,尝试一次,成功返回true,失败返回false
# 添加锁,NX是互斥、EX是设置超时时间 SET lock thread1 NX EX 10 # 同时设置超时与判断,确保操作的原子性
释放锁分为手动释放以及超时释放的方式,获取锁时添加一个超时时间
# 释放锁,删除即可 DEL key
说明:
实现思路:
首先建立分布式的锁。业务开始前,先尝试从Redis中获取锁,获取成功则执行业务,否则失败
步骤一:创建锁
ILock
接口/**
* 锁的操作方式,获取锁,释放锁
*/
public interface ILock {
/**
* 获取锁
*
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功;false代表获取锁失败
*/
boolean tryLock(Long timeoutSec);
/**
* 释放锁
*/
void unLock();
}
说明:
定义锁的接口,实现基本的操作规范,获得锁,释放锁
步骤二:分布式锁的实现类
SimpleRedisLock
类,并实现锁ILock
接口public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(Long timeoutSec) {
long threadID = Thread.currentThread().getId(); //获取线程的ID作为锁值
Boolean success = stringRedisTemplate.opsForValue() // 设置锁的超时时间,防止锁的释放
.setIfAbsent(KEY_PREFIX + name, String.valueOf(threadID), timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); //因为返回结果为包装类型,拆箱涉及到可能空指针的影响,这里判断一下
}
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
说明:
根据定义锁的接口,实现基本的操作,获得锁,释放锁
步骤三:修改业务逻辑
VoucherOrderServiceImpl
类中seckillVoucher
方法与createVoucherOrder
方法@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
StringRedisTemplate stringRedisTemplate;
@Autowired
RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate); //拼接用户的ID,为每个用户添加自己的锁
// 获取锁
boolean success = simpleRedisLock.tryLock(10L);
if (BooleanUtil.isFalse(success)) {
// 获取锁失败,返回错误或重试
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
return proxy.createVoucherOrder(voucherId); //用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
} finally {
// 释放锁
simpleRedisLock.unLock();
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
// 5.1获取用户ID
Long userId = UserHolder.getUser().getId();
// 5.2判断此订单是否存在
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
// 该用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存-使用CAS方式解决超卖问题
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1设置订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2设置用户ID
voucherOrder.setUserId(userId);
// 7.3设置优惠券ID
voucherOrder.setVoucherId(voucherId);
// 7.4保存订单
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
说明:
- 修改VoucherOrderServiceImpl类的业务逻辑,实现手动加锁与手动释放锁
- 值得注意的是创建锁的对象时,需要指定Key的值为用户的唯一标识,为不同用户创建锁,这样才可以实现一人一单功能。若不加用户的唯一标识,则标识锁住所有用户
补充:
- 根据分布式锁初级版本的实现情况,可能会导致线程误删除别人的锁的情况。因为线程1的业务阻塞导致锁超时释放,此时线程2发现巧合获得了锁。此时,线程1业务完成后,执行了手动释放锁,于是导致误删除了线程2的锁,会出现并发线程的误删问题
- 此时,要想改变解决这个问题可以在释放锁之前判断一下,这个锁是不是自己的锁,如果是则释放,不是则不释放
说明:
实现思路:
1.在获取锁时存入线程标识(可以用UUID表示)
2.在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致。如果一致则释放锁,如果不一致则不释放锁
补充:
为什么会用到UUID作为存入线程的标识呢,在JVM内部每创建一个线程,ID就会递增。在不同的JVM中,如果直接使用ID作为线程的标识,可能会出现冲突。此时,用UUID区分不同的JVM
步骤一:使用setnx制作分布式锁
SimpleRedisLock
类的tryLock
与unLock
方法public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; // 标识忽略UUID默认自带的下划线
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(Long timeoutSec) {
String threadID = ID_PREFIX + Thread.currentThread().getId(); //获取线程的ID作为锁值
Boolean success = stringRedisTemplate.opsForValue() // 设置锁的超时时间,防止锁的释放
.setIfAbsent(KEY_PREFIX + name, threadID, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); //因为返回结果为包装类型,拆箱涉及到可能空指针的影响,这里判断一下
}
@Override
public void unLock() {
// 获取线程的标识
String threadID = ID_PREFIX + Thread.currentThread().getId();
// 获取锁的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断锁标识和线程标识是否一致
if (ObjectUtil.equal(threadID, id)) {
// 相等,则删除
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
说明:
每次删除锁之前,先进行判断。判断线程标识与锁的标识是否一致,相同则删除,不相同则不是我的,就不删除
补充:
根据分布式锁改进版本的实现情况,还是可能会导致线程误删除别人的锁的情况。因为线程1的业务完成后判断完自己的线程成功后,将要执行释放锁机制的时候,线程阻塞导致锁超时释放(JVM的垃圾回收机制,可能导致线程阻塞)。此时线程2发现巧合获得了锁。此时,线程1业务完成后,执行了手动释放锁,于是导致误删除了线程2的锁,会出现并发线程的误删问题.。
如果,我们能够将业务完成后判断线程与释放锁作为一个事务来完成(利用Lua脚本),确保其原子性就可以完成Redis分布式锁的实现
此时,由于Redis在7.0.x的版本中不再支持Lua脚本的操作,所以我们不再完成Lua脚本的制作
更何况,当使用了Setnx实现了Redis分布式锁也会出现如下问题
接下来请看Redisson的方式对Redission的方式对分布式锁进行优化
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
官网地址: https://redisson.org、GitHub地址: https://github.com/redisson/redisson
步骤一:引入依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.13.6version>
dependency>
补充:
建议建立分布式锁的时候,不建议用通过starter来对SpringBoot进行整合,因为它会替代Spring官网提供的Redis的API的实现
步骤二:添加Redisson
配置类
@Configuration
public class RedisConfig {
@Bean
public RedissonClient getRedisClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://10.13.164.55:6379").setPassword("qweasdzxc");
// 创建客户端
return Redisson.create(config);
}
}
说明:
使用Redis的单节点方式配置Redisson客户端
步骤三:使用Redisson
的分布式锁
VoucherOrderServiceImpl
实现类的seckillVoucher
方法/**
* 秒杀优惠券业务
*
* @param voucherId 优惠券的Id
* @return 控制层对此业务的处理结果
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁(参数含义分别是,获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位)
boolean isLock = lock.tryLock(); // 默认不重试,锁超过30秒自动释放
if (BooleanUtil.isFalse(isLock)) {
// 获取锁失败,返回错误或重试
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //通过此API可以拿到当前对象的代理对象(process),此处也就是IVoucherOrderService接口
return proxy.createVoucherOrder(voucherId); //用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
} finally {
// 释放锁(其Redisson内部会自动进行锁释放标识的比对)
lock.unlock();
}
}
说明:
可以看到Redisson使用的方式跟我们的自建锁类SimpleRedisLock的方式相似
说明:
可重入锁不仅仅在锁里面记录了获取的锁,还记录了获取锁的次数。在底层核心是利用Redis中的Hash类型来记录线程ID,和记录重入次数
说明:
这是Redisson中的可重入锁的原理。从流程图可以分为获取锁和释放锁两部分
说明:
- 当获取锁时,判断锁是否存在
- 锁不存在则获取锁后添加线程标识,再设置锁的有效期
- 锁存在则根据线程标识判断是否是自己,若是则计数加1,并设置锁的有效期,不是自己的,可能因为其余线程正在使用该锁儿导致,因此则获取失败
说明:
- 当业务执行完后,再次判断锁是否是自己的。
- 若不是自己的锁则不再释放锁,防止误删除其余线程正在使用的锁
- 若是自己的锁则技术减1
- 此时判断计数是否为0,因为本线程执行的业务可能为嵌套业务中的子业务,所以不能直接释放锁,而是判断计数后再决定要不要释放
- 为0则释放锁
- 不为0则重置锁的有效期再次循环以上步骤
@SpringBootTest
@Slf4j
public class RedissonTests {
@Autowired
RedissonClient redissonClient;
RLock lock;
@BeforeEach
void before() {
lock = redissonClient.getLock("order");
}
@Test
void method1() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败,1");
return;
}
try {
log.info("获取锁成功,1");
method2();
} finally {
log.info("释放锁,1");
lock.unlock();
}
}
void method2() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败, 2");
return;
}
try {
log.info("获取锁成功,2");
} finally {
log.info("释放锁,2");
lock.unlock();
}
}
}
说明:结果
补充:
- Redisson获取锁源码
- 查看获取锁的源码,可以看到依旧是用Lua脚本执行,并且逻辑跟画的流程图近似。但此处成功返回的是
nil
,获取失败返回的是剩余的时间- Redisson释放锁源码
- 查看释放的源码,可以看到依旧是用Lua脚本执行,并且逻辑跟画的流程图近似。但此处实际上还发布了一条消息
说明:
- 此时若获取成功则返回nil,获取失败返回锁的过期时间
- 查看可重试的源码
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(); // 当前线程ID Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); // 尝试获取锁的剩余过期时间 if (ttl == null) { return true; // 成功获取到锁,返回true } else { time -= System.currentTimeMillis() - current; // 计算剩余等待时间 if (time <= 0L) { this.acquireFailed(waitTime, unit, threadId); // 等待时间已经用完,获取锁失败 return false; } else { current = System.currentTimeMillis(); // 更新当前时间 RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId); // 订阅锁释放事件 if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) { // 等待一段时间看是否能够获取到锁释放事件的通知 if (!subscribeFuture.cancel(false)) { // 取消订阅 subscribeFuture.onComplete((res, e) -> { if (e == null) { this.unsubscribe(subscribeFuture, threadId); // 取消订阅成功后,执行取消订阅操作 } }); } this.acquireFailed(waitTime, unit, threadId); // 等待时间已经用完,获取锁失败 return false; } else { try { time -= System.currentTimeMillis() - current; // 更新剩余等待时间 if (time <= 0L) { this.acquireFailed(waitTime, unit, threadId); // 等待时间已经用完,获取锁失败 return false; } else { boolean var16; do { long currentTime = System.currentTimeMillis(); // 当前时间 ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId); // 再次尝试获取锁的剩余过期时间 if (ttl == null) { var16 = true; // 成功获取到锁,返回true return var16; } time -= System.currentTimeMillis() - currentTime; // 更新剩余等待时间 if (time <= 0L) { this.acquireFailed(waitTime, unit, threadId); // 等待时间已经用完,获取锁失败 var16 = false; return var16; } currentTime = System.currentTimeMillis(); // 当前时间 if (ttl >= 0L && ttl < time) { ((RedissonLockEntry) subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); // 使用剩余过期时间进行等待 } else { ((RedissonLockEntry) subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS); // 使用剩余等待时间进行等待 } time -= System.currentTimeMillis() - currentTime; // 更新剩余等待时间 } while (time > 0L); this.acquireFailed(waitTime, unit, threadId); // 等待时间已经用完,获取锁失败 var16 = false; return var16; } } finally { this.unsubscribe(subscribeFuture, threadId); // 释放订阅锁释放事件 } } } } }
- 该方法用于尝试获取分布式锁。首先,它计算等待时间并获取当前线程的ID。然后,它尝试获取锁的剩余过期时间。如果成功获取锁(剩余过期时间为null),则返回true。如果剩余等待时间已经用完,则获取锁失败,返回false。
- 如果剩余等待时间仍然可用,则订阅锁释放事件(此释放事件,是由释放锁时发布),并等待一段时间看是否能够获取到锁释放事件的通知。如果等待超时或者取消了订阅,则获取锁失败,返回false。
- 如果成功获取到锁释放事件的通知,则再次尝试获取锁的剩余过期时间。如果成功获取到锁(剩余过期时间为null),则返回true。如果剩余等待时间已经用完,则获取锁失败,返回false。
- 在获取锁的过程中,使用了一个计时器来更新剩余等待时间,并在等待过程中进行等待操作。最后,释放订阅的锁释放事件,并返回获取锁的结果。
说明:
- 当任务完成后,会进行回调,回调如果剩余有效期(ttlRemainingFuture)还有剩余,就回去更新锁的过期时间
补充:
- 当leaseTime不等于负1时,就不会开启看门狗的机制,换句话说,也就不会进行超时续约。30秒后过期就会自动释放锁
说明:
- 查看源码
private void scheduleExpirationRenewal(long threadId) { ExpirationEntry entry = new ExpirationEntry(); // 创建一个过期时间续约条目对象 ExpirationEntry oldEntry = (ExpirationEntry) EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry); // 尝试将续约条目放入续约映射中 if (oldEntry != null) { // 如果续约映射中已存在旧的续约条目 oldEntry.addThreadId(threadId); // 将当前线程的ID添加到旧的续约条目中 } else { // 如果续约映射中不存在旧的续约条目 entry.addThreadId(threadId); // 将当前线程的ID添加到新的续约条目中 this.renewExpiration(); // 开始进行过期时间的续约操作 } }
EXPIRATION_RENEWAL_MAP
为RedissonLock类中的ConcurrentMap
集合,用于放入需要续约条目,便于释放锁时使用
- 刷新过期时间,会开启一个任务,并持续的刷新。也就意味着,此时的锁是永不过期的锁
补充:
- 那么这个锁既然是永不过期的锁,那什么时候会释放呢?其实是在释放锁的时候才会过期
说明:
- 当使用Redis集群的方式,容易出现主从一致性问题
- 当获取锁的主节点意外宕机,会从从节点中任选一个作为新主节点,但此时旧主节点无法同步数据到新主节点,从而导致数据丢失
说明:
- 现在,新建一个Redis集群,每次获取锁时将锁数据都同步到各个主节点中
- 即便主节点宕机,但仍然可以保证数据的不丢失
1.修改RedisConfig
配置文件
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://10.13.164.55:6379").setPassword("qweasdzxc");
// 创建客户端
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient2() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://10.13.164.55:6380").setPassword("qweasdzxc");
// 创建客户端
return Redisson.create(config);
}
}
2.修改RedissonTests
测试类
@SpringBootTest
@Slf4j
public class RedissonTests {
@Autowired
RedissonClient redissonClient;
@Autowired
RedissonClient redissonClient2;
RLock lock;
@BeforeEach
void before() {
RLock lock1 = redissonClient.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
// 创建连锁MultiLock
lock = redissonClient.getMultiLock(lock1, lock2); //这里无论使用哪一个客户端来创建MultiLock都可以
}
@Test
void method1() throws InterruptedException {
boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);
if (!isLock) {
log.error("获取锁失败,1");
return;
}
try {
log.info("获取锁成功,1");
method2();
} finally {
log.info("释放锁,1");
lock.unlock();
}
}
void method2() {
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败, 2");
return;
}
try {
log.info("获取锁成功,2");
} finally {
log.info("释放锁,2");
lock.unlock();
}
}
}
补充:
- 查看获取锁源码
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { long newLeaseTime = -1L; // 计算新的租期时间 if (leaseTime != -1L) { if (waitTime == -1L) { newLeaseTime = unit.toMillis(leaseTime); } else { newLeaseTime = unit.toMillis(waitTime) * 2L; } } long time = System.currentTimeMillis(); long remainTime = -1L; // 计算剩余等待时间 if (waitTime != -1L) { remainTime = unit.toMillis(waitTime); } long lockWaitTime = this.calcLockWaitTime(remainTime); int failedLocksLimit = this.failedLocksLimit(); List<RLock> acquiredLocks = new ArrayList<>(this.locks.size()); ListIterator<RLock> iterator = this.locks.listIterator(); while (iterator.hasNext()) { RLock lock = (RLock) iterator.next(); boolean lockAcquired; try { if (waitTime == -1L && leaseTime == -1L) { // 尝试获取锁,不设置等待时间和租期时间 lockAcquired = lock.tryLock(); } else { // 计算实际等待时间 long awaitTime = Math.min(lockWaitTime, remainTime); // 尝试获取锁,设置等待时间和租期时间 lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS); } } catch (RedisResponseTimeoutException var21) { // 获取锁超时,释放已获取的锁 this.unlockInner(Arrays.asList(lock)); lockAcquired = false; } catch (Exception var22) { lockAcquired = false; } if (lockAcquired) { acquiredLocks.add(lock); } else { if (this.locks.size() - acquiredLocks.size() == this.failedLocksLimit()) { // 已获取的锁数量达到失败限制,退出循环 break; } if (failedLocksLimit == 0) { // 已达到失败限制次数,释放已获取的锁 this.unlockInner(acquiredLocks); if (waitTime == -1L) { return false; } // 重新设置失败锁限制和已获取锁列表 failedLocksLimit = this.failedLocksLimit(); acquiredLocks.clear(); while (iterator.hasPrevious()) { iterator.previous(); } } else { // 减少失败锁限制次数 --failedLocksLimit; } } if (remainTime != -1L) { // 更新剩余等待时间 remainTime -= System.currentTimeMillis() - time; time = System.currentTimeMillis(); if (remainTime <= 0L) { // 等待时间已用完,释放已获取的锁并返回失败 this.unlockInner(acquiredLocks); return false; } } } if (leaseTime != -1L) { // 设置锁的租期时间 List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size()); Iterator<RLock> var24 = acquiredLocks.iterator(); while (var24.hasNext()) { RLock rLock = (RLock) var24.next(); // 异步设置锁的租期时间 RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS); futures.add(future); } var24 = futures.iterator(); while (var24.hasNext()) { // 等待设置锁的租期时间操作完成 RFuture<Boolean> rFuture = (RFuture) var24.next(); rFuture.syncUninterruptibly(); } } return true; }
- 多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。若在获取所有节点的任一一个锁失败,则获取锁失败
笔记小结:
1)不可重入Redis分布式锁:
- 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
- 缺陷:不可重入、无法重试、锁超时失效
2)可重入的Redis分布式锁:
- 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
- 缺陷:redis宕机引起锁失效问题
3)Redisson的multiLock:
- 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
- 缺陷:运维成本高、实现复杂
说明:
左侧图为尝试获取锁的逻辑执行流程、右侧图为尝试释放锁的逻辑执行流程
说明:
基于当前的秒杀业务实现方案,依旧会出现业务流程耗时。因为当执行查询优惠券、查询订单等操作都涉及到频繁的操作数据库
说明:
现在基于Redis来优化秒杀功能,减轻数据库的操作业务。并将同步操作数据库的方式改为异步的方式。减轻秒杀业务的流程,让秒杀完成执行更快
说明:
实现思路:
判断库存是否充足,不充足则结束,充足则进行判断、判断用户是否已完成下单,下单过则结束,没下单则扣减库存、库存扣减后,保存用户的ID到Set集合。(先利用Redis完成库存余量、一人一单判断,完成抢单业务)若下单成功则,返回该订单编号的ID,若下单失败,则秒杀失败(再将下单业务放入阻塞队列,利用独立线程异步下单)
使用Lua脚本是为了保证操作的原子性,要么判断库存、扣减库存同时成功,要么同时失败
- 将用户ID保存到Set集合,是利用自带除重功能,防止该用户重复下单,以实现一人一单的功能
步骤一:新增秒杀优惠券的同时,将优惠券信息保存到Redis中
VoucherServiceImpl
的addSeckillVoucher
方法@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存库存到redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), String.valueOf(voucher.getStock()));
}
说明:
让秒杀券的库存信息,保存到Redis中,减少读取MySQl数据库的频率
步骤二:基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
Resources
里添加seckill.lua
脚本-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2用户id
local userId = ARGV[2]
-- 2.数据key
-- 2.1库存Key,用于获取秒杀券的信息
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单Key,用于保存购买用户的用户Id,值为Set集合
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
-- 3.2 库存不足,返回1
return 1
end
-- 3.3判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5,下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
步骤三:如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
VoucherOrderServiceImpl
类中的seckillVoucher
方法public IVoucherOrderService PROXY; //此时,无法子线程中拿到代理对象,因此定义为成员变量,让子线程可用获取此类的代理对象
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 定义一个阻塞队列
private BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024 * 1024); //类似于MQ
// 静态代码块,用于初始化秒杀脚本
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
// 设置秒杀脚本的位置为类路径下的 "seckill.lua" 文件
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
// 设置秒杀脚本执行结果的类型为 Long
SECKILL_SCRIPT.setResultType(Long.class);
}
/**
* 秒杀优惠券业务
*
* @param voucherId 优惠券的Id
* @return 控制层对此业务的处理结果
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 1.执行Lua脚本(参数含义:脚本,key,Value)
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // 因为我们的Lua脚本没有Key,因此利用Collections工具传递空集合
voucherId.toString(), //将Long类型转换为String类型
userId.toString()
);
// 2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1不为0,代表没有购买资格
return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
}
// 2.2为0,有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
// 2.3.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 2.4设置订单ID;
voucherOrder.setId(orderId);
// 2.5设置用户ID
voucherOrder.setUserId(userId);
// 2.6设置优惠券ID
voucherOrder.setVoucherId(voucherId);
// 2.7保存订单到阻塞队列
orderTask.add(voucherOrder);
// 3.获取代理对象
PROXY = (IVoucherOrderService) AopContext.currentProxy();// 在主线程中获取代理对象,便于子线程中使用
// 4.返回订单Id
return Result.ok(orderId);
}
步骤四:开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
// 定义一个线程池
private final static ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
/**
* 利用Spring框架提供的注解,让在本类初始化完成之后来执行方法内的内容
*/
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
/**
* 定义一个内部类,用于实现异步订单的处理
*/
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) { // 此处循环执行代码逻辑
try {
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTask.take(); // 此时,若队列里面没有订单信息,则会阻塞在这里
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常" + e); // 因为这里是子线程来执行任务的处理,因此不用抛出异常,仅仅打印日志即可
}
}
}
}
/**
* 处理订单信息的业务逻辑
*
* @param voucherOrder 阻塞队列里面的订单信息
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 1.获取用户
Long userId = voucherOrder.getUserId(); //因为此方法交给子线程来执行,因此无法通过主线程的 UserHolder.getUser().getId()方法来获取用户的ID
// 2.获取锁对象
RLock lock = redissonClient.getLock(LOCK_ORDER_KEY + userId); // 其实此处无需加锁,因为每个用户不会有多次下单的操作,除非Redis出现异常。因此,这里加锁判断一下比较好
// 3.获取锁
boolean isLock = lock.tryLock();
// 4.判断锁是否获取成功
if (!isLock) {
// 获取锁失败,返回错误重试
log.error("不允许重复下单");
return;
}
// 5.获取代理对象
try { // 此时无法从子线程中获取
PROXY.createVoucherOrder(voucherOrder);// 用代理对象来调用此createVoucherOrder函数,此时该函数可被Spring进行管理。因为此代理对象由Spring进行创建
} finally {
// 释放锁
lock.unlock();
}
}
/**
* 创建订单信息
* 这里主要实现了一个对数据库的事务操作,保证
*
* @param voucherOrder 阻塞队列里面的订单信息
*/
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
// 5.根据优惠券ID和用户ID判断订单是否存在-实现一人一单功能
// 5.1获取用户ID
Long userId = voucherOrder.getUserId();
// 5.2判断此订单是否存在
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) { // 此处同样也不可能出现重复,除非Redis集群宕机
// 该用户已经购买过了
log.error("用户已经购买过一次!");
return;
}
// 6.扣减库存-使用CAS方式解决超卖问题
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0) // 让库存大于零,若有库存则进行扣库存
.update();
if (!success) {
// 扣减失败
log.error("库存不足");
return;
}
// 7.保存订单
save(voucherOrder);
}
补充:
现在异步阻塞仍然存在问题,内存限制问题,此时阻塞队列是保存在Jvm中,因此Jvm宕机,那么订单信息就会丢失,从而导致了数据安全问题
消息队列(Message Queue),字面意思就是存放消息的队列。
最简单的消息队列模型包括3个角色:
说明:
- MQ不受Jvm限制。MQ内的消息会做持久化
其中,Redis提供了三种不同的方式来实现消息队列:
消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
队列是入口和出口不在一边,因此我们可以利用:LPUSH
结合 RPOP
、或者 RPUSH 结合 LPOP来实现。
说明:
- 不过要注意的是,当队列中没有消息时
RPOP
或LPOP
操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP
或者BLPOP
来实现阻塞效果。
补充:基于List结构模拟消息队列的优点
- 优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
- 缺点:
- 无法避免消息丢失
- 只支持单消费者
- 如果此时从Redis中获取了消息队列中的消息,而消费队列中的服务挂掉了,那么数据也就丢失了
**PubSub(发布订阅)**是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
说明:
- 发布订阅模式,简单的说就是一生产多消费的模式
说明:
- 当订阅与Pattern格式相匹配的所有频道后,可获得多个格式相匹配的频道消费消息
补充:基于PubSub的消息队列有哪些优缺点
- 优点:
- 采用发布订阅模型,支持多生产、多消费
- 缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
- 基于PubSub进行发布于订阅的模式,会将消息存储在消费者那里。然而消费者那里的缓存空间是有上限的,如果超出就会丢失
- 客户端的保存的消息,会如果生产者发出的数据,没有人在Redis中做订阅,那么数据就会丢失。
Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列
Redis Stream主要用于消息队列(MQ,Message Queue),Redis Stream提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失
说明:
- Consumer Group :消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer)
- last_delivered_id :游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动
- pending_ids :消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有 ack (Acknowledge character:确认字符)
说明:
- 当使用阻塞式读取消息队列中的数据的时候,超时则返回
nil
- 在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:
注意:
- 当指定ID为
$
时,此时,又有多条消息到达队列,那么每次获取到的信息都是最新的一条,就会出现漏读的情况
说明:
消费者组(Consumer Group),是将多个消费者划分到一个组中,监听同一个队列。具备如上特点,
说明:
- 以上是基于Stream的消息队列来实现异步秒杀的伪代码
- 如果有报错异常,那么就会一直执行,直到本条消息处理成功为止
补充:STREAM类型消息队列的XREADGROUP命令特点
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次
说明:
- List集合不支持消息回溯:当消费者处理消息时发生错误或异常退出时,已经入队但尚未处理的消息可能会丢失
- PubSub不支持消息回溯:由于消息的发布和订阅是异步的,无法保证消息的严格顺序性。并且,Redis本身不负责跟踪订阅者的状态,当订阅者断开连接后,无法获知其状态变化。
说明:
实现思路:接着修改上一小节中的Redis优化秒杀的功能
步骤一:创建一个Stream类型的消息队列,名为stream.orders
XGROUP CREATE stream.orders g1 0 MKSTREAM
说明:
使用命令行工具提前在Reids中创好,因为在整个Redis中仅创建一个就可以了,不需要重复的创建
步骤二:修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
1.在Resources
里修改seckill.lua
脚本
-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2用户id
local userId = ARGV[2]
-- 1.3订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1库存Key,用于获取秒杀券的信息
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2订单Key,用于保存购买用户的用户Id,值为Set集合
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
-- 3.2 库存不足,返回1
return 1
end
-- 3.3判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5,下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6发送消息到队列中,XADD stream.orders * k1 v1 k2 v2 ...return
redis.call("xadd", "stream.orders","*", "userId", userId,"voucherId", voucherId,"id",orderId)
return 0
说明:
使用操作数据库的
xadd
,添加Lua脚本中发送消息到阻塞队列
2.修改VoucherOrderServiceImpl
类中的seckillVoucher
方法
/**
* 秒杀优惠券业务
*
* @param voucherId 优惠券的Id
* @return 控制层对此业务的处理结果
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 获取订单ID
long orderId = redisIdWorker.nextId("order");
// 1.执行Lua脚本(参数含义:脚本,key,Value)
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // 因为我们的Lua脚本没有Key,因此利用Collections工具传递空集合
voucherId.toString(), //将Long类型转换为String类型
userId.toString(),
String.valueOf(orderId)
);
// 2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1不为0,代表没有购买资格
return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
}
// 3.获取代理对象
PROXY = (IVoucherOrderService) AopContext.currentProxy();// 在主线程中获取代理对象,便于子线程中使用
// 4.返回订单Id
return Result.ok(orderId);
}
步骤三:项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
VoucherOrderServiceImpl
类中的VoucherOrderHandler
方法/**
* 定义一个内部类,用于实现异步订单的处理
*/
private class VoucherOrderHandler implements Runnable {
String queueName = "stream.orders"; // 注意,此处的队列名称,需要与Lua脚本里的相对于
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.order >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), // 读取消费者信息
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
// 2.判断消息获取是否成功
if (list == null || list.isEmpty()) {
// 2.1 如果获取失败,说明没有消息,继续下一次循环
continue;
}
// 3.解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
// 4.如果获取成功,可以下单
handleVoucherOrder(voucherOrder);
// 5.ACK确认 SACK stream.order g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); //设置消息ID
} catch (Exception e) {
log.error("处理订单异常" + e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.order 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( // 注意,此处读取消息变为0
Consumer.from("g1", "c1"), // 读取消费者信息
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))// 未提供API,需要自己上传数据
);
// 2.判断消息获取是否成功
if (list == null || list.isEmpty()) {
// 2.1 如果获取失败,说明pendingList没有消息, 结束循环
break;
}
// 3.解析消息中的订单信息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4.如果获取成功,可以下单
handleVoucherOrder(voucherOrder);
// 5.ACK确认 SACK stream.order g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId()); //设置消息ID
} catch (Exception e) {
log.error("处理pendingList订单异常" + e);
try {
Thread.sleep(20); // 若抛出异常,可休眠一段时间后再进行PendingList的消息处理
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
}
}
说明:
修改此处代码,实现基于Stream的消息队列的伪代码逻辑
笔记小结:
Redis功能点:
在点赞功能中,使用Redis中的
SortedSet
命令,通过score
方法查询该登录用户是否为该博客或每篇博客点赞。通过add
方法记录登录用户为该篇博客的点赞信息。通过remove
方法移除登录用户为该篇博客的点赞信息在点赞排行榜功能中,使用Redis中的
SortedSet
命令,通过range
方法查询点赞博客的用户排名功能实现疑难点:
- MyBatis-plus中使用
eq
、update
、setSql
、添加SQL语句last
的基本使用- Stream流中,类型转换
map()
、终结方法toList()
- Hutool工具中,拼接字符串
StrUtil.join
、实体类拷贝BeanUtil.copyProperties
达人探店是一种旅游和探索体验的活动,通常由旅行者或旅游爱好者作为达人(专家或导游)来引导其他人探索特定目的地的文化、历史、美食、景点
补充:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
步骤一:给Blog类中添加一个isLike字段,标示是否被当前用户点赞
/**
* 是否点赞过了
*/
@TableField(exist = false)
private Boolean isLike;
说明:
新增isLike成员属性,用于判断用户是否点赞。这个属性不存在于数据库,因此添加注解,便于查询时是否选择高亮
步骤二:修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
@Override
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
String key = "blog:liked:" + id; //以商铺的Key作为键
// 2.判断当前登录用户是否已经点赞
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
// 3.如果未点赞,可以点赞
if (BooleanUtil.isFalse(isMember)) { //包装类自动拆箱时,可能为空
// 3.1数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2保存用户到Redis的Set集合
if (isSuccess) {
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
} else {
// 4.如果已点赞,取消点赞
// 4.1数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2把用户从Redis的set集合中移除
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}
说明:
修改MySQL数据库点赞总数,查询Redis数据库的点赞用户
步骤三:修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
@Override
public Result queryBlogById(Long id) {
// 1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
// 2.查询blog有关的用户
queryBlogUser(blog);
// 3.查询blog是否被点赞
queryBlogLiked(blog);
return Result.ok(blog);
}
// 查询Redis中,该商铺,该用户是否点赞
private void queryBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
if (ObjectUtil.isNull(user)) {
// 用户未登录的情况,不做查询
return;
}
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + blog.getId(); //以商铺的Key作为键
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(ObjectUtil.isNotNull(score));
}
/**
* 根据博客查询用户
*
* @param blog 商铺
*/
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
说明:
查询商铺内博客的详细信息,查询博客的内容,以及该博客是由哪个用户发布和登录用户为博客的点赞信息
步骤四:修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
queryBlogUser(blog);
queryBlogLiked(blog); //每个商铺都需要查询该用户是否已点赞
});
return Result.ok(records);
}
说明:
遍历所有商铺,查询该用户为哪些商铺点赞,并将该用户所点赞的所有商铺设置为True
说明:
通过SortedSet集合记录用户点赞情况,并且为点赞排行做铺垫。而List、Set在点赞排行上各有缺点
步骤一:替换点赞的Redis命令集为SortedSet
1.修改BlogServiceImpl类的likeBlog方法
@Override
public Result likeBlog(Long id) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id; //以商铺的Key作为键
// 2.判断当前登录用户是否已经点赞
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 3.如果未点赞,可以点赞
if (ObjectUtil.isNull(score)) { //包装类自动拆箱时,可能为空
// 3.1数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 3.2保存用户到Redis的Set集合
if (isSuccess) {
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
} else {
// 4.如果已点赞,取消点赞
// 4.1数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 4.2把用户从Redis的set集合中移除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
2.修改BlogServiceImpl类的queryBlogLiked方法
// 查询Redis中,该商铺,该用户是否点赞
private void queryBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
if (ObjectUtil.isNull(user)) {
// 用户未登录的情况,不做查询
return;
}
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断当前登录用户是否已经点赞
String key = BLOG_LIKED_KEY + blog.getId(); //以商铺的Key作为键
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(ObjectUtil.isNotNull(score));
}
步骤二:实现点赞排行榜查询功能
@Override
public Result queryBlogLikes(Long id) {
// 1.查询Top5的点赞用户
String key = BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
// 2.解析出用户的Id
if (ObjectUtil.isEmpty(top5) || ObjectUtil.isNull(top5)) {
return Result.ok(Collections.emptyList()); // 返回一个空集合
}
List<Long> ids = top5.stream().map(Long::valueOf).toList();
String idStr = StrUtil.join(",", ids);
// 3.根据Id查询用户信息 WHERE id in (5,1) ORDER BY FIELD(id,5,1)
// 因为关系型数据库SQL语句查询in的特性,默认根据ID升序来排序,会改变用户排行榜的顺序,因此需要通过ORDER BY FIELD来进行自定义排序
List<UserDTO> userList = userService.query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")") //拼接ID
.list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.toList();
return Result.ok(userList);
}
笔记小结:
- Redis功能点
- 在关注和取关功能中,使用了Redis的
Set
命令集,通过add
和remove
方法,对登录用户关注其余博主实现了关注和取关的功能- 在共同关注功能中,使用了Redis的
Set
命令集,通过intersect
方法对二者集合求交集处理实现了共同关注的功能- 在关注推送功能中,使用Redis中的
SortedSet
命令,通过add
方法将博主发布的博客添加到粉丝的收件箱中(实现的为Feed流的推模式)。通过reverseRangeByScoreWithScores
方法进行滚动分页查询实现了分页滚动的查询功能- 功能实现疑难点:
- MyBatis-plus中使用
remove
、last
进行SQL语句添加使用- Stream流中,联合MyBatis-plus类型转换的
map()
方法联合使用- Hutool工具中,拼接字符串
StrUtil.join
通过完成数据库的SQLORDER BY FIELD
实现多条数据的有序查询、实体类拷贝BeanUtil.copyProperties
完成DTO的拷贝- 关注推送功能中,Feed流推模式的动态查询,通过记录最小时间,与跳过元素次数对简单的算法进行实现。查询博客后的基本信息,以及点赞信息的细节实现
说明:
实现关注和取消关注功能
步骤一:实现关注功能
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断是关注还是取消关注
if (BooleanUtil.isTrue(isFollow)) {
// 关注
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
} else {
// 取消关注,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
}
return Result.ok();
}
步骤二:实现查询关注功能
@Override
public Result isFollow(Long followUserId) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.查询是否关注
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
// 3.判断
return Result.ok(count > 0);
}
说明:
实现关注和取消关注功能
步骤一:修改关注逻辑
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.判断是关注还是取消关注
if (BooleanUtil.isTrue(isFollow)) {
// 关注
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
// 当前登录用户记录被关注者的ID
String key = "follows:" + userId;
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 取消关注,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
// 把关注用户的ID从Redis中移除
String key = "follows:" + userId;
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
步骤二:实现共同关注逻辑
@Override
public Result followCommon(Long followUserId) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
String userKey = "follows:" + userId;
String followKey = "follows:" + followUserId;
// 2.查询登录用户与该博客账号共同关注名单
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(userKey, followKey);
// 3.解析Id集合
if (ObjectUtil.isNull(intersect) || ObjectUtil.isEmpty(intersect)) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = intersect.stream().map(Long::valueOf).toList();
// 4.查询用户
List<UserDTO> userIds = userService.query().in("id", ids).list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class)).toList();
return Result.ok(userIds);
}
说明:
主要是利用Redis中Set集合的
intersect
方法,寻找共同值
说明:
关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息
Feed流产品有两种常见模式:
拉模式:也叫做读扩散
说明:
当博主发布博客时,会将博客存储在自己的发件箱内。粉丝在自己的收件箱内拉取发件箱的内容,内容自动排序
推模式:也叫做写扩散
说明:
当博主发布博客时,此时博主没有收件箱,直接推送消息到每个粉丝用户。每个粉丝用户从自己的收件箱中获取博客消息
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点
说明:
博主发送博客,如果是大V博主,则有自己的发件箱,普通粉丝读取发件箱里的内的内容,活跃粉丝则直接将发件箱内的博客收入自己的收件箱。如果是普通博主,则直接推送博客到粉丝的收件箱中
Feed流实现方案
步骤一:修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店博文
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增笔记失败");
}
// 3. 查询笔记作者的所有粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// 4. 推送笔记Id到所有粉丝
for (Follow follow : follows) {
// 4.1获取粉丝Id
Long userId = follow.getUserId();
// 4.2推送到粉丝收件箱
String key = "feed:" + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5.返回id
return Result.ok(blog.getId());
}
步骤二:实现分页查询
说明:
使用滚动分页的形式,实现Feed流的滚动分页查询
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取登录用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3.非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok(); // 如果关注列表没有数据,则返回
}
// 4.解析数据:blogId、minTime(时间戳)、offset(集合中分数值等于最新时间的元素个数),拿到最新时间戳粉丝和
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1; //定义需要跳过的元素个数
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 4.1.获取id
System.out.println("value:::" + tuple.getValue());
ids.add(Long.valueOf(tuple.getValue())); // 收集一下Blog集合
// 4.2.获取分数(时间戳)
long time = tuple.getScore().longValue(); //记录一下最新时间戳分数
System.out.println("value:::" + tuple.getScore());
if (time == minTime) { // 如果 最新时间戳跟上一个元素时间戳相同则增加跳过元素次数,避免重复查询
os++;
} else {
minTime = time;
os = 1; // 重置跳过元素的个数
}
}
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); // 因为In语句不能保证查询出的ID
for (Blog blog : blogs) {
// 5.1.查询blog有关的用户
queryBlogUser(blog); // 设置一下博客的基本信息
// 5.2.查询blog是否被点赞
queryBlogLiked(blog); // 该博客是否点赞
}
// 6. 封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os); // 将本次查询跳过次数进行封装返回,避免下一次结果的重复查询
r.setMinTime(minTime);
return Result.ok(r);
}
补充:
ZREVRANGEBYSCORE
方法命令是返回有序集中指定分数区间内的所有的成员
笔记小结:
- Redis功能点
- 在关注和取关功能中,使用了Redis的
Set
命令集,通过add
和remove
方法,对登录用户关注其余博主实现了关注和取关的功能- 在共同关注功能中,使用了Redis的
Set
命令集,通过intersect
方法对二者集合求交集处理实现了共同关注的功能- 在关注推送功能中,使用Redis中的
SortedSet
命令,通过add
方法将博主发布的博客添加到粉丝的收件箱中(实现的为Feed流的推模式)。通过reverseRangeByScoreWithScores
方法进行滚动分页查询实现了分页滚动的查询功能- 功能实现疑难点:
- MyBatis-plus中使用
remove
、last
进行SQL语句添加使用- Stream流中,联合MyBatis-plus类型转换的
map()
方法联合使用- Hutool工具中,拼接字符串
StrUtil.join
通过完成数据库的SQLORDER BY FIELD
实现多条数据的有序查询、实体类拷贝BeanUtil.copyProperties
完成DTO的拷贝- 关注推送功能中,Feed流推模式的动态查询,通过记录最小时间,与跳过元素次数对简单的算法进行实现。查询博客后的基本信息,以及点赞信息的细节实现
说明:
每个商铺都有类型进行分类
说明:
我们在Redis中,可以利用商铺的类型作为一组ID作为Redis的键,每个商铺的ID作为值,每个商铺的地理位置作为分数
补充:
步骤一:导入店铺的信息到GEO
/**
* 导入店铺信息到GEO
*/
@Test
public void loadShopData() {
// 1.查询商铺信息
List<Shop> shopList = shopService.list();
// 2.根据商铺typeId分组
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3.分批完成Redis的写入
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 3.1获取商铺类型id
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId;
// 3.2获取同类型的商铺集合
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
// 3.3写入redis GEOADD key 经度 纬度 member
for (Shop shop : value) {
RedisGeoCommands.GeoLocation<String> e = new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
);
locations.add(e);
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
说明:添加成功
步骤二:实现通过商铺类型查询商铺
新建ShopServiceImpl方法中的ShopServiceImpl方法
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否通过距离进行查询
if (ObjectUtil.isNull(x) || ObjectUtil.isNull(y)) {
// 根据类型分页查询
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 2.计算分页查询
String key = SHOP_GEO_KEY + typeId;
Integer from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
Integer end = current * SystemConstants.DEFAULT_PAGE_SIZE;
// 3.查询redis,按照距离排序、分页。结果:shopId、distance
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() //GEOSEARSH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000), // 指定距离范围
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance() //返回结果带上距离
.limit(end) //limit方法,指定从 0 到 end
);
// 4.解析出ID
if (ObjectUtil.isNull(results) || ObjectUtil.isEmpty(results)) {
return Result.ok(Collections.emptyList()); //无最新数据
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
return Result.ok(Collections.emptyList()); //无最新数据
}
// 4.1截取from ~ end的部分
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> skipList = list.stream().skip(from).toList();
List<Long> shopIds = new ArrayList<>(skipList.size());
Map<Long, Distance> shopDistances = new HashMap<>(skipList.size());
for (GeoResult<RedisGeoCommands.GeoLocation<String>> geoLocationGeoResult : skipList) {
// 4.2 获取店铺Id
Long shopId = Long.valueOf(geoLocationGeoResult.getContent().getName());
shopIds.add(shopId);
// 4.3 获取距离
Distance distance = geoLocationGeoResult.getDistance();
shopDistances.put(shopId, distance);
}
// 5.根据ID查询shop
String idStr = StrUtil.join(",", shopIds);
List<Shop> shopList = query().in("id", shopIds).last("ORDER BY FILED(id," + idStr + ")").list();
for (Shop shop : shopList) {
Distance distance = shopDistances.get(shop.getId());
shop.setDistance(distance.getValue());
}
// 6.返回
return Result.ok(shopList);
}
笔记小结:
Redis功能点:
在签到能中,使用Redis中的
Bitmap
命令,通过setBit
方法设置当天用户是否签到(记住offset方法参数从0开始)在签到统计功能中,使用Redis中的
Bitmap
命令,通过bitField
方法,获取数组中指定范围的签到十进制数功能实现疑难点:
- 在实现签到统计功能中,使用了
&
运算符,通过个位数与运算结果是否为0判断是否签到,并进行位移,记录了连续签到数,实现了算法的小实现
说明:
bitMap这种位图为是实现签到思路的好法
说明:
通过操作bitMap即可实现签到的次数统计
UserServiceImpl
类中的UserServiceImpl
方法/**
* 登录方法
*
* @return 成功
*/
@Override
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
// 3.拼接Key
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
说明:
在StringRedisTemplate中,对bitMap的命令放在
opsForValue
方法中
UserServiceImpl
类中的signCount
方法/**
* 统计最近一次连续签到天数
*
* @return 签到天数
*/
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
// 3.拼接Key
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截至今天为止的所有签到记录,并返回一个十进制数 BITFIELD sign:5:202303 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(key,
BitFieldSubCommands.create().
get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)) //指定从第几天结束
.valueAt(0) // 指定从第几天开始
);
if (ObjectUtil.isNull(result) || ObjectUtil.isEmpty(result)) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (ObjectUtil.isNull(num) || num == 0) {
// 没有任何签到结果
return Result.ok(0);
}
// 6.遍历循环
int count = 0;
while (true) {
// 6.1让这个数字与1做与运算,得到数字的最后一个bit位。判断这个bit位是否位零
if ((num & 1) == 0) {
// 为0,则表示未签到
break;
} else {
//为1,则表示已签到
count++;
}
// 继续位移,判断下一位
num >>>= 1; // num = num >> 1
}
return Result.ok(count);
}
笔记小结:
Redis功能点:
在UA统计的功能中,使用Redis中的
HyperLogLog
命令,通过add
方法将用户添加至Redis中,实现了用户访问量的统计
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。
@Test
public void testHyperLogLog() {
String key = "user:ua:2023:7";
// 准备一个空数组
String[] users = new String[1000];
int index = 0;
for (int i = 1; i <= 1000000; i++) {
users[index++] = "user_" + i;
if (i % 1000 == 0) {
index = 0;
// 发送到redis
stringRedisTemplate.opsForHyperLogLog().add(key, users);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size(key);
System.out.println(count);
}
Idea快捷键Ctrl+alt+u
可以出现类的关系依赖图
idea快捷键Ctrl+shift+u
将选中内容大小写
(55条消息) 【Java 8系列】收集器Collector与工具类Collectors_collector 处理_善良勤劳勇敢而又聪明的老杨的博客-CSDN博客
(53条消息) Java并发——Executor框架详解(Executor框架结构与框架成员)_tongdanping的博客-CSDN博客
步骤一:添加依赖
<dependency>
<groupId>com.github.xiaoymingroupId>
<artifactId>knife4j-spring-boot-starterartifactId>
<version>3.0.3version>
dependency>
步骤二:添加配置文件
/**
* knife4j配置信息
*/
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfig {
@Bean
public Docket defaultApi() {
return new Docket(DocumentationType.SWAGGER_2)
.groupName("黑马点评管理系统后台接口文档")
.apiInfo(defaultApiInfo())
.select()
.apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
.paths(PathSelectors.any())
.build();
}
private ApiInfo defaultApiInfo() {
return new ApiInfoBuilder()
.title("管理系统后台接口文档")
.description("管理系统后台接口文档")
.contact(new Contact("开发组", "", ""))
.version("1.0")
.build();
}
}
步骤三:修改Yml配置
spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
步骤四:修改拦截器规则
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getLoginInterceptor()).excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login",
"/doc.html/**", // 需要放行此类文件需要的路径请求
"/swagger-resources/**",
"/v2/**"
).order(1);
registry.addInterceptor(getRefreshTokenInterceptor()).addPathPatterns("/**").order(0);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { // 处理一下,静态访问的路径
registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}