1.Redis基础知识
Sql与Nosql区别:
redis特性:
基本数据结构:
可视化客户端:redis-cli
对应的可视化工具:
通用命令:
keys*是查找所有的key,由于redis是单线程,当数据量过大时,可能会堵塞redis进程
对应的java操作客户端
spring对jedis和lettuce的集成
1.导入对应依赖,同时要导入redis的连接池(spring默认集成lettuce,如果要用jedis要导入对应依赖)
<!-- spring集成redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis连接池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
2.配置redis相关信息
spring:
redis:
port: 6379
host: localhost
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 100
3.基本使用,存值取值
@Autowired
private RedisTemplate redisTemplate;
@Test
public void test(){
//插入一个数据,底层有自动序列化机制 可以传对象
redisTemplate.opsForValue().set("tom",123);
//获取数据
Object name = redisTemplate.opsForValue().get("tom");
System.out.println(name);
}
查看redis界面,发现存入了一个很乱的key
这是由于redis不接收java对象,java对象需要进行序列化操作转为json数据才能存入redis中,默认采用的idk序列化方式,可读性差,改变其序列化方式即可
第一种,虽然方便,自动序列化,但是遇到对java象时,会存入对应的类的json,占用内存
因此我们使用第二种方式,当需要存入对象时,使用json转换工具手动转换即可
2.项目环境的搭建
1.项目功能
整体架构
2.导入sql
3.启动后端项目
后端部署在tomcat服务器上,端口为8081
我们访问8081下的任意接口,后端成功查询到数据库的数据
4.启动前端项目
前端项目部署在nginx服务器上的html目录下,即部署静态资源
我们直接启动nginx
访问前端8080端口,成功出现页面!
观察nginx.conf文件可知配置了反向代理的服务器端口为8081,即后端tomcat服务器地址,通过访问nginx的端口8080,在反向代理至tomcat服务器端口8081完成整个联动。
3.验证码短信登录注册验证
1.基本思路
2.接口实现(调用业务层)
实现验证码的发送,同时存入session
@Override
public Result senCode(String email, HttpSession session) {
//校验邮箱
boolean emailInvalid = RegexUtils.isEmailInvalid(email);
if (emailInvalid){
//不符合返回异常
return Result.fail("验证码有误");
}else {
//发送验证码
final String code = RandomUtil.randomNumbers(6);
//调用对应的邮箱服务
//发送人 //接收人
sendEmail.sendMail( "2041742155@qq.com",email, "黑马点评","短信验证码为"+code);
//将验证码存入session
session.setAttribute("code",code);
return Result.ok("验证码发送成功");
}
}
登录校验,用户注册,将user也存入session
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//获取用户的手机号和验证码
String email = loginForm.getPhone();
String usercode = loginForm.getCode();
//校验手机号
boolean emailInvalid = RegexUtils.isEmailInvalid(email);
if (!emailInvalid) {
//取出session中的验证码与用户输入的比对
String code = (String) session.getAttribute("code");
if (code.equals(usercode) && usercode != null) { //这里可以采用反向校验,避免代码if嵌套
//成功,查询数据库是否有此用户
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getPhone, email);
User getone = userMapper.selectOne(wrapper);
//存在,直接登录
if (getone != null) {
//将用户存入session
session.setAttribute("user",getone);
return Result.ok("登录成功");
} else {
User user = new User();
//否则,创建此用户,并登录
user.setCreateTime(LocalDateTime.now());
user.setPhone(email);
//通过工具类设置随机用户名
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
userMapper.insert(user);
//将用户存入session中进行登录状态判断
session.setAttribute("user",user);
return Result.ok("登录成功");
}
}
//失败,提示验证码输入错误
return Result.fail("验证码有误请重新输入");
} else {
return Result.fail("邮箱输入有误");
}
}
此时,还需要登录拦截器,用于拦截过滤每个接口,保证系统安全,此外由于需要将拦截器拦截的用户信息传递到控制器中,我们使用线程域对象ThreadLocal线程来避免多线程问题,保证每个线程只携带一个用户信息。
登录拦截器:
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//拦截请求携带着cookie,通过cookie来获取session来判断用户登录状态
//获取session中的用户
HttpSession session = request.getSession();
Object user = session.getAttribute("user");
//判断用户是否存在
if (user!=null){
//存在放行,并将用户信息存入当前线程中
UserHolder.saveUser((UserDTO) user);
return true;
}else {
//不存在跳转至登录界面
return false;
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
}
拦截器属于mvc需要进行注册,配置放行路径
@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/**"
);
}
}
这里为了避免存入session时返回用户数据的敏感信息,我们用UserDto来封装转换数据库查询到的user
功能测试:
先单独访问项目其他没有被过滤的接口,访问失败,报401,代表拦截器正常工作
我们进行验证码登录,我们先观察前端代码逻辑,我们点击登录按钮前端会跳转到info.html页面
在info.html页面中,钩子函数会直接调用此方法
而此方法,即是我们访问的user/me接口,用于展示用户登录信息,即最后跳转的页面
‘
点击登录成功跳转至预测接口下的页面
4.基于redis实现共享session登录
这里用session存储用户信息,在多台tomcat下容易发生数据丢失问题,因此采用redis来缓存数据
(key的设计:唯一性和方便携带数据)
之前采用session携带的cookie来取数据,这里就需要用redis中的key来取数据,验证码使用手机号形式的key来缓存value采用string,而用户登录后的信息使用token为key存储到redis中value使用hash结构存储,并通过前端携带的token来进行用户登录判断的凭证。
验证码发送优化
登录注册验证优化
拦截器优化
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//拦截请求携带着cookie,通过cookie来获取session来判断用户登录状态
//获取session中的用户
// HttpSession session = request.getSession();
// Object user = session.getAttribute("user");
//从前端取出token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
//token为null跳转报错401
response.setStatus(401);
}
//基于token获取redis中的user
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//判断用户是否存在
if (map.isEmpty()) {
//token为null跳转报错401
response.setStatus(401);
}
//将查询到的map转为UserDto
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//存入线程域中
UserHolder.saveUser(userDTO);
//每当用户调用控制器时,刷新token有效期防止token失效被拦截
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
测试:
登录成功,观察redis,存入了token以及封装后的UserDto信息
拦截器优化:
当用户一直访问没有被拦截的页面时,拦截器就不生效了,那也不会去刷新redis缓存的有效期,过了有效期,用户登录就失效了,因此需要用两个拦截器,一个用于存取user信息刷新有效期,一个来进行过滤拦截。
刷新缓存拦截器:
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 {
//拦截请求携带着cookie,通过cookie来获取session来判断用户登录状态
//获取session中的用户
// HttpSession session = request.getSession();
// Object user = session.getAttribute("user");
//从前端取出token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
//不做拦截功能放行至下一个拦截器进行拦截
return true;
}
//基于token获取redis中的user
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//判断用户是否存在
if (map.isEmpty()) {
//不做拦截功能放行至下一个拦截器进行拦截
return true;
}
//将查询到的map转为UserDto
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//存入线程域中
UserHolder.saveUser(userDTO);
//每当用户调用控制器时,刷新token有效期防止token失效被拦截
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//每个线程完成后(即代码跑完后端)移除用户session,新的线程会重新添加user
UserHolder.removeUser();
}
登录拦截器:
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 postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//每个线程完成后(即代码跑完后端)移除用户session,新的线程会重新添加user
UserHolder.removeUser();
}
5.商户查询缓存
1.介绍
2.商户页面和接口
商户有分类,每个分类有对应商户,以及每个商户的具体信息
对应控制器:
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return Result.ok(shopService.getById(id));
}
业务流程:
实现:
@Override
public Result queryById(Long id) {
//先从redis中查询是否有缓存数据 //定义key
String shopJson = stringRedisTemplate.opsForValue().get( CACHE_SHOP_KEY + id);
//判断拿到的数据是否为空
if (StrUtil.isNotBlank(shopJson)) {
//存在直接返回,将json转为java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//否则查询数据库返回
Shop shop = getById(id);
if (shop==null){
//为null返回异常
return Result.fail("店铺不存在");
}
//否则将数据存入缓存
stringRedisTemplate.opsForValue().set( CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
//返回
return Result.ok(shop);
}
测试:
第一次点击商铺,查询数据库
观察redis中存入了商铺缓存,随后再次点击商铺,没有查询数据库,而是查询的缓存
同时可以给分类列表也加上缓存
@Override
public Result queryTypeList() {
//查询缓存有无数据
String TypeJson = stringRedisTemplate.opsForValue().get("cache:ShopType");
if (StrUtil.isNotBlank(TypeJson)){
//将json转为java的list集合
List<ShopType> shopType = JSONUtil.toList(TypeJson, ShopType.class);
// 直接读取缓存
return Result.ok(shopType);
}
//查询数据库有无数据
List<ShopType> list = query().orderByAsc("sort").list();
if (list==null){
//无,报错
return Result.fail("未查到列表数据");
}
//返回数据库数据,并存入缓存
stringRedisTemplate.opsForValue().set("cache:ShopType", JSONUtil.toJsonStr(list));
return Result.ok(list);
}
6.缓存更新策略
为商铺添加缓存更新,读操作设置超时时间,写操作,添加事务,先更新数据库在删缓存
//缓存更新
@Override
@Transactional //为了保证删除缓存和更新数据库的行为一致需要加入事务控制原子性
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id==null){
return Result.fail("商铺id不为null");
}
//更新数据库
updateById(shop);
//删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
测试,当访问店铺后,此店铺已设置超时时间
先用postman发送put请求来更新数据103->102茶餐厅,结果修改成功
观察缓存进行了删除
再次查询商铺,改成了102商铺,同时新的缓存也进行了修改
7.缓存穿透雪崩击穿
1.缓存穿透
案例解决思路
代码:(对商铺信息)
@Override
public Result queryById(Long id) {
//先从redis中查询是否有缓存数据 //定义key
String shopJson = stringRedisTemplate.opsForValue().get( CACHE_SHOP_KEY + id);
//判断拿到的数据是否为空
if (StrUtil.isNotBlank(shopJson)) {
//存在直接返回,将json转为java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值(即数据库也没有此数据,redis写入了一个空值防止缓存击穿)
if (shopJson!=null){
return Result.fail("店铺不存在");
}
//否则查询数据库返回
Shop shop = getById(id);
if (shop==null){ //数据库没有查到,返回null
//将空值写入redis,并设置很短的有效期
stringRedisTemplate.opsForValue().set( CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
//为null返回异常
return Result.fail("店铺不存在");
}
//否则将数据存入缓存并设置超时时间
stringRedisTemplate.opsForValue().set( CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回
return Result.ok(shop);
}
测试:
查询存在的数据
查询不存在的数据,报错
观察对应的reids缓存,成功缓存了2种数据,包括null值的缓存
2.缓存雪崩
3.缓存击穿
热门key失效后,多个线程同时访问,一起重构数据库业务导致数据库崩溃
//获取锁,防止缓存击穿
private boolean tryLock(String key){
//如果锁中key有值则其他线程无法修改其key的值,代表拿到锁
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(aBoolean);
}
//删除锁对应的key
private void unLock(String key){
stringRedisTemplate.delete(key);
}
private Shop queryWithMutex(Long id){
//先从redis中查询是否有缓存数据 //定义key
String shopJson = stringRedisTemplate.opsForValue().get( CACHE_SHOP_KEY + id);
//判断拿到的数据是否为空
if (StrUtil.isNotBlank(shopJson)) {
//存在直接返回,将json转为java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中数据的是否是redis中的空值(即在第一次数据穿过了缓存和数据库后,即都没有数据,然后在redis中写入了一个值为空的缓存,
// 在下一次此数据经过时,由于已有缓存则会查询redis中生成的那个空值,即被命中了,随后直接进行return处理,来防止缓存击穿
if (shopJson!=null){
//此时被命中的缓存就是空值缓存,即第二次数据经过会被命中并return
return null;
}
//实现缓存重建
//获取互斥锁并判断
String LockKey ="lock:shop"+id;
Shop shop = null;
try {
boolean islock = tryLock(LockKey);
if (!islock){
//失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//成功,重建数据库,将数据写给redis
shop = getById(id);
if (shop==null){ //数据库没有查到,返回null
//将空值写入redis,并设置很短的有效期
stringRedisTemplate.opsForValue().set( CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
//为null返回异常
return null;
}
//否则将数据存入缓存并设置超时时间
stringRedisTemplate.opsForValue().set( CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//释放互斥锁
unLock(LockKey);
}
//返回
return shop;
}
测试:使用jmeter进行并发测试,1000个线程一起执行,吞吐量在200左右
观察控制台sql,也就执行了一次,代表其他进程没有拿到锁执行重构,走的是拿到锁的设置的redis缓存,互斥锁成功!
//缓存重建线程池
private static final ExecutorService cacheRebuild = Executors.newFixedThreadPool(10);
//使用逻辑过期来防止缓存击穿
private Shop queryWithLogicalExpire(Long id){
//先从redis中查询是否有缓存数据 //定义key
String shopJson = stringRedisTemplate.opsForValue().get( CACHE_SHOP_KEY + id);
//判断拿到的数据是否为空
if (StrUtil.isBlank(shopJson)) {
//为空直接返回,因为处理的是热点数据已经进行了预处理写入redis,如果在redis里没有找到则直接返回null
return null;
}
//命中需要先将json转为java对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject)redisData.getData();
LocalDateTime expireTime = redisData.getExpireTime();
Shop shop = JSONUtil.toBean(data, Shop.class);
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,返回店铺信息
return shop;
}
//已过期,缓存重建
//获取互斥锁
String LockKey =LOCK_SHOP_KEY+id;
boolean lock = tryLock(LockKey);
if (lock){
//这里为了防止多个线程一起重建缓存要先检查过期的逻辑时间是否进行了更新
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,返回店铺信息
return shop;
}
// 开启独立线程,重建缓存
cacheRebuild.submit(()->{
try {
this.saveShopRedis(id,20L);
} catch (Exception e) {
//出现异常,用全局异常处理器捕获
throw new RuntimeException(e);
}finally {
//释放锁,为了避免重建缓存业务异常而导致锁无法释放,因此需要进行异常捕获,并让其失败了也可以释放锁
unLock(LockKey);
}
});
}
//失败返回过期的数据信息
return shop;
}
8.用户秒杀下单
1.全局id生成器
@Component
public class RedisIDWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
//获取开始时间戳(2023.1.1)
private static final long BEGIN_TIEMSTAMP = 1672531200L;
//序列号位数
private static final int COUNT_Bit = 32;
//区别不同的业务
public Long nextId(String Keyprefix){
//生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond-BEGIN_TIEMSTAMP;
//生成序列号,自增长
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//这里拼接不同业务名,加当前日期来生成key,便于统计订单数量,防止key自增过多
Long count = stringRedisTemplate.opsForValue().increment("icr:" + Keyprefix + ":" + date);
//拼接并返回
//采用位运算,先将时间戳向左移动32位,在填充序列号
return timestamp << COUNT_Bit | count;
}
}
2.优惠券秒杀
先通过postman添加一个特价卷
观察页面成功出现秒杀卷
用户实现秒杀下单
业务逻辑
@Override
@Transactional
public Result seckillVouncher(Long voucherId) {
//获取优惠券信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
LocalDateTime beginTime = seckillVoucher.getBeginTime();
//判断是否在秒杀时间内
if (beginTime.isAfter(LocalDateTime.now())){
//秒杀未开始
return Result.fail("活动未开始");
}
if (beginTime.isBefore(LocalDateTime.now())){
//秒杀未开始
return Result.fail("活动以及结束");
}
//判断库存是否充足
if (seckillVoucher.getStock()<1){
return Result.fail("库存不足");
}
//扣减库存
UpdateWrapper<SeckillVoucher> wrapper = new UpdateWrapper<>();
wrapper.eq("voucher_id",voucherId);
wrapper.set("stock","stock-1");
boolean update = seckillVoucherService.update(wrapper);
if (!update){
return Result.fail("库存不足");
}
//生成订单
VoucherOrder voucherOrder = new VoucherOrder();
//设置优惠卷id,用户id,订单号id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
Long orderid = redisIDWorker.nextId("order");
voucherOrder.setId(orderid);
//存入数据库并返回
save(voucherOrder);
return Result.ok(orderid);
}
点击抢购,此时没有加锁,容易出现超卖因此需要加锁
乐观锁一种是通过version版本号法,一种是cas法
测试:jmeter,定义200个线程来抢购100个优惠卷
查看聚合报告和数据库,确实买完了,而且没有超卖
3.一人一单
为了限制每一个用户使用优惠卷的力度,进行一人一单限制
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIDWorker redisIDWorker;
@Autowired
private VoucherOrderServiceImpl voucherOrderServiceImpl;
@Override
public Result seckillVouncher(Long voucherId) {
//获取优惠券信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
LocalDateTime beginTime = seckillVoucher.getBeginTime();
LocalDateTime endTime = seckillVoucher.getEndTime();
//判断是否在秒杀时间内
if (beginTime.isAfter(LocalDateTime.now())){
//秒杀未开始
return Result.fail("活动尚未开始");
}
if (endTime.isBefore(LocalDateTime.now())){
//秒杀已经结束
return Result.fail("活动已经结束");
}
//判断库存是否充足
if (seckillVoucher.getStock()<1){
return Result.fail("库存不足");
}
//一人一单,由于并发问题需要给下单加悲观锁
Long userid = UserHolder.getUser().getId();
synchronized (userid.toString().intern()) {
//由于下面的新增操作进行了事务管理(生成代理对象),在调用此方法是要用其代理的事务对象来调用
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVouncherOrder(voucherId);
}
}
@Transactional
public Result createVouncherOrder(Long voucherId){
//一人一单
Long userid = UserHolder.getUser().getId();
int count = query().eq("user_id", userid).eq("voucher_id", voucherId).count();
if (count>0){
return Result.fail("用户已经购买过一次");
}
//扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock-1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!update){
return Result.fail("库存不足");
}
//生成订单
VoucherOrder voucherOrder = new VoucherOrder();
//设置优惠卷id,用户id,订单号id
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
Long orderid = redisIDWorker.nextId("order");
voucherOrder.setId(orderid);
//存入数据库并返回
save(voucherOrder);
return Result.ok(voucherOrder);
}
}
测试:只有一个用户下单,加锁成功
数据库中优惠卷也只卖了一个,生成一个订单
9.redis分布式锁
正常的锁只在每个jvm中生效,通过锁监视器来实现锁的分发,而在集群模式下,会有多个服务器多个jvm,那么每个jvm的锁都是独立的,需要统一管理。因此会出现并发问题
我们将所有线程用一个独立jvm机之外的锁监视器来管理,即分布式锁
基于redis实现分布式锁,让集群或分布式服务的所有进程都去redis中拿锁,从而保证数据一致性
实现:封装一个工具类
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 tyrLock(long timeout) {
//获取线程标识
long id = Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
//过期时间,防止redis崩溃导致死锁
.setIfAbsent(KEY_PREFIX + name, id + "", timeout, TimeUnit.SECONDS);
//自动拆箱可能发生空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
极端情况:当第一个线程获取锁后业务堵塞至锁的过期时间,锁被释放第二个线程拿到执行自己的业务,但此时第一个线程苏醒了业务完成了,就删除锁了,但是删除的是第二个线程拿到的锁,即没有通过判断锁是否是自己的就进行了删除
优化:
这里观察代码,可能出现线程安全问题,当第一个业务完成线程标识判断后,阻塞删除了key,此时第二个线程拿到key执行业务,在执行一半时,第一个线程好了,然后由于已经进行了线程标识的判断则第一个线程执行删除key操作,导致又有其他线程来获取key
解决:使用lua脚本来保证redis操作的原子性
对应的lua脚本
--锁的key,动态获取参数,默认为参数个数后面跟着的参数
local key = KEYS[1]
--当前线程标识
local threadId = ARGV[1]
--获取锁中的线程标识
local id = redis.call('get',key)
--比较线程标识与锁的标识是否一致
if(id == threadId) then
--一致就可以释放锁
return redis.call('del',key)
end
return 0
总结:
10.Redisson分布式框架
1.快速入门
导入对应依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置类
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redisClient(){
//配置类
Config config = new Config();
//添加redis地址(单点地址或者集群地址)
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
基本使用:
注入对应对象
调用api,实现锁的可重入
通过建立多个节点,每次获取锁要向每个节点都获取锁来保证锁一致性,并且每个节点可以建立主从关系来解决主从一致性问题
总结:
11.异步秒杀优化
业务改进:voucher接口
先新建一个优惠卷,将其库存加入缓存中
lua脚本编写:
--参数列表,用于判断库存是否充足
local voucherId = ARGV[1]
local userId = ARGV[2]
--数据key 拼接优惠卷id
local stockKey = 'seckill:stock:' ..voucherId
local orderKey = 'seckill:order:' ..voucherId
--业务
--判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <=0) then
--不足,返回1
return 1
end
--判断用户是否下单
if(redis.call('sismember',orderKey,userId)==1)
--存在重复下单,返回2
then return 2
end
--扣减库存,下单
redis.call('incrby',stockKey,-1)
--添加用户信息至redis
redis.call('sadd',orderKey,userId)
return 0
api调用:先初始化lua文件,在修改购物卷秒杀接口调用execute的api完成库存和用户是否已下单的判断
//提前加载lua文件
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
//初始化脚本
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVouncher(Long voucherId) {
//获取用户id
Long userId = UserHolder.getUser().getId();
//执行lua脚本判断库存和用户是否下单
Long result = stringRedisTemplate.execute( //传入脚本,key,参数
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
//判断结果是否为0
int value = result.intValue();
if (value != 0) {
//不为0,没有购买资格
return Result.fail(value == 1 ? "库存不足" : "用户已经下单");
}
//执行下单逻辑,把订单信息加入阻塞队列
//TODO 加入阻塞队列
Long order = redisIDWorker.nextId("order");
//返回订单id
return Result.ok(order);
}
;
测试:先购买一个优惠卷观察redis缓存库存减1,同时通过lua脚本生成了购买的订单id
再次购买由于redis缓存中有用户id,经过判断则无法购买
判断逻辑:先执行乱脚本返回2,
执行java逻辑代码将lua返回结果与0进行比较,默认0成功其他则是无法购买,确保了一人一单
加入阻塞队列中:使用线程池,并开启一个子线程来完成任务
//把生成订单的信息传入阻塞队列
private BlockingQueue<VoucherOrder> orders = new ArrayBlockingQueue<>(1024*1024);
@PostConstruct
private void init(){ //类一初始化就执行run方法
seckill_order.submit(new VoucherOrderHandler());
}
//开启线程池,异步下单
private static final ExecutorService seckill_order = Executors.newSingleThreadExecutor();
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
//获取队列中的订单信息
VoucherOrder voucherOrder = orders.take();
//创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("创建订单异常");
}
}
}
}
阻塞队列调用生成订单的方法
//创建订单方法
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userid = voucherOrder.getId();
//加锁,创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userid, stringRedisTemplate);
// RLock lock = redissonClient.getLock("lock:order:" + userid);
//获取锁
boolean isLock = lock.tryLock(1200);
if (!isLock) {
log.error("出错了");
}
try {
proxy.createVouncherOrder(voucherOrder);
} finally {
//释放锁
lock.unlock();
}
}
调用之前创建订单的方法来异步创建订单
@Transactional
public void createVouncherOrder(VoucherOrder voucherOrder){
//一人一单
Long userid = voucherOrder.getVoucherId();
int count = query().eq("user_id", userid).eq("voucher_id", voucherOrder).count();
if (count>0){
// return Result.fail("用户已经购买过一次");
}
//扣减库存
boolean update = seckillVoucherService.update().setSql("stock = stock-1")
.eq("voucher_id", voucherOrder).gt("stock", 0)
.update();
if (!update){
// return Result.fail("库存不足");
}
// //生成订单
// VoucherOrder voucherOrder = new VoucherOrder();
// //设置优惠卷id,用户id,订单号id
// voucherOrder.setVoucherId(voucherOrder);
// voucherOrder.setUserId(UserHolder.getUser().getId());
// Long orderid = redisIDWorker.nextId("order");
// voucherOrder.setId(orderid);
//存入数据库并返回
save(voucherOrder);
// return Result.ok(voucherOrder);
}
}
总结:
12.redis消息队列
总结:
13.达人探店
1.发布探店笔记
通过两个接口完成探店笔记的发布
2.笔记查询
对应接口,查询笔记包括对应达人的信息
//查询笔记
@Override
public Result queryById(Long id) {
//查询blog
Blog blog = getById(id);
if (blog==null){
return Result.fail("该笔记不存在");
}
//查对应的达人
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
//返回对应blog
return Result.ok(blog);
}
成功查询
3.笔记点赞
//将点赞加入缓存
@Override
public Result likeBlog(Long id) {
//获取登录用户
Long userid = UserHolder.getUser().getId();
//判断当前登录用户是否已经点赞
String key = "blog:liked"+id; //set集合中是否存在此key
Boolean member = stringRedisTemplate.opsForSet().isMember(key, userid.toString());
if (BooleanUtil.isFalse(member)){
//未点赞
//点赞数加1
boolean isSuccess = update().setSql("liked = liked+1 ").eq("id", id).update();
if (isSuccess){
//将用户存入redis的set集合中
stringRedisTemplate.opsForSet().add(key,userid.toString());
}
}else {
//取消点赞
//点赞数减1
boolean isSuccess = update().setSql("liked = liked-1 ").eq("id", id).update();
if (isSuccess){
//将用户从set集合移除
stringRedisTemplate.opsForSet().remove(key,userid.toString());
}
}
return Result.ok();
}
4.点赞排行榜
由于set集合不能进行排序,使用Zset来实现点赞排行,我们修改点赞缓存,存入时间戳,通过获取分数来排序
成功获取点赞的用户和时间戳
完成排序接口
@Override
public Result queryBlogLikes(Long id) {
String key= BLOG_LIKED_KEY + id;
//查询前五点赞用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5==null ||top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
//解析对应id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
//查询对应用户,转为userdto防止用户信息泄露
List<UserDTO> userDTOs = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOs);
}
测试:成功出现点赞用户
14.好友关注
1.关注和取关
接口实现:
//关注取关功能,多对多使用中间表来记录两张关系
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//获取登录用户
Long id = UserHolder.getUser().getId();
//判断isfollow
if (isFollow){
//关注,新增数据
Follow follow = new Follow();
follow.setUserId(id);
follow.setFollowUserId(followUserId);
save(follow);
}else {
//取关删除数据
LambdaQueryWrapper<Follow> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Follow::getFollowUserId,followUserId);
wrapper.eq(Follow::getUserId,id);
remove(wrapper);
}
return Result.ok();
}
//判断用户是否关注
@Override
public Result isfollow(Long followUserId) {
//获取登录用户
Long id = UserHolder.getUser().getId();
//查询数据库是否有此数据
LambdaQueryWrapper<Follow> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Follow::getFollowUserId,followUserId);
wrapper.eq(Follow::getId,id);
int count = count(wrapper);
return Result.ok(count>0);
}
测试:点击关注,查看页面和数据库成功关注,同时进行了是否关注的判断
2.共同关注
实现思路:首先将用户关注的信息加入到redis的set集合,当点击共同关注时则查询redis中的数据
关注一位博主,观察redis存入了此博主id
完成判断共同关注的接口,通过set的api实现判断最终返回封装的userDTO
@Override
public Result followCommons(Long id) {
//获取当前用户
Long userid = UserHolder.getUser().getId();
//登录用户key
String key1 = "follows:"+userid;
//目标用户key
String key2 = "follows"+id;
//求交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (intersect==null || intersect.isEmpty()){
//为空返回一个空集合
return Result.ok(Collections.emptyList());
}
//解析id集合
List<Long> list = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//查询用户,返回共同关注的用户信息并将user转为UserDto
List<UserDTO> users = userService.listByIds(list)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
3.关注推送
目前先学这么多,后续有时间在更新