黑马点评
导入数据
按照视频介绍导入需要的后端代码以及数据库
更改数据库和redis的地址
一、基于Session实现登录
1.1 发送短信验证码
请求为:post
~~~
url:/user/code
~~~
参数:phone
在usercontroller中生成方法
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
业务代码
1.校验手机号
2.如果不符合,返回错误信息
3.符合,生成验证码
4.保存验证码到session
5.发送验证码
6.返回ok
@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
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5.发送验证码(模拟发送验证码)
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
1.2 短信验证码登录
请求为:post ~~~ url:/user/login ~~~ 参数为json形式:code和phone
封装登陆信息
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
在usercontroller中生成方法
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
// 实现登录功能
return userService.login(loginForm, session);
}
因为参数为json数据,所以要加 @RequstBody
业务代码
1.校验手机号
2.校验验证码
3.不一致,报错
4.一致,根据手机号查用户
5.判断用户是否存在
6.不存在,创建新用户并保存
7.保存用户信息到session中
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到 session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
return Result.ok();
}
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
- 将User的属性拷贝到UserDTO中,优点是不用新建对象
1.3检验登陆状态
拦截器实现
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中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user == null) {
//4.不存在,拦截
response.setStatus(401);
return false;
}
//5.存在,保存用户信息在ThreadLocal
UserHolder.saveUser((UserDTO) user);
//6.放行
return true;
}
//后置拦截
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户
UserHolder.removeUser();
}
}
配置拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/voucher/**",//优惠券查询
"/shop/**",//商店
"/shop-type/**",//商店类型
"/upload/**",//更新
"/blog/hot"//博客热点
);
}
}
1.4集群session共享问题
Session共享问题: 多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题
核心思路分析
- 早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
- 每台服务器中都有完整的一份session数据,服务器压力过大。
- ression拷贝数据时,可能会出现延迟
所以后来采用的方案都是基于redis来完成,把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
session的替代方案应该满足:数据共享、内存存储、key、value结构。 即:Redis
基于Redis实现共享session登录
业务流程
不能使用手机号做key,容易泄露,我们用token
数据类型选择
- 保存登录的用户信息,可以使用String结构,以JSON字符串来保存,比较直观
- Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD
这里我们选择hash
发送短信业务代码
@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(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
登录校验
@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(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
登录状态续期
-
需求:用户session的过期时间不是固定的,如果期间内用户有访问系统,就应该给过期时间续期
-
由于自定义拦截器中没有注入容器,所以无法自动注入redis操作类,只能手动注入
-
可以通过构造方法注入,由于MvcConfig是配置类,由IoC容器管理,所以可以自动注入StringRedisTemplate对象,并将该对象通过构造器注入到自定义拦截器中
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
//判空
if (StrUtil.isBlank(token)) {
//不存在,拦截,返回401
response.setStatus(401);
return false;
}
//2.基于TOKEN获取redis中的用户
String key =RedisConstants.LOGIN_USER_KEY+token;
Map<Object, Object> UserMap = stringRedisTemplate.opsForHash().entries(key);
//3.判断用户是否存在
if(UserMap.isEmpty()) {
//4.不存在,拦截
response.setStatus(401);
return false;
}
//5.将hash转为user对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(UserMap, new UserDTO(), false);
//6.存在,保存用户信息在ThreadLocal
UserHolder.saveUser(userDTO);
//7.刷新token有效期
stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//8.放行
return true;
}
登录报错数据类型转换错误
- 原因:使用StringRedisTemplate要求存储的数据类型都为String,因此要确保hash中的每一个值都为String类型
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)//设置忽略null值
.setFieldValueEditor
((fieldName,fieldValue) //接收字段名和字段值
->fieldValue.toString()));//将字段值转成string类型
登录拦截器优化
token刷新拦截器
public class RefreshTokenInterceptor implements HandlerInterceptor {
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");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
修改登录拦截器
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(Thread中是否有用户)
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();
}
}
将拦截器连接
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",//商店
"/voucher/**",//优惠券
"/shop-type/**",//商店类型
"/upload/**",//更新
"/blog/hot",//博客
"/user/code",
"/user/login"
).order(1);
//token刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
二、商户查询缓存
2.1什么是缓存
缓存就是数据交换的缓冲区(称作cache),是存贮数据的临时地方,一般读写性能较高。
添加Redis缓存
查询商户代码实现
1.从redis查询商铺缓存
2.判断是否存在
3.存在,直接返回
4.不存在,查询数据库
5.不存在,返回错误
6.存在,写入redis
7.返回
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 7.返回
return Result.ok(shop);
}
2.2店铺类型查询业务添加缓存
ShopTypeController中
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
/**
* 查询商户类型
* @return
*/
@GetMapping("list")
public Result queryTypeList() {
//List<ShopType> typeList = typeService
// .query().orderByAsc("sort").list();
return typeService.queryShopType();
}
}
业务代码如上
1.从redis查询商铺缓存
2.判断是否存在
3.存在,直接返回
4.不存在,查询数据库
5.不存在,返回错误
6.存在,写入redis
7.返回
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 查询商户类型
* @return
*/
@Override
public Result queryShopType() {
String key = RedisConstants.CACHE_SHOPTYPE_KEY;
//1.从redis中查询商户缓存
String shopTypeJson = stringRedisTemplate.opsForValue().get(key);
//2.判断存在
if(StrUtil.isNotBlank(shopTypeJson)){
//3.存在,返回
List<ShopType> shopTypes = JSONUtil.toList(shopTypeJson, ShopType.class);
return Result.ok(shopTypes);
}
//4.不存在查数据库
List<ShopType> shopTypes = query().orderByAsc("sort").list();
//5.不存在,返回错误
if(shopTypes==null){
return Result.fail("类型为空");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopTypes));
// 7.返回
return Result.ok(shopTypes);
}
}
2.3缓存更新策略
2.4主动更新策略
第一种最常用
2.5商家缓存
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方法有两种:
-
缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗
~~~~~~~~~ 可能造成短期的不一致
-
布隆过滤
- 优点:内存占用少,没有多余的key
- 缺点: 实现复杂
~~~~~~~~~~~ 存在误判可能
业务流程
修改代码
//判断是否是空值
if(shopJson != null){
//返回错误
return Result.fail("店铺信息不存在");
}
//4.不存在,查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在");
}
缓存雪崩
缓存击穿
逻辑过期设置的一般不是TTL,设置缓存基本上是一直有效到活动结束后,才移除缓存中数据
之所以会逻辑过期,不是因为有效时间,而是因为数据更新了,缓存也需要更新数据,这时逻辑过期。
基于互斥锁方式解决缓存击穿问题
定义锁
//创建锁
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1", LOCK_SHOP_TTL,TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
缓存穿透
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断是否是空值
if(shopJson != null){
//返回错误
return null;
}
//4.不存在,查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return shop;
}
基于互斥锁方式解决缓存击穿问题
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断是否是空值
if(shopJson != null){
//返回错误
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockKey = LOCK_SHOP_KEY+id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2判断是否成功
if(!isLock){
//4.3失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功,根据id查询数据库
shop = getById(id);
//模拟延时
Thread.sleep(200);
//5.不存在,返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7.释放互斥锁
unLock(lockKey);
}
//8.返回
return shop;
}
public Result queryById(Long id) {
//缓存穿透
// Shop shop = queryWithPassThrough(id);
//互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
// 7.返回
return Result.ok(shop);
}
基于逻辑过期方式解决缓存击穿问题
代码忽略
2.6缓存工具封装
- 方法一和方法三应对普通缓存
- 方法二和方法四应对热点key解决击穿问题
三、优惠券秒杀
3.1全局ID生成器
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
、
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成ID
* @param keyPrefix
* @return
*/
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;//时间戳左移32位,然后或运算拼接count
}
}
生成全局唯一ID策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
其中Redis自增策略:
- 每天一个key,方便统计订单量
- ID构成时间戳+计数器
3.2实现优惠券秒杀下单
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
业务实现
/**
* 优惠券抢购
* @param voucherId
* @return
*/
@Override
@Transactional
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);
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
3.3超卖问题
即多线程并发安全问题,常见解决方案就是加锁:
乐观锁
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
- 版本号法
- CAS法
更新数据用乐观锁,我们采用CAS法来:
//5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId).gt("stock",0) //stock>0
.update();
if(!success)
{
return Result.fail("库存不足");
}
3.4一人一单
插入数据要用悲观锁
反复观看视频,知识点很多
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。