文章目录
1.登录怎么实现?
1.1 基于Session登录
登录包括短信验证码的发送,然后是基于短信验证码的登录,最后是对登录状态的校验。
发送短信验证码
- 用户提交自己的手机号,服务端接收到这个手机号以后,首先要去验证一下手机号是不是合法的。合法就发送验证码。发验证码之前要先生成验证码,生成验证码的目的是为了让用户去做登录。要把这个验证码保存在本地,将来用户在登录的时候,才能做验证。那既然是基于session登录,那肯定是把这个验证码保存在session当中,之后就可以给用户发送短信验证码了。
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号:利用util下RegexUtils进行正则验证
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码:导入hutool依赖,内有RandomUtil
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
session.setAttribute("code",code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
短信验证码登录、注册
- 用户收到验证码以后,就可以去做登录或者注册。把自己的手机号和验证码提交到后台,后台去验证他提交的这两个数据。用户提交的验证码,跟上一步保存在session里的验证码做一个比较。如果一样证明验证码是正确的,但不能证明他就可以登录,因为手机号还没有验证。要验证手机号,我们可以去数据库里查,根据手机号去查询用户信息,有可能查得到,也有可能查不到,所以要对用户做个判断,如果这个用户存在,就可以登录。登录一定要保存用户信息,保存在session里。如果用户不存在,证明这个人是第一次来访问,这个时候要给他注册成一个新用户,给他填充一些基本信息,写到数据库中,然后把用户信息保存到session里。也就是说, 登录和注册其实是在一个功能里完成的。
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误!");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(code==null||!cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误!");
}
//4.一致,根据手机号查询用户(需要写对应的单表查询方法:select * from tb_user where phone = #{phone})
User user = query().eq("phone", phone).one();
if(user==null){
//5.注册用户
user.setPhone(phone);
user.setNickName("user_"+RandomUtil.randomString(10));
//保存用户
save(user);
}
//6.存入session,需要隐藏用户敏感信息,不能直接存user
//session.setAttribute("user",user);
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
校验登录状态
- 后续开发的很多业务中需要登录验证,我们不可能在每一个 controller 里来写这些业务逻辑。所以用一个拦截器(由 SpringMVC 提供)统一进行拦截然后验证。用户登录成功了,那以后他访问一些关键的业务的时候,都需要去校验登录状态。那么我们把用户信息保存到session了,我们要基于session进行校验。session是基于cookie的,每一个session都会有一个对应的session ID保存在浏览器的cookie当中,所以说,当用户来访问我们的时候,他一定会携带上自己的cookie,cookie里就会有那个session ID。这个时候我们可以基于cookie当中的session ID,从而拿到session,从而去从session当中获取用户。那这个时候我们只需要判断一下这个session有没有用户,就能够知道他是否登录了,那如果经过判断发现没有用户,那就拦截他的请求就行了。那如果判断有,证明这个用户是曾经登录过的,那就放行。这个登陆状态的校验不能白校验,因为在后续的业务当中,一定会用到当前登录的这个用户的信息,既然如此,我们把这个用户的信息给他缓存起来,方便后续的业务是不是来使用它,把登录用户缓存在这个ThreadLocal当中,那这样一来呢,后续的业务就可以直接从ThreadLocal里获取用户,这就是基于session的一个登录状态的校验了。
1.2 ThreadLocal
ThreadLocal其实就是一个线程域对象。在我们的业务当中,每一个请求到达我们的微服务,它都会是一个独立的线程,如果说,我们没有用ThreadLocal,而是直接把数据保存到一个本地变量,那就会可能出现多线程并发修改的一个安全问题,而ThreadLocal呢,它会将这个数据保存到每一个线程的内部,在线程内部创建一个map来去保存,这样一来每一个线程都有自己独立的存储空间,那每一个请求来了以后,都会有自己的空间,相互之间没有干扰。然后我们再去放行,那后续的所有的业务都可以从这里边去取出自己的用户信息。
1.3 session共享问题
-
基于session的短信登录会出现session共享的问题:多台Tomcat无法共享session存储空间,当请求切换到不同tomcat服务时会导致数据丢失的问题。
-
将来为了应对并发,肯定是要做水平扩展,部署多个tomcat形成负载均衡的集群。当请求进入Nginx,它会做一个负载均衡,在多台tomcat进行轮询。每一个tomcat都会有自己的session空间。用户请求来了以后,第一次被负载均衡到了第一台tomcat。假如说用户登录,那么用户信息保存到了这台tomcat里面去了。紧接着,用户第二次登录,请求被负载均衡到了第二台tomcat。那去获取用户信息的时候,就是空的。就会告诉用户说你没有登录,这不扯了吗?我前一秒钟刚登录完,这样用户体验就很不好。这就是所谓的session共享的问题。
-
session 的替代方案应该满足:数据共享;内存存储;key、value 结构(Redis 恰好就满足这些情况)
1.4 基于Redis实现共享session登录
修改发送短信验证码,保存验证码用Redis的String数据结构,key是手机号,value是验证码
// 4.保存验证码到 session
// session.setAttribute("code",code);
// 4.保存验证码到 Redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
修改短信验证码登录、注册,用Redis的Hash数据结构保存用户,通过UUID随机生成token,作为登录令牌。不能直接把user存入Hash,而是要通过UserDTO隐藏用户敏感信息,然后存入
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 3.一致,根据手机号查询用户 select * from tb_user where phone = #{phone}
User user = query().eq("phone", phone).one();
// 4.判断用户是否存在
if (user == null) {
//5.注册用户
User newUser = new User();
newUser.setPhone(phone);
newUser.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
save(newUser);
user = newUser;
}
// 6.保存用户信息到 redis中
// 6.1.通过UUID随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 6.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
HashMap<Object, Object> userMap = new HashMap<>();
userMap.put("id", userDTO.getId().toString());
userMap.put("nickName", userDTO.getNickName());
userMap.put("icon", userDTO.getIcon());
// 6.3.存储到redis中
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 6.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7.返回token
return Result.ok(token);
}
修改校验登录状态
- 之前拦截器拦截的是需要登录检验的路径,对于一些不会被拦截器拦截的路径(比如首页),拦截器就不会生效,token就不会刷新。那么token过了有效时间后,尽管用户一直在访问,但用户的登录状态也就消失了。我们可以在原有的拦截器上新增一个拦截器,第一个拦截器拦截一切路径,保存ThreadLocal和刷新token有效期,第二个拦截器做登录校验。
2.商户查询缓存怎么实现?
2.1 什么是缓存,有什么用?使用缓存会带来什么问题?
什么是缓存:
- 缓存(Cache)是数据交换的缓冲区,是临时存贮数据的地方,读写性能较高。
缓存的作用:
- 降低后端的负载压力:请求进入Tomcat以后,以前我们是要去查数据库,而数据库要去做磁盘读写,相对来说效率是比较低的,一些复杂业务的Sql查询起来就更慢了。如果有了缓存,请求进入Tomcat以后,直接在缓存里查到数据,返回给前端,不用去查数据库,对后端来说压力就大大降低了。
- 提高读写效率,降低响应时间:数据库的读写是磁盘读写,响应时间比较长。如果使用像Redis这样的缓存,它的读写延时往往在微秒级别,响应时间大大缩短,读写效率大大提高,在面对用户量比较大,并发量比较高的业务里,使用缓存就能够解决这样的高并发问题。
使用缓存带来的问题:
- 使用缓存可能会带来数据一致性问题,用户查询数据先去查Redis,如果数据库的数据发生改变,Redis里的数据还没及时更新,那么从缓存内取到的数据就会出错。
2.2 解决缓存一致性问题要采用什么策略?
- 解决缓存一致性问题的策略:缓存更新策略
内存淘汰 | 超时剔除 | 主动更新 |
---|---|---|
不用自己维护。利用 Redis 的内存淘汰机制:当内存不足时自动淘汰部分数据。下次查询时更新缓存。 | 给缓存数据添加 TTL 时间,到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存。 |
业务场景
- 低一致性需求:使用Redis自带的内存淘汰机制。
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。
具体例子
项目中 ShopController 中给查询商铺的缓存添加超时剔除和主动更新的策略
- 查询数据时:根据 id 查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。
- 修改数据时:根据 id 修改店铺时,先更新数据库,再删除缓存,通过事务保证原子性。
更新商铺时,保证数据库和缓存的一致性
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
2.3 缓存存在的问题
2.3.1 缓存穿透
缓存穿透是指用户请求的数据在缓存和数据库中都不存在,如果不断发起这样的请求,这些请求都会打到数据库,给数据库带来巨大的压力。
常见的解决方案有两种:
1.缓存null值
2.布隆过滤
基于缓存空对象解决商铺查询的缓存穿透问题
2.3.2 缓存雪崩
缓存雪崩指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
1.给不同的Key的TTL添加随机值
2.利用Redis集群提高服务的可用性
2.3.3 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建耗时长的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
1.互斥锁:查询缓存未命中,获取互斥锁,获取到互斥锁的线程才能查询数据库重建缓存,将数据写入缓存中后,释放锁。
2.逻辑过期:查询缓存,发现逻辑时间已经过期,获取互斥锁,开启新线程;在新线程中查询数据库重建缓存,将数据写入缓存中后,释放锁;在释放锁之前,查询该数据时,都会将过期的数据返回。
3.秒杀业务
3.1 全局唯一ID
当用户抢购优惠券时,就会生成订单并保存到订单表中,而订单表如果使用数据库自增ID就存在一些问题:
- id的规律性太明显:用户就能根据id猜测在一天时间内,卖出了多少单,这明显不合适。
- 受单表数据量的限制:当数据量增大时,单张表保存不了那么多的数据,就要将数据分到多张表,MySql每张表都从1开始增,那订单id就重复了,肯定不行,我们需要保证id的唯一性。
全局唯一 ID 生成策略用的是Redis自增id策略。Redis的String数据结构,有个Increment命令,可以实现自增。
- ID 的组成部分: 时间戳 + 自增id。
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
全局唯一ID
@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;
}
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;
}
}
测试
@Resource
private RedisIdWorker redisIdWorker;
//线程池,500个线程
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testRedisIdWorker() throws InterruptedException {
//CountDownLatch: 300个线程执行任务,每当一个线程的任务执行完,计数器-1
CountDownLatch latch = new CountDownLatch(300);
//任务:每个线程生成100个id,并打印
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
//提交任务300次
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin) );
}
3.2 秒杀下单
秒杀下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件,时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。
3.3 库存超卖问题
超卖问题是典型的多线程并发安全问题。线程 1 和 线程 2 都查询库存为 1(二者都未进行判断并扣减),之后线程1先判断1>0,扣减库存,这时候库存变为0。线程2也判断1>0,扣减库存,这时候库存就变成-1了。
多线程并发安全问题产生的原因就是多个线程操作共享资源,操作资源的代码有好几行,在这几行代码执行的中间,多个线程互相穿插,就出现安全问题了。
针对这一问题的常见解决方案就是加锁:而对于加锁,通常有两种解决方案:
- 悲观锁:认为线程安全问题一定会发生,在对数据操作之前先获取锁,确保线程串行执行(既然多线程并发会有安全问题,就不让它并发,多个线程一个一个地去执行,在高并发场景下效率就比较低。Synchronized和Lock都是悲观锁)。
- 乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的处理方式有两种:版本号 和 CAS
版本号法:每当数据进行修改,版本号就会 +1。判断一个数据有没有修改过只要判断版本号有没有变化。
CAS法:在版本号法基础上进行简化,通过数据本身有没有发生变化来判断线程是否安全。既然每次更新都要更新库存和版本,那只要判断我扣减库存时的库存和之前我查询到的库存是不是一样的,一样就意味着没有修改过库存,那么此时线程就是安全的。
乐观锁
// 5. 减扣库存
boolean isSuccess = seckillVoucherService.update()
//set stock = stock - 1
.setSql("stock= stock -1")
//where voucher_id = ? and stock = ?
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();
以上逻辑的核心含义是:只要扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
修改乐观锁,改成stock大于0
// 5. 减扣库存
boolean isSuccess = seckillVoucherService.update()
// set stock= stock - 1
.setSql("stock = stock - 1")
// where voucher_id = ? and stock > 0
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
3.4 一人一单
3.4.1 单机情况下实现一人一单
修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
具体操作逻辑如下:时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单
存在问题:和之前库存超卖问题一样,有多线程并发安全问题,多个线程穿插执行。所以需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作。
3.4.2 集群模式下一人一单的并发安全问题
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1、我们将服务启动两份,端口分别为8081和8082:
2、然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
有关锁失效原因分析
由于我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,jvm的内部维护了一个锁监视器对象,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象userid是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
3.5 分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程。
常见的分布式锁有三种:
-
Mysql:mysql本身有互斥锁机制,但是mysql性能一般,所以mysql作为分布式锁比较少见。
-
现在企业级开发中基本都使用Redis或者zookeeper作为分布式锁。
-
Redis:利用setnx方法,获取锁。如果key插入成功,则表示获得到了锁,如果插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
-
Zookeeper:zookeeper利用节点的唯一性和有序性(节点id递增)实现互斥。
3.5.1 基于 Redis 的分布式锁
实现分布式锁时需要实现两个方法:获取锁和释放锁
- 获取锁:利用 setnx 命令获取锁
- 释放锁:
- 手动释放:利用 del 命令 直接删除
- 超时释放:获取锁时通过 expire 命令添加超时时间,避免服务宕机
如果获取锁成功,还没来得及expire,Redis 就宕机了。如何保证获取锁和释放锁操作同时成功和失败,保证其原子性?
将 setnx 命令 和 ex命令 合起来:SET key value EX 超时时间 NX
3.6 Redis消息队列实现异步秒杀
消息队列(Message Queue)是存放消息的队列。最简单的消息队列模型包括3个角色:
- 生产者:发送消息到消息队列
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 消费者:从消息队列获取消息并处理消息
使用队列的好处在于 解耦:所谓解耦,举一个生活中的例子就是:快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,如果耦合,那么这个快递员相当于直接把快递交给你,这事固然好,但是万一你不在家,那么快递员就会一直等你,这就浪费了快递员的时间,所以这种思想在我们日常开发中,是非常有必要的。
在秒杀场景中:当有人抢购优惠券时,不着急真正下单,可以先判断用户有没有购买的资格,有购买的资格,不写到数据库,而是把订单的相关信息写到消息队列里去,通过队列把消息发送出去,这时候再开启一个独立的线程作为消费者,不断地从队列里获取消息,真正地完成下单,写入数据库。这样秒杀的业务就和写数据库的业务分离了,变成了异步操作,解除了耦合。
这里我们可以使用一些现成的mq,比如kafka,rabbitmq等等,但是如果没有安装mq,我们也可以直接使用redis提供的mq方案,降低我们的部署和学习成本。
4.点赞怎么实现?
4.1 需求:
同一个用户只能点赞一次,再次点击就取消点赞。
如果当前用户已经点赞,则点赞按钮高亮显示(由前端来实现,我们只要告诉前端有没有点过赞,前端判断Blog类的isLike属性是True还是False,True表示点过赞,False表示没点赞)
4.2 实现步骤:
- 要实现点赞功能,第一件事就是给 Blog 类中添加一个 isLike 字段,标识它有没有被点过赞
- 那怎么判断用户有没有点过赞呢?最简单的方案就是在数据库里建张表,这张表里保存这个Blog的id和给这个Blog点赞的User的id。每当点赞了一次,这张表就记录了一条数据,那下次再点赞就去判断这张表有没有这条数据,有的话就说明点过赞了,这种实现方式是可以的,但是用数据库来实现,太重了!!这种点赞的判断肯定比较多,对数据库的压力比较大。所以我们就用Redis来实现,我们要判断用户有没有点过赞,其实就是记录当前的Blog被哪些人点赞过,我们可以在Redis里,以Blog的id为key,value就去记录给这个Blog点赞过的所有用户,这时候就需要一个集合把所有点赞过的用户id都存进去,下次就判断用户id在这个集合里存不存在,就知道点没点过赞了。我们要找Redis中一个集合的数据结构,同时一个用户只能点赞一次,也就是说在这个集合中用户id不能重复,那Redis中的数据结构保证是集合,又保证元素不可重复,那就是Set集合了。利用Set 集合判断是否点赞过,没点赞过则点赞数 + 1,点赞过则点赞数 - 1
- 剩下的就是前端查询的时候给Blog类的isLike属性赋值。有2个地方做了查询,第一个就是在首页查询Blog的列表,这是个分页查询,第二个地方是点击某个Blog,查看它的详情的时候。
所以要在根据 id 查询Blog的时候,判断当前登录用户是否点赞过,给isLike属性赋值,还有分页查询Blog的时候,判断当前登录用户是否点赞过,给isLike属性赋值。
给 Blog 类中添加一个 isLike 字段
//是否点赞
@TableField(exist = false)
private Boolean isLike;
判断用户是否对该 Blog 点赞过
/**
* 判断用户是否对该 Blog 点赞过
*/
private void isBlogLiked(Blog blog) {
String key = BLOG_LIKED_KEY + blog.getId();
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点过赞
return;
}
Long userId = user.getId();
Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
blog.setIsLike(BooleanUtil.isTrue(isLiked);
/**
* 展示 Blog 详情页(根据ID查Blog)
*/
@Override
public Result queryById(Long id) {
//1. 查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在!");
}
//2. 查询blog相关的用户
queryBlogWithUserInfo(blog);
// 3.查询blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
/**
* 分页查询 Blog
*/
@Override
public Result queryHotBlog(Integer current) {
// 分页查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询blog相关的用户以及blog是否被点赞
records.forEach(blog -> {
this.queryBlogWithUserInfo(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
}
实现点赞功能
@Override
public Result likeBlog(Long id) {
// 1. 判断当前登录用户是否点过赞。
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id;
Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
// 2. 未点过赞:点赞,数据库点赞数 +1,将用户保存到 Redis 的 Set 集合中。
if (BooleanUtil.isFalse(isLiked)) {
Boolean isSucceed = update().setSql("liked = liked + 1").eq("id", id).update();
if (BooleanUtil.isTrue(isSucceed)) {
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
} else {
// 3. 已点过赞:取消赞,数据库点赞数 -1,将用户从 Redis 的 Set 集合中移除。
Boolean isSucceed = update().setSql("liked = liked - 1").eq("id", id).update();
if (BooleanUtil.isTrue(isSucceed)) {
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}
5.点赞排行版怎么实现?
5.1 需求:
按照点赞时间先后排序,返回Top5点赞的用户。
5.2 实现步骤:
- 之前的点赞是放在 Set 集合中,但是 Set 集合里的元素是无序不可重复的,这里需要使用可排序的 Set 集合,即 SortedSet。
- 通过 ZSCORE 命令获取 SortedSet 中存储的元素的相关的 SCORE 值,来判断存不存在元素,查的到就是有点赞,查不到就是没点赞。
- 排行榜的功能通过 ZRANGE 命令获取范围内的元素,按时间戳从小到大排序,返回前5名,那就是查0到4的元素。
修改点赞业务逻辑
private void isBlogLiked(Blog blog) {
// 1.获取登录用户
String key = BLOG_LIKED_KEY + blog.getId();
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询是否点过赞
return;
}
// 2.判断当前登录用户是否已经点赞
Long userId = user.getId();
//Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//blog.setIsLike(BooleanUtil.isTrue(isLiked);
blog.setIsLike(score != null);
}
public Result likeBlog(Long id) {
// 1. 判断当前登录用户是否点过赞。
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id;
//Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
// 2. 未点过赞:点赞,数据库点赞数 +1,将用户保存到 Redis 的 Set 集合中。
if (BooleanUtil.isFalse(isLiked)) {
Boolean isSucceed = update().setSql("liked = liked + 1").eq("id", id).update();
if (BooleanUtil.isTrue(isSucceed)) {
//stringRedisTemplate.opsForSet().add(key, userId.toString());
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
} else {
// 3. 已点过赞:取消赞,数据库点赞数 -1,将用户从 Redis 的 Set 集合中移除。
Boolean isSucceed = update().setSql("liked = liked - 1").eq("id", id).update();
if (BooleanUtil.isTrue(isSucceed)) {
//stringRedisTemplate.opsForSet().remove(key, userId.toString());
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
top5点赞用户查询
@Override
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 1. 查询最早五个点赞的用户
Set<String> topFive = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (topFive == null || topFive.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2. 解析出UserId,然后根据UserId查询user,再转化为UserDto
List<Long> userIdList = topFive.stream().map(Long::valueOf).collect(Collectors.toList());
// List<UserDTO> userDTOList = userService.listByIds(userIdList)
// .stream()
// .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
// .collect(Collectors.toList());
List<User> users = userService.listByIds(userIdList);
List<UserDTO> userDTOList =new ArrayList<>();
for (User user : users){
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user,userDTO);
userDTOList.add(userDTO);
}
return Result.ok(userDTOList);
}
6.关注怎么实现?
6.1 需求:
关注讲的是用户之间的关系,是种多对多的关系,需要借助中间表,通过 tb_follow表进行表示。
关注功能需要实现两个接口:1.关注与取关的接口 2.判断是否关注的接口。
6.2 实现步骤:
判断到底是关注还是取关,取决的是传的参数isFollow,是True就代表关注,是False就代表取关。先获取当前登录的用户,然后判断是关注还是取关,关注就新增数据,取关就删除数据。
//关注或取关
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
if(isFollow){
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
}else {
// 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
QueryWrapper<Follow> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id",userId).eq("follow_user_id",followUserId);
remove(queryWrapper);
}
}
return Result.ok();
}
关注和取关完成之后,我们就要去判断一个用户有没有关注,就去tb_follow表查询有没有这样的一条数据,有的话就是关注了,没有的话就是没有关注。
/**
* 判断是否关注该用户
* @param followUserId
* @return
*/
@Override
public Result isFollow(Long followUserId) {
//1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
//2.查询是否已关注 select count(*) from tb_follow where user_id = #{userId} and follow_user_id = #{followUserId};
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
//3.判断是否关注
return Result.ok(count > 0);
}
7.共同关注怎么实现?
7.1 需求:
在博主个人页面展示出当前用户与博主的共同好友
7.2 实现步骤:
- 要实现共同关注,我们要去查当前登录的用户和目标用户都关注了谁,求这两个关注列表中的交集。求交集可以用Redis的Set集合。当前登录的用户关注了谁保存在Set集合当中,目标用户关注了谁也保存在Set集合当中,然后求它们的交集。
- 所以我们要先去修改之前的关注取关的代码,每次关注的时候不仅要把目标用户放到数据库里,还要放到Redis里,取关的时候要把目标用户从数据库里删除,还要从Redis里删除:
//关注或取关
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
String followKey = "follow:" + userId;
if(isFollow){
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSave = save(follow);
if(isSave){
//把目标用户id放入Redis的Set集合 当前用户id 为 key,关注用户id 为 value
stringRedisTemplate.opsForSet().add(followKey, followUserId.toString());
}
}else {
// 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
QueryWrapper<Follow> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id",userId).eq("follow_user_id",followUserId);
boolean isRemove = remove(queryWrapper);
if(isRemove){
//把目标用户id从Redis的Set集合移除
stringRedisTemplate.opsForSet().remove(followKey, followUserId.toString());
}
}
return Result.ok();
}
- 接下来实现共同关注,求当前用户和目标用户关注列表的交集。
- 先获取当前登录用户,再获取目标用户,求交集,通过stream流解析出UserId集合,然后根据UserId查询用户。
@Override
public Result commonFollow(Long followUserId) {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
String followKey1 = "follow:" + userId;
//获取目标用户
String followKey2 = "follow:" + followUserId;
//2.求交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(followKey1, followKey2);
if(intersect==null||intersect.isEmpty()){
return Result.ok(Collections.emptyList());
}
//3.解析出id集合
List<Long> userIdList = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//4.然后根据UserId查询用户,再转化为UserDto List<User> ---> List<UserDTO>
// List<User> users = userService.listByIds(userIdList);
// List<UserDTO> userDTOList =new ArrayList<>();
// for (User user : users){
// UserDTO userDTO = new UserDTO();
// BeanUtils.copyProperties(user,userDTO);
// userDTOList.add(userDTO);
// }
List<UserDTO> userDTOList = userService.listByIds(userIdList)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOList);
}
8.关注推送怎么实现?
8.1 需求:
要去修改新增笔记的业务,在保存 Blog 到数据库的同时,也要推送消息到粉丝的收件箱。
收件箱根据时间戳排序,使用 Redis 的 SortedSet 数据结构实现。
实现滚动分页查询收件箱数据。(不能使用传统的分页,因为Feed流中的数据会不断更新,每当有新数据的时候,就会出现角标变动,读取到重复的数据,所以需要利用到滚动分页,记录每次操作的最后一条消息,从这个位置开始读取数据。)
8.2 实现步骤:
关注推送也叫做 Feed 流。用户通过无限下拉刷新获取新的信息。
获取信息的两种模式:
- 传统模式:用户自己寻找信息。
- Feed流模式:应用程序将信息推送给用户。
Feed 流有两种常见模式: - Timeline模式:不做内容筛选,直接按照内容发布时间排序,常用于好友或关注。比如微信的朋友圈。
- 智能排序模式:利用算法屏蔽掉违规、用户不感兴趣的内容。推送用户感兴趣的信息来吸引用户,比如快手,抖音,b站。
在我们的业务量,个人页面中有个关注的选项卡,这里会展示出关注的人发的探店笔记。用户关注的人发布新的笔记,就会第一时间推送给用户,是基于关注的好友来做 Feed 流,因此采用的是Timeline模式。
实现Timeline模式的方案有三种:拉模式、推模式、推拉结合。
因为用户不多,这里基于推模式实现关注推送。
推送消息到粉丝的收件箱
public Result saveBlog(Blog blog) {
// 1. 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2. 保存探店博文
boolean isSucceed = save(blog);
if (BooleanUtil.isFalse(isSucceed)) {
return Result.fail("笔记发布失败");
}
// 3. 查询笔记作者的所有粉丝(select * from tb_follow where follow_user_id = ?)
List<Follow> followUserList = followService.query().eq("follow_user_id", user.getId()).list();
if (followUserList.isEmpty() || followUserList == null) {
return Result.ok(blog.getId());
}
// 4. 推送笔记给所有粉丝
for (Follow follow : followUserList) {
// 粉丝ID
Long userId = follow.getUserId();
// 推送
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
}
// 5. 返回id
return Result.ok(blog.getId());
}
滚动分页查询
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1. 获取当前用户
Long userId = UserHolder.getUser().getId();
// 2. 查询收件箱 ZREVRANGEBYSCORE key max min LIMIT offset count
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> tupleSet = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (tupleSet.isEmpty() || tupleSet == null) {
return Result.ok();
}
//3.解析数据:blogId,minTime(时间戳),offset
List<Long> blogIdList = new ArrayList<>(tupleSet.size());
long minTime = 0;
int nextOffset = 1;
for (ZSetOperations.TypedTuple<String> tuple : tupleSet) {
blogIdList.add(Long.valueOf(tuple.getValue()));
// 时间戳(最后一个元素即为最小时间戳)
long time = tuple.getScore().longValue();
// 假设时间戳为:5 4 4 2 2
// 5 != 0 --> minTime=5; nextOffset = 1;
// 4 != 5 --> minTime=4; nextOffset = 1;
// 4 == 4 --> minTime=4; nextOffset = 2;
// 2 != 4 --> minTime=2; nextOffset = 1;
// 2 == 2 --> minTime=2; nextOffset = 2;
if (time == minTime) {
nextOffset ++;
} else {
minTime = time;
nextOffset = 1;
}
}
// 4. 根据 ID 查询 Blog
String blogIdStr = StrUtil.join(", ", blogIdList);
List<Blog> blogList = lambdaQuery().in(Blog::getId, blogIdList).last("ORDER BY FIELD(id, " + blogIdStr + ")").list();
for (Blog blog : blogList) {
// 完善 Blog 数据:查询并且设置与 Blog 有关的用户信息,以及 Blog 是否被该用户点赞
queryBlogWithUserInfo(blog);
isBlogLiked(blog);
}
// 5. 封装并返回
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogList);
scrollResult.setMinTime(minTime);
scrollResult.setOffset(nextOffset);
return Result.ok(scrollResult);
}
9.用户签到怎么实现?
9.1 签到
假如直接用数据库表来实现签到,用户签到一次就是一条记录,若有 1000 万用户,平均每人每年的签到次数为 10 次,这张表的数据量为 1 亿条,数据库压力过大。
解决方案:用一张签到表,签到打个 ✔️ 即可,未签到打个❌。
- 按月统计用户签到信息,签到记为 1,未签到则记为 0。
- 每一个 bit 位对应当月的一天,形成映射关系;用 0 和 1 标识业务状态,这种思路被称为位图(BitMap),位图的核心思想是把bit位和某种业务状态进行映射,实现简单,节省内存,大多数在做数据统计的时候就会用到BitMap。(布隆过滤器的底层也是用BitMap来实现的)
- Redis 中使用 String 数据结构实现 BitMap,最大上限是 512 MB,转换为 Bit 则是 232个
bit 位。(512MB=29 x 210KB=219KB=229B=232bit)
public Result sign() {
//1.获取当前用户
Long userId = UserHolder.getUser().getId();
//2.获取日期
LocalDateTime now = LocalDateTime.now();
//3.拼接key sign:1010:202302
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
//4.获取今天是本月的第几天 (1~31,对应BitMap的offset0~30)
int dayOfMonth = now.getDayOfMonth() - 1;
//5.写入redis
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth,true);
return Result.ok();
}
9.2 连续签到天数统计
从最后一次签到向前统计,直到遇到第一次未签到为止;计算总的签到次数,就是连续签到天数。
Java 代码:用BitField命令获取本月到今天为止的所有数据,定义一个计数器,从后向前遍历每个 Bit 位;BitField命令获取到数据的是十进制,将它与 1 做与运算,就能得到最后一个 bit 位。随后将数字右移 1 位,下一个 bit 位就成为了最后一个 bit 位。不断地向前统计,每次获得一个非0 的数字计数器 + 1,直到遍历完所有的数据。
10.用户信息UV统计怎么实现?
- UV:Unique Visitor,也叫独立访客量。同一个用户1天内多次访问该网站,只记录1次。
- PV:Page View,也叫页面访问量或页面点击量,用户每访问一次页面,就记录1次PV,用户多次访问页面,则记录多次PV。
UV 统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到 Redis 中,数据量会非常恐怖。
- Hyperloglog是种概率算法,根据一些数据的统计来推算出一个总的数量。
- Redis 中的 Hyperloglog是基于String结构实现的,单个Hyperloglog的内存永远小于16KB,内存占用非常低!
- 作为代价,Hyperloglog的测量结果是有大约0.81%的误差。不过对于 UV 统计来说,没啥差。因为是大数据量的统计,比如说1万用户,大约0.81%的误差也就是80多个人。
单元测试,向 HyperLogLog 中添加100万条数据
@Test
void testHyperLogLog() {
String[] values = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
values[j] = "user_" + i;
if (j == 999) {
// 发送到 Redis
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("count = " + count);
}
11.项目中遇到的困难
11.1 事务失效的情况
1、调用自身方法导致的事务失效
一个简单的事务失效的例子:
@Service
public class OrderServiceImpl implements OrderService {
public void update(Order order) {
updateOrder(order); // 相当于this.updateOrder(order)
}
@Transactional
public void updateOrder(Order order) {
// update order
}
}
事务可以生效,是因为Spring对当前类做了动态代理。这里调用的方法,是this.的方式调用的,我们要获取代理对象让事务生效。
1.获取代理对象让事务生效
@Service
public class OrderServiceImpl implements OrderService {
public void update(Order order) {
IOrderService proxy = (IOrderService) AopContext.currentProxy();
proxy.updateOrder(order);
}
@Transactional
public void updateOrder(Order order) {
// update order
}
}
2.在pom文件里引入aspectj依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
3.启动类上加 @EnableAspectJAutoProxy(exposeProxy = true) 暴露代理对象
11.2 事务失效的情况——秒杀模块一人一单
最终版
public Result seckillVoucher(Long voucherId) {
// 1. 根据 优惠券id 查询数据库
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始或结束(未开始或已结束,返回异常结果)
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始!");
}
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
// 3. 判断库存是否充足(不充足返回异常结果)
if(seckillVoucher.getStock() < 1){
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
// 获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 4. 一人一单(根据 优惠券id 和 用户id 查询订单;存在,则直接返回)
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
if (count > 0) {
return Result.fail("不可重复下单!");
}
// 5. 减扣库存
boolean isSuccess = seckillVoucherService.update()
// set stock= stock - 1
.setSql("stock = stock - 1")
// where voucher_id = ? and stock = ?
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!isSuccess) {
//减扣失败
return Result.fail("库存不足!");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
//Long userId = UserHolder.getUser().getId();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
boolean isSaved = save(voucherOrder);
if (!isSaved) {
return Result.fail("下单失败!");
}
// 7. 返回 订单id
return Result.ok(orderId);
}