记录一些敲代码过程遇到的问题,和开始学的时候不熟悉的点
复盘每个部分的思路
前端配置nginx一开始有问题。redis要开,数据库表要建好
!sendCode方法在userController没写userService.sendCode(),导致一直重复调用。。。
低级错误不要再犯了。。
短信登录
1. 发送验证码
用RegexUtil判断手机号码是否合法,如果合法调用RandomUtil生成验证码,存入session,发送后返回结果。
2.登录
判断手机号是否有效,判断验证码对不对。如果对了则在数据库中找这个用户--如果有用户则直接保存到session,没有则创建后再保存。
(判断验证码用session内的和LoginForm比较)
3.登录验证
拦截器验证,LoginInterceptor继承HandleInterceptor,实现前置拦截和渲染后拦截。
·前置:获取session、从session中拿出用户、比对用户判断是否存在、不存在则拦截 存在则保存到ThreadLocal。
·后:removeUser()
·配置拦截器:创建mvcConfig,用拦截器注册器registry调用addInterceptor
Tomcat一个请求对应一个现成,ThreadLocal线程本地变量可以共享数据,同一个线程各个环节、不同线程直接不会造成影响。
@Configuration 声明一个类是配置类,Spring扫描@Bean方法,把返回值放入Ioc容器方便自动注入。保证了Spring Bean的单例模式,A调用B两个都有@bean确保B只被调用一次。如果是@Component,A调用B就是普通调用,会创建很多B对象
@Configurable是把依赖注入到非spring管理对象,比较少用
前端页面大小自己调了之后协议点击的地方也不在眼睛看到的按钮位置了
问题1: session一开始存的实体,拦截器后面调用UserHolder的UserDTO,类型不对会抛出异常
--> 用BeanUtils.copyProperties转成DTO
问题2:原始代码的return写了代码之后没改,要return Result。注意getAttribute和setAttribute
4. 用户加密
由于是直接从前端拿到user,可能导致信息全部被返回泄漏。将user copy到userDTO可以防止这个问题。
5.改进引入redis
由于用session,如果一个用户登录后发多次请求,可能会负载均衡到其他tomcat,先前保存的信息其他tomcat看不到。登录涉及读写,需要操作快一点,session都存在tomcat内存,可能造成空间浪费。----需要数据共享,内存存储结构合理,是key-value结构的。
引入redis,集群在tomcat之外,可以存储信息。生成token,从redis返回到客户端
可以用string-json结构,但是value还要存json格式等,存储密度没hash大,所以选择用hash结构存储。
6. 修改:
· 发送验证码send Code:code存到redis
· 登录时Login:从redis拿到code校验,如果校验成功,保存用户信息、生成用户token(可以用UUID)转换结构为hash存入redis(token设置有效期)、返回客户端
·更新用户信息时间,如果用户一直在活动就一直更新过期时间--拦截器判断:
拦截拿token比对,如果用户不存在则return。因为拦截的对象是map,用entries拿到所有信息注入userMap,将userMap转为userDTO(还是用BeanUtil,fillWithMap),调用UserHolder存入ThreadLocal。最后刷新过期时间expire()
StringRedisTemplate的Map必须是<String ,String>
1. 用setFieldValueEditor:
BeanToMap(userDTO, new HashMap<>(),Copyoptions.create().setIngnoreNullValue(true).setFieldValueEditor((FieldName,fieldValue)->fieldValue.toString());2. 改为userMap.put手动处理三个字段。
7. 拦截器优化:如果有些页面不用登录,也就不用更新token时间。
新加一个拦截器拦截所有--》获取token,去redis比对用户信息,保存到ThreadLocal,刷新token
原来的拦截器--》从ThreadLocal拿用户信息,存在用户则拦截,不存在则代表不需要拦截。
拦截器逻辑别写错了,refresh拦截器是如果userMap isEmpty则return true,没有信息就直接放行。
商户查询缓存
1. 查询商户:
从redis查询商铺缓存--> 存在直接返回
--> 不存在 查询数据库并写入redis
1. redis缓存一般是json存在String结构中。java对象只存在于JVM,Redis无法判别,所以最后要存入Redis要转成Json形式。调用JsonUtil
2.toBean(shopJSon,Shop.class) 决定了返回值类型,将Json属性填进对象
2. 缓存更新
·更新一般有三种方法(一致性低->高):内存淘汰(redis内存淘汰机制自动淘汰部分)
超时剔除(设TTL)、主动更新(改数据库时同时更新缓存)
一致性就是容不容易有变化,低一致性即一般不会变
·主动更新选Cache Aside Pattern
-->选择删除缓存。更新数据库时删缓存,查询再更新。更新缓存每次更新数据库都会更新,无效写操作 多。
-->保证缓存和数据库操作同时成功或者失败:单体系统(缓存和数据库操作放一个事务)
分布式系统(TCC等)
-->线程安全:先操作数据库再缓存或者 先缓存再数据库 都可能会脏读,但是先数据库的概率小
给shop加TTL
操作先数据库再缓存: 方法写在service中,用@transactional
3. 缓存穿透
请求的数据缓存和数据库都不存在,请求一直打到数据库
解决:缓存空对象(把不存在的数据在redis写空只)/布隆过滤
布隆是一种算法,原理是哈希函数算key,比对每一位的01
· 改代码的思路:之前查数据库和缓存都没有直接404,现在将空值写入redis;同时redis命中也要判断是否为空 。
isNotBlank只有在里面是字符串的时候才为true
// queryById // 某个搜索信息不存在,key是信息,value为空 stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);如果get到null代表redis中没有这个key,会访问数据库,不能解决缓存穿透。“”表示有这个key但是没数据。isNotBlank判断为false,于是判断(shopJson != null),但是不是null而是“”,所以直接返回错误信息。
4. 缓存雪崩
同一时间大量key失效或者redis宕机
·解决:key TTL加随机数,利用redis集群提高可用性(哨兵),给缓存业务加降级限流策略,多级缓存
缓存击穿:被高并发访问且缓存重建业务较复杂的key失效
·解决:互斥锁(拿不到锁 失眠)一致性
逻辑过期(不加ttl,value加字段,开个新线程重建缓存,返回过期的数据,其他的也返回过期数据)可用性
·代码思路:缓存未命中-->拿到锁-->查数据库,未拿到锁-->等待
添加锁用setnx(string,用opsForValue),释放锁delete
//这里的Boolean是boolean的包装类,直接返回可能会拆箱 // 用BoolenaUtil private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unLock(String key) { stringRedisTemplate.delete(key); }热点key加商铺缓存,如果未命中说明不是热点key 返回空。再判断缓存是否过期,过期了尝试获取锁,获取失败返回旧数据,成功开新线程
创建redisdata为redis存储对象,包含expireTime和Object data(不写死类型,后续其他问题也可以用)
测试别忘了@Test
redis有&没过期--》返回正确信息
redis有&过期--〉返回旧信息,异步更新
redis没有---》查询数据库,存入redis,返回数据
5. 封装缓存工具
两个set( java对象存入key,设置TTL/ 逻辑过期解决缓存击穿)
两个查(根据key查询并反序列化为对象,设置控制解决穿透 / 逻辑过期解决击穿)
难点主要是包装为泛型
引入ID id,ID只是泛型。函数用dbFallback,缓存失效时查询数据库
public <R> R queryWithPassThrough( String keyPrefix, ID id, Class<R> type, Function< ID, R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; //1. 从redis查询缓存 String json = stringRedisTemplate.opsForValue().get(key); //2. 查询缓存内有没有 if (StrUtil.isNotBlank(json)) { //3.存在直接返回 return JSONUtil.toBean(json, type); } //4. 不存在,根据id查数据库 // 解决缓存穿透,先判断是不是null,如果不是null报错 if (json != null) { return null; } R r = dbFallback.apply(id); if (r == null) { // 查到数据库还是空,解决缓存穿透,写空值 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //5. 数据写入缓存 this.set(key, r, time, unit); // 返回 return r; }Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY,id,Shop.class.this::getById,2L,TimeUnit.MINUTES);优惠券秒杀
1. 全局生成器
订单主键不能自增,防止规律性;受单表数据量限制若多表容易出现重复
全局ID生成器高可用,唯一性,高性能,递增性。拼接信息
· 实现:时间戳+序列号
--时间戳now.toEpochSecond - 开始时间戳
--序列号key自增长,redis key yyyy:MM:dd分层,每天创建一个key方便统计。key自增长不会涉及订单编号泄露
--用左移和或运算合并两串long数字
2. 秒杀下单
提交优惠券id--查询信息--判读时间
--开始&有库存-扣库存创建订单
--没有开始或者库存不足--结束
3.多线程超卖
解决方法就是加锁:
·悲观锁--上来就加锁,认为安全问题一定会发生
·乐观锁--安全问题不一定发生,只有更新数据时去查看其他线程有没有更改,没有修改则安全,修改了则重试或者异常
乐观锁两种方式:
·版本号法--更改了数据version+1,要修改时将前面请求的version和现version比对,不一致说明被更改了
·CAS法(compare and set)--用数据(比如库存)自身对比,查询库存时把正确的情况(stock数量)写入判断条件,如果和当前数据不匹配则 不执行
用CAS,扣库存之前判断库存是不是和查询到的数据一样-->没安全问题但失败率高!并发查询时多个线程查到的数据是一样的,扣库存之后数据改变导致无法正常售卖
--> 数据是否相同 改成判断数据是否大于0
当多个线程要对数据库id = voucherId修改的时候,mysql会对这一行加行锁。其他线程排队拿行锁再依次判断
用jmeter测试的时候超卖。。要登录获取token之后再测试,注意关注数据库等数据,可能库存前面测试扣完了
4. 一人一单
在秒杀业务加判断:如果库存充足,用用户和券id查询有无订单,存在订单则异常。
如果并发多个线程都查询到没有订单信息,就会多次下单,不能实现一人一单
· 解决:用悲观锁(查询--判断--新增)不用乐观锁,乐观锁是更改数据之前再查询
如果对方法加synchronized可能影响性能,所以可以对用户加锁
·对用户加锁:
将一人一单--扣库存--创建订单封装为新方法createVoucherOrder,如果对这个方法内部synchronized,由于加了@Transactional,先加锁,然后保存订单释放锁之后才完成整个事务。在释放锁之后可能会有其他线程拿到锁,如果事务还没提交新数据就会造成重复下单。
所以可以在create方法外加synchronized
先获取userId,由于toString底层代码是new字符串对象,多次调用也会创建多个对象,所以调用intern从常量池找值(userId)一样的字符串地址。
由于return其实是this.xxxx,是此对象。事务是由spring管理用的是代理对象,所以获取代理对象调用create方法。可以实现事务就可以实现在synchronized内提交事务,即提交之后才释放;同理获取锁之后才开启事务。
在启动文件加@EnableAspectAutoProxy(exposeProxy = true)暴露代理对象
Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()){ // 获取代理对象,获取锁之后才创建事务,事务提交之后才释放锁 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); }前面只针对单机情况
集群情况下可能有多个虚拟机,每个jvm内有锁监视器,所以可能导致无法监控所有线程,多个线程同时拿到锁引发安全问题。
分布式锁
原理:将锁监视器安在多个jvm外统一控制
获取锁:获取nil--获取失败;获取成功--执行--释放。
要实现互斥(只有一个线程)和非阻塞(只尝试一次)
@Override public boolean tryLock(long timeoutSec) { // 获取线程id long threadId = Thread.currentThread().getId(); // redis SET lock thread1 NX EX 10,所以用ifabsent // 获取锁 Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name, threadId+"", timeoutSec, TimeUnit.SECONDS) return Boolean.TRUE.equals(success); }函数是boolean(只有t,f),返回是Boolean(有t,f,null),涉及自动拆箱,如果redis有异常setIfAbsent返回null可能会有安全问题-->
返回Boolean.TRUE.equals(success),如果success是true则返回TRUE,如果是null返回false
释放锁:手动释放或者超时释放(加个超时时间)
从redis删除
极端情况下还是有可能发生线程安全问题,所以要加判断锁的标识和当前线程是否一致,不能删其他线程的锁
-->在获取锁时存入线程标识,释放锁时检查线程标识
即使有线程标识, 超时释放也会导致释放其他线程的锁, 用Lua脚本保证原子性
if(redis.call('get', KEYS[1]) == ARGV[1])then return redis.call('del', KEYS[1]) end retrun 0初始化加载脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); }分布式锁基于setnx实现,还是存在问题:
不可重入:同一个线程不那么多次获取同一个锁
不可重试
超时释放:业务执行耗时长的话也可能引起问题
主从一致性:如果主没和从同步就宕机了,会引发安全问题
--》引入Redisson
配置redisson,写config将redissonClient注入bean
修改业务代码,用redisson获取锁,tryLock可以传入参数,现在要求释放不等待所以不传参(waittime默认-1即不等待),
//SimpleRedisLock lock = new SimpleRedisLock("order:"+ userId, stringRedisTemplate); RLock lock = redissonClient.getLock("lock:order:" + userId); // 获取锁 boolean isLock = lock.tryLock(); if(!isLock){ // 获取锁失败,避免一人多单 return Result.fail("不允许重复下单!"); } try { // 获取代理对象,获取锁之后才创建事务,事务提交之后才释放锁 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } finally { lock.unlock(); }可重入锁
key:lock;value用哈希结构,存线程标识和重入次数(获取+1释放-1,到方法最外面应该value为0,如果为0则业务完成)。
之前的string类型用setnx和ex,分逻辑处理:
锁存在-->(存在) 判断标识 --> 设置有效期--> 锁是否是自己的(value不为0重置有效期,为0释放)
为了保证原子性还是用Lua
整个流程:
redisson分布式锁原理:
可重入:用了hash结构记录重入次数和线程标识
可重试:redisson内的pubsub机制实现了等待,获取锁失败的重试机制 。释放锁会发送消息,被捕捉后可重获取锁 (失败--等待--重试。。)
超时续约:获取锁成功后开启定时任务,看门狗机制隔一段时间重置超时时间
Redisson multiLock:
多个独立redis节点,必须所有节点都获取重入锁才算获取锁成功
秒杀优化
收到请求后发给tomcat,从查询到创建订单全部处理了,为了提高效率,可以改为多线程异步。
将秒杀库存和一人一单的信息存在redis,如果符合创建资格则在另一个线程创建。
· 一人一单:库存充足要判断用户是否下过单,查找优惠券库存扣减(只是扣redis数据),可以改用set集合,将用户if存入优惠券集合,查询时直接查找用户id。
lua判断库存是否充足、用户是否下过单,在执行代码中引入lua脚本
// 加载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); }·异步下单的思路:
· 用lua脚本判断用户有没有购买资格,如果有就创建订单传入阻塞队列
· 创建线程池,线程池从阻塞队列拿信息并创建订单
为什么还要创建订单:lua只是减库存并把userId放到redis里面的set中,没有创建数据库记录。线程池拿到信息(各种id)然后构建为voucherOrder对象写入数据库,而且阻塞队列中的信息只是task,只存在于jvm,写入数据库才变成记录。lua只管和redis快速响应,如果lua写数据效率会十分慢
其中创建订单的流程:还是创建锁,判断一人一单,减库存
消息队列
用jdk的阻塞队列可能会有内存限制,或者宕机导致数据丢失,所以引入消息队列
redis也可以实现消息队列:list,pubSub,stream
1. list:
redis的list是双向链表,可以用lpush rpop或者lpop rpush来模拟,但是此时如果是空会返回null
如果要实现阻塞效果可以用brpop和blpop
优点:基于redis存储不依赖jvm内存,redis持久化数据安全性有保障,满足消息有序性
缺点:pop之后没处理可能导致消息丢失,只支持单消费者
2. pubsub
发布订阅模式,可以多消费者生产者
如果没人订阅会丢失消息、消息堆积有限制
3. stream
stream是redis的一种数据类型
// 添加消息进队列-- 加入s1,*表示自动生成id,k1v1一组键值对 XADD s1 * k1 v1 //从stream队列s1中读取一条信息,0表示从头开始,$表示从最新开始 XREAD COUNT 1 STREAM s1 0 // 等待新消息,等待0秒 XREAD COUNT 1 BLOCK 0 STREAM s1 0XREAD优点:可回溯(不会消失),可以多消费者读取,可以阻塞读取
缺点:可能漏读(如果是$读取,读完之后同时有很多条新消息,只能读到最后的那条)
为了改进这些问题,可以引入消费者组,消费者组消息分流给不同消费者、对消息进行标识、进行消息确认(消息进入pendinglist,处理完执行XACK之后才会从中取出)
XGROUP CREATE/DESTROY... // 读消费者组g1,消费者c1 读一条消息,阻塞2000ms,>表示从下一个未消费的信息开始 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >改造代码:
private class VoucherOrderHandler implements Runnable{ @Override public void run() { // 就是一直从消息队列取信息 while(true){ try { //获取消息队列信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.orders > List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create(queueName, ReadOffset.lastConsumed()) ); // 判断是否拿到信息 if(list == null || list.isEmpty()){ // 没拿到,继续循环 continue; } // 解析信息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values,new VoucherOrder(),true); // 拿到信息,可以下单 handleVoucherOrder(voucherOrder); // ACK确认 stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId()); } catch (Exception e) { log.error("处理订单异常",e); }StreamReadOptions是Spring Data Redis 用来配置 Stream 读取行为的参数构造器,设置参数时需要通过静态方法StreamReadOptions.empty()先创建一个“空的/默认的配置对象”
StreamOffset.create是用来指定读取“哪一个队列”以及“从队列的什么位置开始读”的偏移量定位器。ReadOffset对应的就是0、$、>
MapRecord是 Spring Data Redis 专门封装 Redis Stream 消息的主体数据结构,redis内信息底层由消息id和消息体键值对构成,所以list.get(0)得到的是redis第一条消息,getValue得到的是消息体键值对,所以是Map结构。
利用工具包BeanUtil,将前面拿到的value拆开注入到新的voucherOrder对象,自动反射注入各种id
创建HandlePendingList,大致和前面代码差不多,如果处理订单异常则调用这个函数处理pendinglist。while ture循环,如果处理完之后又有新的消息则会继续循环,不用嵌套
秒杀部分总结:
--改为异步下单,引入lua脚本。将一人一单判断、扣redis库存写入lua,先用lua判断,如果有资格下单则将信息存入阻塞队列。开启多线程从阻塞队列中不断获取消息实现异步下单
--用了阻塞队列,生产者消费者通过orderTasks发送接收消息,消息都存在Jvm中且是voucherorder对象,重启可能会导致信息丢失
--由于阻塞队列可能会有内存和数据安全问题,改用消息队列,用到stream结构存在redis,引入消费者组(增加了pendinglist处理),消息只有键值对,需要序列化
代码思路大致如下:
· 类初始化之后就要执行init,提交VoucherOrderHandler任务给线程池,随时有人可能下单
· seckillOrder引入lua判断,返回判断结果
· VoucherOrderHandler重写run方法,任务执行流程为:whiletrue一直循环,从消息队列中拿信息,无信息继续循环,有信息反序列化调用handleVoucherOrder下单并ack确认
- handleVoucherOrder从voucherOrder中获取userid,获取锁,反向代理使得事务生效,调用createVoucherOrder执行业务,事务提交之后才释放锁
- createVoucherOrder真正执行业务,判断一人一单、下单扣库存
- 如果有异常,调用handlePendingList处理
达人探店
查看笔记
完善blogcontroller
@GetMapping("/hot") public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) { // 根据用户查询 return blogService.queryHotBlog(current); } @GetMapping("/{id}") public Result queryBlogById(@PathVariable("id") Long id){ return blogService.queryBlogById(id); }·GetMapping告诉springboot只接收get方法,指定URL
·@RequestParma告诉springboot需要且必须传入参数,如果没有current参数会返回400,默认1开始,配合MyBatisPlus的Page对象执行分页的逻辑。Controller一般用Integer包装类而不是int,防止null出现异常。
·@PathVarible路径变量注解,将URL中的id传入方法作为参数
点赞
参考一人一单,将blogid作为key,redis中存储给这个帖子点过赞的userId (唯一用set)
在Blog增加islike判断。@TableField(exist = false)避免MyBatisPlus的ORM映射,让这些字段不强制对应表中字段
登录之后的主页和点开具体帖子页面都要显示点赞的情况,这两种情况都要查询
代码思路:
获取userId--> 判断userId是否在redis key中--> 没点过赞可以点赞+1且add进redis,点过赞取消点赞remove
Long userId = UserHolder.getUser().getId();如果是游客登录没有token,getId()为null
记得isMember()内的id要toString,stringRedisTemplate只接受string。凡是代码里出现UserHolder.getUser(),先想"这个接口游客能访问吗?"能的话就得判空。
点赞排行榜
前面由于点赞唯一性用set,实现排行榜要实现排序、唯一、和方便查找,list结构不能实现唯一
因此选用sortedset--> ZSCORE可以查询元素对应分数,ZRANGE查找范围内的元素
把先前关于点赞保存到redis的代码改为ZSet
(改完数据结构redis要清空,用的是同一个key
添加排行榜代码
@Override public Result queryBlogLikes(Long id) { String key = BLOG_LIKED_KEY + id; // 查询top5的点赞用户 Range 0 4 Set<String> top5 = stringRedisTemplate.opsForZSet().range(0, 4); if(top5 == null || top5.isEmpty()){ return Result.ok(Collections.emptyList()); } // 解析其中的用户id List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList()); // 根据用户id查用户 List<UserDTO> userDTOS = userService.listByIds(ids) .stream() .map(user -> BeanUtil.copyProperties(user,UserDTO.class)) .collect(Collectors.toList()); // 返回 return Result.ok(userDTOS);用 :: 替代 str -> Long.valueOf(str)写成(Long :: valueOf)。map将string转为long类型,将流的数据收集到collection
但是想让后点赞的人头像在前,sql执行WHERE id IN(5,1)不会从5到1排序,返回的是1到5
sql加ORDER BY FIELD(id,5,1),代码:
// 根据用户id查用户,ORDER BY FIELD保持ZSet里的点赞顺序(listByIds会打乱顺序) // WHERE id IN(5,1) ORDER BY FIELD(id,5,1) String idStr = StrUtil.join(",", ids); List<UserDTO> userDTOS = userService.query() .in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list() .stream() .map(user -> BeanUtil.copyProperties(user,UserDTO.class)) .collect(Collectors.toList());调用strutil的join方法,将类似5,1拼接为字符串
mybatisplus的query方法增加last表示在sql语句最后加一句
关注
添加两个接口:关注取关,判断是否关注(从tb_follow中获取信息)
@Override public Result follow(Long followUserId, Boolean isFollow) { // 现在登录用户的id,followUserId是关注博主的id Long userId = UserHolder.getUser().getId(); // 判断关注还是取关 if(isFollow){ // 没关注--加关注,新增关系 Follow follow = new Follow(); follow.setFollowUserId(followUserId); follow.setUserId(userId); save(follow); }else{ // 关注了--取关 // delete from tb_follow where userId = ? and followUserId = fuid remove(new QueryWrapper<Follow>() .eq("follow_user_id",followUserId).eq("user_id",userId)); } return Result.ok(); }ORM思想比如mybatisplus中,数据库表一行就是一个对象,Follow就是表的实体类,所以要newFollow,save()接收对象,反射读取属性
QueryWrapper负责写where子句,每次查询都需要全新的条件容器,所以new
列名容易写错也可以用.eq(Follow::getUserId, userId)
和query()不太一样
// 直接通过 query() 开头,链式拼接条件并直接执行 .list() 获取结果 List<Blog> list = blogService.query() .eq("user_id", userId) .orderByDesc("liked") .list(); // 链式结尾直接触发查询共同关注
set结构求交集可以求共同关注,SADD添加这个用户的关注列表,SINTER s1 s2就可以查出s1s2的交集
要把数据保存到redis,首先改造在关注当前用户的时候不仅存储到数据库还存储到redis中
关注接口add语句不要写错,followUserId是关注的博主的id
Feed流
拉模式:每个人发的信息传到发件箱,需要看的时候再拉取到收件箱(读比例高,延时高)
推模式:发信息直接发到粉丝收件箱(写比例高,适合用户量少)
推拉结合:普通粉丝采用拉、活跃粉用推,减少延迟(适合用户量多)
修改代码思路:
1. 发笔记把blog存数据库之后推到粉丝收件箱
2. 收件箱按时间排序,用redis数据结构,支持分页查询(但是feed流导致数据随时发生变化尽量不用list结构,不能用传统的角标分页模式,sortedset支持score范围查询)
关注页滚动分页
原理是用ZREVRANGEBYSCORE查询,一共有四个参数:
1. max,第一次查询一般是时间戳范围最大值,其他是score值
2. min,一般写0,从≦max的第一个元素开始
3. offset,特殊情况下如果score值相同会导致查询重复,一般score有几个一样就写多少
4. count,查询条数
时间戳是发布笔记的时间,关注页要求最新发的在最上面,所以从上到下时间戳由大到小。记录最小时间戳就是记录最后一条blog数据。每一次查询都是判断小于等于,所以会将前面页查过的数据也一起查询,因此offset就是score一样的blog条数,新一轮查询时可以决定跳过多少条查询。
Java 的包装类在超过-128 ~ 127时,使用==比较的是内存地址,而不是数值本身!
// 解析数据:blogId,offset,minTime(时间戳) List<Long> ids = new ArrayList<>(typedTuples.size()); long minTime = 0; int os = 1; //offset for (ZSetOperations.TypedTuple<String> tuple: typedTuples) { // 获取id。存入zset的是string,转为long存入list,用add添加 ids.add(Long.valueOf(tuple.getValue())); // 获取score(时间戳。将double强制转化为long类型 Long time = tuple.getScore().longValue(); // Long类型的是对象,指向对象的内存地址,超过一定范围==比较的是地址不是值,long是基本数据类型 if(time == minTime){ os++; }else{ minTime = time; os = 1; } }// 根据id查blog String idStr = StrUtil.join(",", ids); List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for (Blog blog : blogs) { // 查询笔记相关用户 queryBlogUser(blog); // 查询blog是否被点赞 isBlogLiked(blog); }将列表转为字符串,相当于sql: WHERE id IN (10,8,5)
附近商铺
基于redis 的GEO数据结构,可以存储经纬度、值。
GEOADD key 经 纬 value;GEODIST
经纬度数据存在数据库,可以存到redis中按位置查找,member存shopid
按照店铺类型分类写入redis
@Test void loadShopData(){ // 查询店铺信息 List<Shop> list = shopService.list(); // 店铺分组,按照typeid分组 Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId)); // 分批写入redis for (Map.Entry<Long, List<Shop>> mapEntry : map.entrySet()) { // 获取类型id Long typeId = mapEntry.getKey(); String key = "shop:geo:" + typeId; // 获取同类型店铺集合 List<Shop> value = mapEntry.getValue(); List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size()); // GEOADD for (Shop shop : value) { locations.add(new RedisGeoCommands.GeoLocation<>( shop.getId().toString(), new Point(shop.getX(), shop.getY()) ) ); } stringRedisTemplate.opsForGeo().add(key,locations); } // }整体代码思路:
1. 判断是否需要坐标,如果xy其中一个null直接按数据库查
2. 分页参数from和end,查redis中范围内店铺的位置信息,解析信息
3. skip from过滤信息实现分页,将店铺id和距离存入distanceMap实现对应
4. 根据shopid查询店铺,但是不打乱距离排序,填充每个店铺距离
查询现在位置5000米圆内的店铺
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEORADIUS key x y 5000 m WITHDIST COUNT end .radius(key, new Circle(new Point(x, y), new Distance(5000)), RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end));List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent(); if (list == null || list.size() <= from) { // 没有下一页了,返回空 return Result.ok(Collections.emptyList()); } List<Long> ids = new ArrayList<>(list.size()); Map<String, Distance> distanceMap = new HashMap<>(list.size()); list.stream().skip(from).forEach(result -> {因为用skip跳过实现分页查询,如果跳过了所有数据list会为空,最后查询出现异常
因此添加判断,listsize小于from不用再查询
签到
用数据库占用空间太大,用redis bitmap,一位表示一天的情况。BITMAP基于String,分装到string内了