短信登录
商户查询缓存
优惠卷秒杀
附近的商户
UV统计
用户签到
好友关注
达人探店
在实现功能之前,我们先来导入项目,让项目跑起来
黑马已经在资料中提供好了SQL文件,这里简单分析一下提供的表
表 | 说明 |
---|---|
tb_user | 用户表 |
tb_user_info | 用户详情表 |
tb_shop | 商户信息表 |
tb_shop_type | 商户类型表 |
tb_blog | 用户日记表(达人探店日记) |
tb_follow | 用户关注表 |
tb_voucher | 优惠券表 |
tb_voucher_order | 优惠券的订单表 |
该项目采用的是前后端分离开发模式
手机或者app端发起请求,请求我们的Nginx服务器,Nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开Tomcat访问Redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游Tomcat服务器,打散流量,我们都知道一台4核8G的Tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过Nginx的负载均衡分流后,利用集群支撑起整个项目,同时Nginx在部署了前端项目后,更是可以做到动静分离,进一步降低Tomcat服务的压力,这些功能都得靠Nginx起作用,所以Nginx是整个项目中重要的一环。
在Tomcat支撑起并发流量后,我们如果让Tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。
start nginx.exe
请求网址: http://localhost:8080/api/user/code?phone=15832165478
请求方法: POST
phone
,看黑马提供的源码也证实了我的猜想/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return Result.fail("功能未完成");
}
javax.activation
activation
1.1.1
javax.mail
mail
1.4.7
org.apache.commons
commons-email
1.4
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import javax.mail.Authenticator;
import javax.mail.MessagingException;
import javax.mail.PasswordAuthentication;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMessage.RecipientType;
public class MailUtils {
public static void main(String[] args) throws MessagingException {
//可以在这里直接测试方法,填自己的邮箱即可
sendTestMail("[email protected]", new MailUtils().achieveCode());
}
public static void sendTestMail(String email, String code) throws MessagingException {
// 创建Properties 类用于记录邮箱的一些属性
Properties props = new Properties();
// 表示SMTP发送邮件,必须进行身份验证
props.put("mail.smtp.auth", "true");
//此处填写SMTP服务器
props.put("mail.smtp.host", "smtp.qq.com");
//端口号,QQ邮箱端口587
props.put("mail.smtp.port", "587");
// 此处填写,写信人的账号
props.put("mail.user", "[email protected]");
// 此处填写16位STMP口令
props.put("mail.password", "tXXXXXXXXXfgjb");
// 构建授权信息,用于进行SMTP进行身份验证
Authenticator authenticator = new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
// 用户名、密码
String userName = props.getProperty("mail.user");
String password = props.getProperty("mail.password");
return new PasswordAuthentication(userName, password);
}
};
// 使用环境属性和授权信息,创建邮件会话
Session mailSession = Session.getInstance(props, authenticator);
// 创建邮件消息
MimeMessage message = new MimeMessage(mailSession);
// 设置发件人
InternetAddress form = new InternetAddress(props.getProperty("mail.user"));
message.setFrom(form);
// 设置收件人的邮箱
InternetAddress to = new InternetAddress(email);
message.setRecipient(RecipientType.TO, to);
// 设置邮件标题
message.setSubject("Kyle's Blog 邮件测试");
// 设置邮件的内容体
message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");
// 最后当然就是发送邮件啦
Transport.send(message);
}
public static String achieveCode() { //由于数字 1 、 0 和字母 O 、l 有时分不清楚,所以,没有数字 1 、 0
String[] beforeShuffle = new String[]{"2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F",
"G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a",
"b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
"w", "x", "y", "z"};
List list = Arrays.asList(beforeShuffle);//将数组转换为集合
Collections.shuffle(list); //打乱集合顺序
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s); //将集合转化为字符串
}
return sb.substring(3, 8);
}
}
/**
* 发送手机验证码
*/
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
// TODO 发送短信验证码并保存验证码
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确");
}
String code = MailUtils.achieveCode();
session.setAttribute(phone, code);
log.info("发送登录验证码:{}", code);
MailUtils.sendTestMail(phone, code);
return Result.ok();
}
然后输入邮箱,发送验证码,看看能否接收到验证码
测试没有问题之后,我们继续来编写登录功能,点击登录按钮,查看发送的请求
请求网址: http://localhost:8080/api/user/login
请求方法: POST
{phone: "[email protected]", code: "iMPKc"}
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// TODO 实现登录功能
return Result.fail("功能未完成");
}
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
{% endtabs %}
{% tabs 实现登录业务逻辑651 %}
/**
* 登录功能
*
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
//获取session中的验证码
Object cacheCode = session.getAttribute(phone);
//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (code == null || !cacheCode.toString().equals(code)) {
//4. 不一致则报错
return Result.fail("验证码不一致!!");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
// 创建的逻辑封装成了一个方法
user = createUserWithPhone(phone);
}
//7. 保存用户信息到session中
session.setAttribute("user", user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
//创建用户
User user = new User();
//设置手机号
user.setPhone(phone);
//设置昵称(默认名),一个固定前缀+随机字符串
user.setNickName("user_" + RandomUtil.randomString(8));
//保存到数据库
userService.save(user);
return user;
}
{% endtabs %}
这部分需要用到拦截器的知识,我在前面的SSM整合篇做过详细介绍
{% link SSM整合, https://cyborg2077.github.io/2022/09/10/SSMIntegration/, https://pic1.imgdb.cn/item/6335135c16f2c2beb100182d.jpg %}
创建一个LoginInterceptor类,实现HandlerInterceptor接口,重写其中的两个方法,前置拦截器和完成处理方法,前置拦截器主要用于我们登陆之前的权限校验,完成处理方法是用于处理登录后的信息,避免内存泄露
{% tabs 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中的用户信息
User user = (User) session.getAttribute("user");
//3. 判断用户是否存在
if (user == null) {
//4. 不存在,则拦截
response.setStatus(401);
return false;
}
//5. 存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(user);
//6. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
这是黑马已经提供好了的一个工具类
public class UserHolder {
private static final ThreadLocal<User> tl = new ThreadLocal<>();
public static void saveUser(User user){
tl.set(user);
}
public static User getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
}
}
{% endtabs %}
@GetMapping("/me")
public Result me() {
// TODO 获取当前登录的用户并返回
User user = UserHolder.getUser();
return Result.ok(user);
}
{
"success":true,
"data":{
"id":1010,
"phone":"[email protected]",
"password":"",
"nickName":"user_i1b3ir09",
"icon":"",
"createTime":"2022-10-22T14:20:33",
"updateTime":"2022-10-22T14:20:33"
}
}
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
public class UserHolder {
private static final ThreadLocal tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
//获取session中的验证码
Object cacheCode = session.getAttribute(phone);
//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (code == null || !cacheCode.toString().equals(code)) {
//4. 不一致则报错
return Result.fail("验证码不一致!!");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
//7. 保存用户信息到session中
- session.setAttribute("user", user);
+ UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+ session.setAttribute("user", userDTO);
return Result.ok();
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取session
HttpSession session = request.getSession();
//2. 获取session中的用户信息
- User user = (User) session.getAttribute("user");
+ UserDTO user = (UserDTO) session.getAttribute("user");
//3. 判断用户是否存在
if (user == null) {
//4. 不存在,则拦截
response.setStatus(401);
return false;
}
//5. 存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
UserHolder.saveUser(user);
//6. 放行
return true;
}
{
"success":true,
"data":{
"id":1016,
"nickName":"user_zkhf7cfv",
"icon":""
}
}
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
所以我们后面都是基于Redis来完成,我们把session换成Redis,Redis数据本身就是共享的,就可以避免session共享的问题了
StringRedisTemplate
@Autowired
private StringRedisTemplate stringRedisTemplate;
这里的key使用用login:code:email
的形式,并设置有效期2分钟,我们也可以定义一个常量类来替换这里的login:code:
和2
,让代码显得更专业一点
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
// TODO 发送短信验证码并保存验证码
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确");
}
String code = MailUtils.achieveCode();
- session.setAttribute(phone, code);
- stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
+ stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
log.info("发送登录验证码:{}", code);
// MailUtils.sendTestMail(phone, code);
return Result.ok();
}
定义的常量类
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 36000L;
public static final Long CACHE_NULL_TTL = 2L;
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}
{% endtabs %}
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
- //获取session中的验证码
- Object cacheCode = session.getAttribute(phone);
+ //获取redis中的验证码
+ String sessionCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + userCode);
//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (code == null || !cacheCode.toString().equals(code)) {
//4. 不一致则报错
return Result.fail("验证码不一致!!");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
- //7. 保存用户信息到session中
+ //7. 保存用户信息到Redis中
+ //7.1 随机生成token,作为登录令牌
+ String token = UUID.randomUUID().toString();
+ //7.2 将UserDto对象转为HashMap存储
+ UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
+ HashMap userMap = new HashMap<>();
+ userMap.put("icon", userDTO.getIcon());
+ userMap.put("id", String.valueOf(userDTO.getId()));
+ userMap.put("nickName", userDTO.getNickName( ));
+ //7.3 存储
+ String tokenKey = LOGIN_USER_KEY + token;
+ stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
+ //7.4 设置token有效期为30分钟
+ stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
+ //7.5 登陆成功则删除验证码信息
+ stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
- session.setAttribute("user", userDTO);
+ //8. 返回token
+ return Result.ok(token);
}
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
// TODO 实现登录功能
//获取登录账号
String phone = loginForm.getPhone();
//获取登录验证码
String code = loginForm.getCode();
//获取redis中的验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//1. 校验邮箱
if (RegexUtils.isEmailInvalid(phone)) {
//2. 不符合格式则报错
return Result.fail("邮箱格式不正确!!");
}
//3. 校验验证码
log.info("code:{},cacheCode{}", code, cacheCode);
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
//5. 根据账号查询用户是否存在
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
//6. 如果不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
//7. 保存用户信息到session中
//7. 保存用户信息到Redis中
//7.1 随机生成token,作为登录令牌
String token = UUID.randomUUID().toString();
//7.2 将UserDto对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
HashMap<String, String > userMap = new HashMap<>();
userMap.put("icon", userDTO.getIcon());
userMap.put("id", String.valueOf(userDTO.getId()));
userMap.put("nickName", userDTO.getNickName());
//高端写法,现在我还学不来,工具类还不太了解,只能自己手动转换类型然后put了
// Map userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
// CopyOptions.create()
// .setIgnoreNullValue(true)
// .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
//7.3 存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//7.4 设置token有效期为30分钟
stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
//7.5 登陆成功则删除验证码信息
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
//8. 返回token
return Result.ok(token);
}
{% endtabs %}
authorization: 6867061d-a8d0-4e60-b92f-97f7d698a1ca
LoginInterceptor
类 @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求头中的token
String token = request.getHeader("authorization");
//2. 如果token是空,则未登录,拦截
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
//3. 基于token获取Redis中的用户数据
Map
RefreshTokenInterceptor
类,其业务逻辑与之前的LoginInterceptor
类似,就算遇到用户未登录,也继续放行,交给LoginInterceptor
处理public class RefreshTokenInterceptor implements HandlerInterceptor {
//这里并不是自动装配,因为RefreshTokenInterceptor是我们手动在WebConfig里new出来的
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求头中的token
String token = request.getHeader("authorization");
//2. 如果token是空,直接放行,交给LoginInterceptor处理
if (StrUtil.isBlank(token)) {
return true;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
//3. 基于token获取Redis中的用户数据
Map
LoginInterceptor
类,只需要判断用户是否存在,不存在,则拦截,存在则放行public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断用户是否存在
if (UserHolder.getUser()==null){
//不存在则拦截
response.setStatus(401);
return false;
}
//存在则放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
WebConfig
配置类,拦截器的执行顺序可以由order来指定,如果未设置拦截路径,则默认是拦截所有路径@Configuration
public class MvcConfig implements WebMvcConfigurer {
//到了这里才能自动装配
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);
//RefreshTokenInterceptor是我们手动new出来的
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}
避震器
,防止车体加速之后因惯性,在U
型地形上飞跃硬着陆导致损坏
,像个弹簧意义避震器
,防止过高的数据量猛冲系统,导致其操作线程无法及时处理信息而瘫痪缓存
(Cache)就是数据交换的缓冲区
,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地,例如Static final ConcurrentHashMap map = new ConcurrentHashMap<>();
static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build();
Static final Map<K,V> map = new HashMap();
{% endtabs %}
static
修饰,所以随着类的加载而加载到内存之中,作为本地缓存,由于其又被final
修饰,所以其引用之间的关系是固定的,不能改变,因此不用担心复制导致缓存失败避震器
系统是几乎撑不住的,所以企业会大量运用缓存技术缓存的作用
缓存的成本
浏览器缓存:
主要是存在于浏览器端的缓存应用层缓存:
可以分为toncat本地缓存,例如之前提到的map或者是使用Redis作为缓存数据库缓存:
在数据库中有一片空间是buffer pool,增改查数据都会先加载到mysql的缓存中CPU缓存:
当代计算机最大的问题就是CPU性能提升了,但是内存读写速度没有跟上,所以为了适应当下的情况,增加了CPU的L1,L2,L3级的缓存请求网址: http://localhost:8080/api/shop/10
请求方法: GET
不出意外是ShopController
里的业务逻辑,而且restFul风格的
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库肯定慢
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return Result.ok(shopService.getById(id));
}
业务逻辑我们写到Service中,需要在Service层创建这个queryById
方法,然后去ServiceImpl中实现
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
}
@Override
public Result queryById(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//否则去数据库中查
Shop shop = getById(id);
//查不到返回一个错误信息或者返回空都可以,根据自己的需求来
if (shop == null){
return Result.fail("店铺不存在!!");
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr);
//最终把查询到的商户信息返回给前端
return Result.ok(shop);
}
{% endtabs %}
业务逻辑依旧是写在Service中
@GetMapping("list")
public Result queryTypeList() {
return typeService.queryList();
}
public interface IShopTypeService extends IService<ShopType> {
Result queryList();
}
@Override
public Result queryList() {
//先从Redis中查,这里的常量值是固定前缀 + 店铺id
List shopTypes =
stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE_KEY, 0, -1);
//如果不为空(查询到了),则转为ShopType类型直接返回
if (!shopTypes.isEmpty()) {
List tmp = new ArrayList<>();
for (String types : shopTypes) {
ShopType shopType = JSONUtil.toBean(types, ShopType.class);
tmp.add(shopType);
}
return Result.ok(tmp);
}
//否则去数据库中查
List tmp = query().orderByAsc("sort").list();
if (tmp == null){
return Result.fail("店铺类型不存在!!");
}
//查到了转为json字符串,存入redis
for (ShopType shopType : tmp) {
String jsonStr = JSONUtil.toJsonStr(shopType);
shopTypes.add(jsonStr);
}
stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOP_TYPE_KEY,shopTypes);
//最终把查询到的商户分类信息返回给前端
return Result.ok(tmp);
}
{% endtabs %}
内存淘汰
:Redis自动进行,当Redis内存大道我们设定的max-memery
时,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)超时剔除
:当我们给Redis设置了过期时间TTL之后,Redis会将超时的数据进行删除,方便我们继续使用缓存主动更新
:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护, 利用Redis的内存淘汰机制, 当内存不足时自动淘汰部分数据。 下次查询时更新缓存。 |
给缓存数据添加TTL时间, 到期后自动删除缓存。 下次查询时更新缓存。 |
编写业务逻辑, 在修改数据库的同时, 更新缓存。 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
综上所述,在企业的实际应用中,还是方案一最可靠,但是方案一的调用者该如何处理呢?
如果采用方案一,假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大,所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来
对比删除缓存与更新缓存
更新缓存
:每次更新数据库都需要更新缓存,无效写操作较多删除缓存
:更新数据库时让缓存失效,再次查询时更新缓存如何保证缓存与数据库的操作同时成功/同时失败
单体系统:
将缓存与数据库操作放在同一个事务分布式系统:
利用TCC等分布式事务方案先操作缓存还是先操作数据库?我们来仔细分析一下这两种方式的线程安全问题
先删除缓存,在操作数据库
删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题
先操作数据库,再删除缓存
线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题
虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以我们最终采用后者先操作数据库,再删除缓存
的方案
核心思路如下
修改ShopController的queryById方法,写入缓存时设置一下TTL
@Override
public Result queryById(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//否则去数据库中查
Shop shop = getById(id);
//查不到返回一个错误信息或者返回空都可以,根据自己的需求来
if (shop == null){
return Result.fail("店铺不存在!!");
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return Result.ok(shop);
}
/**
* 更新商铺信息
*
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
shopService.updateById(shop);
return Result.ok();
}
业务逻辑我们依旧是放在Service层去写
/**
* 更新商铺信息
*
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
return shopService.update(shop);
}
新增一个方法,Impl里去实现
Result update(Shop shop);
@Override
public Result update(Shop shop) {
//首先先判一下空
if (shop.getId() == null){
return Result.fail("店铺id不能为空!!");
}
//先修改数据库
updateById(shop);
//再删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
return Result.ok();
}
{% endtabs %}
{
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"score": 37,
"name": "476茶餐厅",
"typeId": 1,
"id": 1
}
476茶餐厅
,然后我们再去Redis中刷新,发现新数据已经被缓存了缓存穿透
:缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库。
常见的结局方案有两种
缓存空对象
思路分析:当我们客户端访问不存在的数据时,会先请求redis,但是此时redis中也没有数据,就会直接访问数据库,但是数据库里也没有数据,那么这个数据就穿透了缓存,直击数据库。但是数据库能承载的并发不如redis这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库,简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个这个数据存在redis中去(这就是为啥说会有额外的内存消耗
),这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库。可能造成的短期不一致
是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据,不过这种情况很少见罢了
布隆过滤
思路分析:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去判断当前这个要查询的数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库里一定会存在这个数据,从数据库中查询到数据之后,再将其放到redis中。如果布隆过滤器判断这个数据不存在,则直接返回。这种思想的优点在于节约内存空间,但存在误判,误判的原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希冲突
@Override
public Result queryById(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//否则去数据库中查
Shop shop = getById(id);
//查不到返回一个错误信息或者返回空都可以,根据自己的需求来
if (shop == null){
return Result.fail("店铺不存在!!");
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return Result.ok(shop);
}
@Override
public Result queryById(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//如果查询到的是空字符串,则说明是我们缓存的空数据
if (shopjson != null) {
return Result.fail("店铺不存在!!");
}
//否则去数据库中查
Shop shop = getById(id);
//查不到,则将空字符串写入Redis
if (shop == null) {
//这里的常量值是2分钟
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!!");
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return Result.ok(shop);
}
{% note info no-icon %}
小结:
Sentinel
)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )缓存击穿也叫热点Key问题,就是一个被高并发访问
并且缓存重建业务较复杂
的key突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击
举个不太恰当的例子:一件秒杀中的商品的key突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿
常见的解决方案有两种
逻辑分析
:假设线程1在查询缓存之后未命中,本来应该去查询数据库,重建缓存数据,完成这些之后,其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时,又进来了线程2、3、4同时来访问当前方法,那么这些线程都不能从缓存中查询到数据,那么他们就会在同一时刻访问数据库,执行SQL语句查询,对数据库访问压力过大
解决方案一
:互斥锁
利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用tryLock方法+double check来解决这个问题
线程1在操作的时候,拿着锁把房门锁上了,那么线程2、3、4就不能都进来操作数据库,只有1操作完了,把房门打开了,此时缓存数据也重建好了,线程2、3、4直接从redis中就可以查询到数据。
解决方案二
:逻辑过期方案互斥锁方案
:由于保证了互斥性,所以数据一致,且实现简单,只是加了一把锁而已,也没有其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁的情况,就可能死锁,所以只能串行执行,性能会受到影响逻辑过期方案
:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构缓存数据,但是在重构数据完成之前,其他线程只能返回脏数据,且实现起来比较麻烦解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外的内存消耗 保证一致性 实现简单 |
线程需要等待,性能受影响 可能有死锁风险 |
逻辑过期 | 线程无需等待,性能较好 |
不保证一致性 有额外内存消耗 实现复杂 |
核心思路
:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,知道获取到锁为止,才能进行查询
如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿
操作锁的代码
核心思路就是利用redis的setnx方法来表示获取锁,如果redis没有这个key,则插入成功,返回1,如果已经存在这个key,则插入失败,返回0。在StringRedisTemplate中返回true/false,我们可以根据返回值来判断是否有线程成功获取到了锁
{% tabs 操作所的代码46513 %}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//避免返回值为null,我们这里使用了BooleanUtil工具类
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
{% endtabs %}
@Override
public Shop queryWithPassThrough(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if (shopjson != null) {
return null;
}
//否则去数据库中查
Shop shop = getById(id);
//查不到,则将空值写入Redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return shop;
}
@Override
- public Shop queryWithPassThrough(Long id) {
+ public Shop queryWithMutex(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if (shopjson != null) {
return null;
}
//否则去数据库中查
+ //从这里,用try/catch/finally包裹
+ //获取互斥锁
+ boolean flag = tryLock(LOCK_SHOP_KEY + id);
+ //判断是否获取成功
+ if (!flag) {
+ //失败,则休眠并重试
+ Thread.sleep(50);
+ return queryWithMutex(id);
+ }
Shop shop = getById(id);
//查不到,则将空值写入Redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
+ //try/catch/finally包裹到这里,然后把释放锁的操作放到finally里
+ //释放互斥锁
+ unlock(LOCK_SHOP_KEY + id);
//最终把查询到的商户信息返回给前端
return shop;
}
在上面的基础上,使用try/catch/finally包裹,因为不管前面是否会有异常,最终都必须释放锁
@Override
public Shop queryWithMutex(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if (shopJson != null) {
return null;
}
Shop shop = null;
try {
//否则去数据库中查
boolean flag = tryLock(LOCK_SHOP_KEY + id);
if (!flag) {
Thread.sleep(50);
return queryWithMutex(id);
}
//查不到,则将空值写入Redis
shop = getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(LOCK_SHOP_KEY + id);
}
return shop;
}
{% endtabs %}
queryById
方法 @Override
public Result queryById(Long id) {
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在!!");
}
return Result.ok(shop);
}
: ==> Preparing: SELECT id,name,type_id,images,area,address,x,y,avg_price,sold,comments,score,open_hours,create_time,update_time FROM tb_shop WHERE id=?
: ==> Parameters: 2(Long)
: <== Total: 1
步骤一
@Data
public class RedisData {
private LocalDateTime expireTime;
private T data;
}
步骤二
public void saveShop2Redis(Long id, Long expirSeconds) {
Shop shop = getById(id);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expirSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl shopService;
@Test
public void test(){
shopService.saveShop2Redis(1L,1000L);
}
}
{
"data": {
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"updateTime": 1666502007000,
"score": 37,
"createTime": 1640167839000,
"name": "476茶餐厅",
"x": 120.149192,
"y": 30.316078,
"typeId": 1,
"id": 1
},
"expireTime": 1666519036559
}
步骤三
:正式代码 //这里需要声明一个线程池,因为下面我们需要新建一个现成来完成重构缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Override
public Shop queryWithLogicalExpire(Long id) {
//1. 从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2. 如果未命中,则返回空
if (StrUtil.isBlank(json)) {
return null;
}
//3. 命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//3.1 将data转为Shop对象
JSONObject shopJson = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
//3.2 获取过期时间
LocalDateTime expireTime = redisData.getExpireTime();
//4. 判断是否过期
if (LocalDateTime.now().isBefore(time)) {
//5. 未过期,直接返回商铺信息
return shop;
}
//6. 过期,尝试获取互斥锁
boolean flag = tryLock(LOCK_SHOP_KEY + id);
//7. 获取到了锁
if (flag) {
//8. 开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, LOCK_SHOP_TTL);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(LOCK_SHOP_KEY + id);
}
});
//9. 直接返回商铺信息
return shop;
}
//10. 未获取到锁,直接返回商铺信息
return shop;
}
saveShop2Redis
方法,向redis中添加一个逻辑过期数据,设置过期时间为2秒,这样很快就过期了,saveShop2Redis
方法 %} ```JAVA
public void saveShop2Redis(Long id, Long expirSeconds) {
```JAVA
@Test
public void test(){
shopService.saveShop2Redis(2L,2L);
}
{% endtabs %}
public void set(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
}
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
//由于需要设置逻辑过期时间,所以我们需要用到RedisData
RedisData
@Override
public Shop queryWithPassThrough(Long id) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopjson != null) {
return null;
}
//否则去数据库中查
Shop shop = getById(id);
//查不到,则将空值写入Redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
//并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//最终把查询到的商户信息返回给前端
return shop;
}
Shop
了,那我们直接设置一个泛型,同时ID的类型,也不一定都是Long
类型,所以我们也采用泛型。public R queryWithPassThrough(String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit timeUnit) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//如果不为空(查询到了),则转为R类型直接返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null;
}
//否则去数据库中查,查询逻辑用我们参数中注入的函数
R r = dbFallback.apply(id);
//查不到,则将空值写入Redis
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(r);
//并存入redis,设置TTL
this.set(key, jsonStr, time, timeUnit);
//最终把查询到的商户信息返回给前端
return r;
}
public Result queryById(Long id) {
Shop shop = cacheClient.
queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
if (shop == null) {
return Result.fail("店铺不存在!!");
}
return Result.ok(shop);
}
{% endtabs %}
public R queryWithLogicalExpire(String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit timeUnit) {
//1. 从redis中查询商铺缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//2. 如果未命中,则返回空
if (StrUtil.isBlank(json)) {
return null;
}
//3. 命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//5. 未过期,直接返回商铺信息
return r;
}
//6. 过期,尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
//7. 获取到了锁
if (flag) {
//8. 开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R tmp = dbFallback.apply(id);
this.setWithLogicExpire(key, tmp, time, timeUnit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
//9. 直接返回商铺信息
return r;
}
//10. 未获取到锁,直接返回商铺信息
return r;
}
public R queryWithMutex(String keyPrefix, ID id, Class type, Function dbFallback, Long time, TimeUnit timeUnit) {
//先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
//如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null;
}
R r = null;
String lockKey = LOCK_SHOP_KEY + id;
try {
//否则去数据库中查
boolean flag = tryLock(lockKey);
if (!flag) {
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, timeUnit);
}
r = dbFallback.apply(id);
//查不到,则将空值写入Redis
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//并存入redis,设置TTL
this.set(key, r, time, timeUnit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return r;
}
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.RedisData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.*;
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
}
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
RedisData
tb_voucher_order
表中,而订单表如果使用数据库自增ID就会存在一些问题
全局ID生成器
了
public static void main(String[] args) {
//设置一下起始时间,时间戳就是起始时间与当前时间的秒数差
LocalDateTime tmp = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
System.out.println(tmp.toEpochSecond(ZoneOffset.UTC));
//结果为1640995200L
}
@Component
public class RedisIdWorker {
@Autowired
private StringRedisTemplate stringRedisTemplate;
//设置起始时间,我这里设定的是2022.01.01 00:00:00
public static final Long BEGIN_TIMESTAMP = 1640995200L;
//序列号长度
public static final Long COUNT_BIT = 32L;
public long nextId(String keyPrefix){
//1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long currentSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = currentSecond - BEGIN_TIMESTAMP;
//2. 生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("inc:"+keyPrefix+":"+date);
//3. 拼接并返回,简单位运算
return timeStamp << COUNT_BIT | count;
}
}
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
id | bigint unsigned | (NULL) | NO | PRI | (NULL) | auto_increment | 主键 |
shop_id | bigint unsigned | (NULL) | YES | (NULL) | 商铺id | ||
title | varchar(255) | utf8mb4_general_ci | NO | (NULL) | 代金券标题 | ||
sub_title | varchar(255) | utf8mb4_general_ci | YES | (NULL) | 副标题 | ||
rules | varchar(1024) | utf8mb4_general_ci | YES | (NULL) | 使用规则 | ||
pay_value | bigint unsigned | (NULL) | NO | (NULL) | 支付金额,单位是分。例如200代表2元 | ||
actual_value | bigint | (NULL) | NO | (NULL) | 抵扣金额,单位是分。例如200代表2元 | ||
type | tinyint unsigned | (NULL) | NO | 0 | 0,普通券;1,秒杀券 | ||
status | tinyint unsigned | (NULL) | NO | 1 | 1,上架; 2,下架; 3,过期 | ||
create_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 | |
update_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP | 更新时间 |
Field | Type | Collation | Null | Key | Default | Extra | Comment |
---|---|---|---|---|---|---|---|
voucher_id | bigint unsigned | (NULL) | NO | PRI | (NULL) | 关联的优惠券的id | |
stock | int | (NULL) | NO | (NULL) | 库存 | ||
create_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 创建时间 | |
begin_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 生效时间 | |
end_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED | 失效时间 | |
update_time | timestamp | (NULL) | NO | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP | 更新时间 |
新增普通券,也就只是将普通券的信息保存到表中
/**
* 新增普通券
* @param voucher 优惠券信息
* @return 优惠券id
*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
新增秒杀券主要看addSeckillVoucher
中的业务逻辑
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
// 关联普通券id
seckillVoucher.setVoucherId(voucher.getId());
// 设置库存
seckillVoucher.setStock(voucher.getStock());
// 设置开始时间
seckillVoucher.setBeginTime(voucher.getBeginTime());
// 设置结束时间
seckillVoucher.setEndTime(voucher.getEndTime());
// 保存信息到秒杀券表中
seckillVoucherService.save(seckillVoucher);
}
{% endtabs %}
http://localhost:8081/voucher/seckill
,请求方式POST,JSON数据如下{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五可用",
"rules":"全场通用\\n无需预约\\n可无限叠加",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2022-01-01T00:00:00",
"endTime":"2022-10-31T23:59:59"
}
限时抢购
,然后查看发送的请求请求网址: http://localhost:8080/api/voucher-order/seckill/13
请求方法: POST
VoucherOrderController
里的方法@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return Result.fail("功能未完成");
}
}
具体的业务逻辑我们还是放到Service层里写,在Service层创建seckillVoucher方法
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return seckillVoucherService.seckillVoucher(voucherId);
}
}
public interface IVoucherOrderService extends IService {
Result seckillVoucher(Long voucherId);
}
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.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");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
{% endtabs %}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足");
}
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
版本号
,完全可以使用stock
来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
+ .eq("stock",seckillVoucher.getStock())
.update();
if (!success) {
return Result.fail("库存不足");
}
//6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1 设置订单id
long orderId = redisIdWorker.nextId("order");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
- .eq("stock",seckillVoucher.getStock())
+ .gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
//6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//6.1 设置订单id
long orderId = redisIdWorker.nextId("order");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
+ // 一人一单逻辑
+ Long userId = UserHolder.getUser().getId();
+ int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
+ if (count > 0){
+ return Result.fail("你已经抢过优惠券了哦");
+ }
//5. 扣减库存
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");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
存在问题
:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题createVoucherOrder
方法中,然后给这个方法加锁 private Result createVoucherOrder(Long voucherId) {
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
//5. 扣减库存
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");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
一人一单
,所以这个锁,应该只加在单个用户上,用户标识可以用userId
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单逻辑
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
//5. 扣减库存
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");
//6.2 设置用户id
Long id = UserHolder.getUser().getId();
//6.3 设置代金券id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(id);
//7. 将订单数据保存到表中
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
//执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}
userId.toString()
拿到的也不是同一个用户,需要使用intern()
,如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。public static String toString(long i) {
if (i == Long.MIN_VALUE)
return "-9223372036854775808";
int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
char[] buf = new char[size];
getChars(i, size, buf);
return new String(buf, true);
}
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
}
AopContext.currentProxy()
来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService
中创建createVoucherOrder
方法Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
org.aspectj
aspectjweaver
@EnableAspectJAutoProxy(exposeProxy = true)
注解@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
具体操作,我们使用POSTMAN
发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。
失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥
这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)
分布式锁:满足分布式系统或集群模式下多线程课件并且可以互斥的锁
那么分布式锁应该满足一些什么条件呢?
常见的分布式锁有三种
SETNX
这个方法,如果插入Key成功,则表示获得到了锁,如果有人插入成功,那么其他人就回插入失败,无法获取到锁,利用这套逻辑完成互斥
,从而实现分布式锁MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
SET lock thread01 NX EX 10
DEL lock
SETNX
方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁持有的超时时间,过期自动释放
* @return true表示获取锁成功,false表示获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
public class SimpleRedisLock implements ILock {
//锁的前缀
private static final String KEY_PREFIX = "lock:";
//具体业务名称,将前缀和业务名拼接之后当做Key
private String name;
//这里不需要@Autowired,因为该对象是我们使用构造函数手动new出来的
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
long threadId = Thread.currentThread().getId();
//获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
//自动拆箱可能会出现null,这样写更稳妥
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//通过DEL来删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//1. 查询优惠券
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
//2. 判断秒杀时间是否开始
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
//3. 判断秒杀时间是否结束
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁对象
boolean isLock = redisLock.tryLock(120);
// 加锁失败,说明当前用户开了多个线程抢优惠券,但是由于key是SETNX的,所以不能创建key,得等key的TTL到期或释放锁(删除key)
if (!isLock) {
return Result.fail("不允许抢多张优惠券");
}
try {
// 获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
redisLock.unlock();
}
}