1.小众点评项目要点
- 使用Redis代替Session登录
- Session登录存在的问题
- 使用Redis代替Session登录分析
- 使用Redis登录的流程
- 解决Redis中有效期问题
- 使用Redis作为缓存
-
- 为什么使用缓存
- 缓存策略
- 解决缓存穿透
- 解决缓存雪崩
- 解决缓存击穿
- 秒杀功能
-
- 全剧唯一ID生成器
- 秒杀业务V1.0 使用乐观锁解决超卖问题
- 秒杀业务V1.1 实现一人一单秒杀
- 秒杀业务V1.2 实现集群环境下一人一单
- 可重入锁的原理
- Redisson分布式锁
- Redission锁的MutiLock
- 使用Lua脚本结合消息队列实现异步秒杀业务
- 好友点赞和关注
-
- 5.1 好友点赞
- 5.2 好友共同关注
- Feed流的实现
-
- Feed流的实现方式
- Feed流的实现
- Feed流中的难点:滚动分页
- 附近商铺
- 即时通讯模块
-
- 即时通讯实现的步骤
- 代码修改
1.使用Redis代替Session登录
1.1 Session登录存在的问题
- Session的数据都保存在服务器中,数据量很大的时候可能导致服务器内存不足
- 在分布式系统下,每一个服务器的Session数据是独立的,用户在不同服务器之间切换的时候可能因为session问题而需要反复登录
1.2 使用Redis代替Session登录分析
为什么可以是用Redis代替Session
- Redis是基于内存的,读写速度非常快,和Session类似
- 多个服务器访问的是同一个Redis,就实现了数据的共享。此外Redis集群内部的数据一致性机制也很棒
Redis中数据结构和key的选择
- 保存验证码到Redis:由于在登录中还需要根据用户手机号来从Redis中获取验证码并比对,并且保存到信息相对简单,因此采用的key就是手机号,value保存验证码。采用String类型保存。
- 登录key设计:登录key需要满足脱敏性和唯一性。因此使用UUID作为key,value是经过脱敏后的用户对象JSON字符串。这里没有使用Hash存储,主要因为保存的数据相对简单,并且没有对用户中某一个属性进行访问的需求。
1.3 使用Redis登录的流程
发送验证码流程
- 验证手机号
- 获取验证码
- 将验证码保存到Redis中。key为手机号
登录流程
- 验证手机号
- 获取用户输入验证码,并根据手机号从Redis中获取验证码,进行比较
- 通过后,生成UUID的token。将用户信息脱敏后以token为key保存到Redis中
- 向客户端返回token
1.4 解决Redis中有效期问题
存在的问题
根据上述逻辑,Redis中保存用户信息的记录只在用户登录时设置了30min。用户访问其他网页时,记录并不会向session一样续期。
- 方案一:在登录拦截器中,从Redis中获取到用户信息,然后续期。但是对于这个项目来说,查看商店信息等请求并不会被拦截,因为不登录也可以看。当登录用户访问这些请求的时候,Redis并不会续期,导致记录过期。
- 方案二:在登录拦截器之间,加一个全局拦截器,这个拦截器拦截所有请求,并放行所有请求。它主要的作用时判断用户是否登录,如果登录,则续期。
2.使用Redis作为缓存
2.1 为什么使用缓存 :缓解数据库压力
在高并发的场景下,用户对于一些热点数据,如商品信息等访问请求量很大,如果每一次请求都访问数据库,那么对数据库等压力是非常大的。因此考虑使用缓存。
2.2 缓存更新策略 -> 内存淘汰、超时剔除 、主动更新
考虑了使用缓存就不得不谈缓存和数据库的数据一致性问题。在这个项目中,采用了在代码中手动更新的方式来保证数据的一致性。下面还有一些细节问题:
问题1: 当数据库中的数据发生变化的时候,是删除缓存还是更新缓存?
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
- 更新缓存:每次更新数据库都更新缓存,无效写操作太多
问题2: 如何保证缓存和数据库的操作同时成功?
在单体项目中使用事务注解,在分布式中使用分布式事务框架
问题3: 先操作数据库还是先操作缓存?
应该是先操作数据库。因为如果先操作缓存,那么把缓存删除掉,此时有一个请求访问缓存,发现缓存中没有数据,那么就访问数据库重建缓存,而此时数据库还没有进行修改 ,当数据修改完成后,又有新的请求访问,一看缓存中有,那么直接读取之前的脏数据。
先操作数据库,再删除缓存
2.3 解决缓存穿透
什么是缓存穿透?
一个请求始终访问数据库和缓存中都没有的记录,导致每一次请求都需要到数据库中进行查询,给数据库造成巨大压力。这种现象就是缓存穿透。
解决方案: 本项目中采用缓存空对象的方法解决这个问题。
- 当一个请求请求某一个数据,然后查询到缓存中没有,则到数据库查询
- 如果数据库中也没有查询到,则在缓存中创建一个空对象。
- 这样当这个请求再次发送时,缓存中就有了一个空对象数据。我们在查询缓存时判断如果从缓存中查询到的结果是空对象,那么就直接返回查询不到即可。
这样就解决了缓存穿透的问题。但是这个方案还有一些缺点:比如如果一开始请求的这个数据不存在,后期存在了,然而缓存中保存的还是空对象,就导致这个新增的数据查询不到。我们可以通过合理的设置空对象的过期时间来缓解这个问题。
除此之外,在项目中增加ID值的长度,这样的话如果有人恶意的访问某一个不存在的数据,在前端检测的时候直接发现ID不合法,那么也可以避免对数据库的访问。
2.4 解决缓存雪崩
什么是缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。、
解决方案:给不同的Key的TTL添加随机值。保证key不会同时失效。利用Redis集群提高服务的可用性
2.5 解决缓存击穿
什么是缓存击穿
缓存击穿也叫做热点key问题。在高并发的情况下,某一个热点key缓存过期,导致大量的请求直接发送到数据库,导致数据库压力过大。
比如说商品列表,同时有很多人访问。一旦商品列表的缓存过期,那么很多人的请求就直接打到数据库上,数据库就很有可能出现宕机的情况。
解决方案1: 可以利用互斥锁的方式解决。
- 当一个请求发现热点key过期以后,直接获取互斥锁,一旦获取到互斥锁,那么就开始访问数据库,然后重建缓存。
- 其他的请求发现缓存过期,但是已经有其他的请求获取到了互斥锁,所以这个请求只能等待。
- 当重建缓存的请求结束以后。其他的请求又发过来,此时缓存已经建立完毕,所以请求不会到数据库中。
这个互斥锁可以使用setnx命令来实现,因为setnx命令只允许一个请求成功,其他的都失败。
由于使用了互斥锁,并发性会降低一些。
解决方案2: 使用逻辑过期的方式。
这种方案创建的热点key的TTL是-1。从而不会因为缓存失效而缓存击穿
- 在缓存中保存数据的同时会保存一个逻辑过期时间的时间戳。
- 当从缓存中查询到这个数据的时候,首先获取它的逻辑过期时间。
- 如果已经过期,单独的去开辟一个线程用来重建数据。当前线程直接返回缓存中已经过期的数据。
这种方法的好处是保证了程序的并发性,但是在某一段时间内,程序读取到的数据是脏数据。
3.秒杀功能
3.1 全剧唯一ID生成器
用户在对优惠券抢购时,一个订单就对应一个订单ID,在高并发情况下需要保证ID的一下特点:
- 唯一性:ID必须保证唯一
- 高可用:可以以极快的速度生成唯一ID
- 递增型:递增的ID有利于在数据库中建立索引
在本项目中,自己实现了一个ID生成器。生成的ID结构如下:
- 第一位,符号位,永远为0
- 时间戳位:31bit,以秒为单位,可以使用69年。当前时间戳–自定义的起始时间戳
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID。这个使用Redis中的incr函数实现
Key的设计incr:业务名:日期
如果一个业务只建立一个key,那么随着时间的推移,redis中的value会达到上限,此时ID生成器就不可用了。
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;
}
3.2 秒杀业务V1.0 使用乐观锁解决超卖问题
超卖问题的产生
假设目前库存位为1,在高并发环境下:
- 线程1执行判断是否有库存的操作,然后时间片结束。结束的时候线程1并没有完成创建订单的操作。
- 此时线程2获得时间片,开始判断是否有库存,由于线程1并没有完成下单操作,因此此时库存仍然为1。
- 这时,线程1获得时间片,完成下单操作。
- 线程2获得时间片,完成下单操作
那么就会出现超卖问题,此时库存应该为-1
解决方案
目前解决方案有两种:
- 使用悲观锁,在下单的业务上使用sync关键字。但是这样会导致秒杀业务变成串行执行,严重降低并发性。
- 使用乐观锁,在更新库存的时候,判断一下是否和查询库存的时候结果一样,如果一样,则说明数据没有被修改过,则可以执行。如果库存和之前不一样,则回滚。 关键是:判断之前查询到的数据是否被修改过 两种方式:版本号法和CAS法
使用乐观锁解决超卖问题
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();
最开始的思路就是在更新库存的时候,判断一下库存是不是和当初查询库存的时候一样,如果一样就更新,否则就不更新。
在测试的时候发现,有很多的优惠卷没有卖出去。在还有库存的时候,就提示库存不足。
问题的原因在于:通过分析上述的逻辑可以得出,假设同时有100个线程同时拿到了100个库存,那么他们拿到的版本号就应该是相同的,此时只能有一个线程扣减库存成功,修改了版本号,其余所有线程会因为版本号不一致而扣减失败。因此我们还需要进一步的优化。
问题分析以后,我们发现实际上在更新库存的时候只需要判断库存不为空,就可以满足不超卖的条件,因此修改代码为:
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0);
//where id = ? and stock > 0
3.3 秒杀业务V1.1 实现一人一单秒杀
优惠卷的目的主要是引流,如果一个人可以购买多张优惠卷,那么就没有意义了。因此还需要完成一人一单的功能。
存在的问题 一个用户下多个订单
从理论上来说,只需要在代码中判断完库存充足以后,然后在判断一下用户是否下过单即可,如果下过单,则直接返回异常信息。但是在使用JMeter测试时,发现依然可以一个用户下多个订单的情况。
产生这个问题的原因还是线程的安全问题。
- 假设线程1和线程2都是同一个用户多次点击购买发出请求对应的线程。
- 那么当线程1判断用户有购买资格的时候。线程2突然获得时间片,那么线程2也判断用户是否下过单。
- 由于线程1还没有下单,因此线程2查询到用户也是有购买资格的。然后就继续执行下单操作。
- 此时,线程1获取时间片,由于已经判断过是否有购买资格,线程1直接执行下单操作,这样就导致了一个人下单多次的情况。
解决方案
根据刚才解决超卖问题的经验,那么解决这个问题也应该可以使用悲观锁或乐观锁,但是实际上这个问题只能使用悲观锁。因为这一次是插入数据,并不是更新数据,因此不存在所谓的版本号,也就无法使用乐观锁。
确定加锁范围
经过分析可以发现:需要保证判断用户是否下过单和创建订单的操作要满足原子性。也就是说在判断完是否可以下单以后,其他的线程不得访问这一部分代码。所以我们要把锁加在判断用户是否下过单和创建订单的逻辑上。
确定锁的粒度
通常情况下,在非静态方法中,sync的锁监视器都是this对象,在代码中this指代应该是VoucherOrderServiceImpl
,而这个对象是一个bean,由Spring创建,并且是单粒的。因此如果在sync中使用this作为锁监视器,那么所有线程都共享这一个锁监视器,那么加锁部分的代码就变成了完全串行执行。 (所有的用户的线程都是串行执行)
而实际上,我们只需要同一个用户发出的不同线程串行执行,不同用户的线程可以并发执行。因此不可以使用this作为锁监视器。这里我们考虑使用UserId作为锁监视器,userId.toString().intern()
通过这个代码,把用户id变成常量,这样相同的用户会共享一个锁监视器,从而完成业务。
Spring事务管理与sync关键字
目前,加锁的方法位于createVoucherOrder()
中,这个方法上面有声明式事物注解,那么就有可能出现锁已经释放了,但是事务还没有提交,那么这种情况也会出现超卖的现象。(事务还没有提交,新的用户进入到方法,查询到脏数据,所以需要先提交事务再释放锁)
锁已经释放了,说明其他的线程可以进来,而此时还没有提交事物,也就是说订单还没有写入数据库,此时进来的线程还是可以查询到该用户有购买资格,那么就会再次下单,导致一个人多次下单的问题。
因此需要先提交事务,再释放锁。所以需要将这部代码抽取成一个方法,然后从外部调用。
Spring实现事务方式
我们知道Spring中实现事务是通过动态代理来实现的,也就是说Spring调用的实际上是VoucherOrderServiceImpl的代理类对象,而在上面的代码中,我们直接使用了this,也就是说是直接调用的VoucherOrderServiceImpl中的方法,会导致声明式事物失效。因此需要先获取到当前对象的代理类对象,然后通过代理类对象对方法进行调用,才可以使声明式事务生效。
V1.0
3.4 秒杀业务V1.2 实现集群环境下一人一单
存在的问题
在上一个版本中提到的解决一人一单的方法是通过加sync代码块实现。但是这种方法在集群环境下不适用。主要原因是因为在集群环境下,每一个服务器都是一个独立的JVM,线程1和线程2属于同一个服务器,那么他们之间可以使用sync代码块实现互斥访问,但是线程3线程4位于另一台服务器。由于不同的JVM,他们之间的锁监视器是不共享的,因此线程12和线程34之间是一种并发的状态。那么一人一单的问题就不能得到保证。
解决方法
利用Redis实现一个分布式锁。需要满足一下条件
- 利用Redis中的setnx命令,并加以设置过期时间。
-
- setnx满足互斥性,多个线程执行只有一个返回true
- 使用过期时间可以保证出现故障后锁依然可以释放,不会产生死锁问题。
- 利用redis集群来提高可用性。
- 释放锁的时候需要判断当前的锁是不是自己加的,只有当前的锁是自己加的才可以删除。
- 删除锁的动作需要具备原子性,因此我们使用了Lua脚本实现多条指令的原子性。
*--* *这里的* *KEYS[1]* *就是锁的**key**,这里的**ARGV[1]* *就是当前线程标示
**--* *获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
一致,则删除锁
return redis.call('DEL', KEYS[1])
end
不一致,则直接返回
return 0
因此,我们可以利用Redis构建一个分布式锁
核心思想是利用了Redis的setnx方法,当多个线程进入时,只有一个线程能够执行setnx方法返回值为true,其余线程因为key已经存在返回的都是false,这就实现互斥。
另外,当该用户的其他线程得到的结果是false的时候,应该直接返回"一个用户只能下一单"的提示,而不是继续等待。
Redis分布式锁的key value问题
从理论上来说,key应该是业务名+用户id, 。但是在实际情况下,value需要设置的UUID+线程ID。具体原因如下:
试想一下一种情况,当线程1获取到锁,在执行业务的时候花费时间很长,导致锁自动超时释放。此时线程2获取到了锁,正在执行业务。此时线程1的业务也执行完了,直接释放锁。导致线程1把线程2的锁给释放了,产生了线程安全的问题。
这个问题实际上就是一个线程删除了本来不属于自己的锁
我们的解决方法是在value里面保存UUID+线程ID,使用UUID的目的是在集群环境下可能会存在线程ID相同的问题。这样在删除锁的时候,线程需要先根据value值判断是不是自己加的锁,如果不是,则说明其他线程已经获取到锁,那么自己执行的业务就应该回滚。如果是自己的锁,那么可以直接释放。
此外,判断锁是不是属于自己和删除锁这两个操作应该保证原子性,否则如果一个线程已经判断完是自己的锁还没有删除的时候,突然失去时间片,导致锁自动释放。另外一个线程又获取到了锁。那么当原来的已经判断完是自己的锁的那么线程再次获取到时间片,就会直接释放锁,而不会再次判断是不是自己的锁,所以还是会释放掉不属于自己的锁。
在这个项目中,采用了Lua脚本的方式来保证判断锁和删除锁的原子性。
@Resource
private RedissonClient redissonClient;
@Override
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("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
//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();
}
3.5 可重入锁的原理
上面我们实现的锁并不具备可重入的功能,当一个线程获取到锁以后,即使它再次获取锁,也会被阻塞。实际上我们可以通过使用hash结构实现可重入锁。
底层采用Redis的哈希存储方式,除了存储以 lock:order:userId作为key,以字段名threadId值为statsu的变量作为值。当线程获取锁的时候,当重入时会先判断一下当前获取锁的线程是不是threadid里面的线程,如果是则status+1
当释放锁的时候,首先判断是不是自己获取的锁。如果是,将statsu-1,然后判断status是不是为0,如果此时为0,则释放锁,否则不释放锁。
3.6 Redisson分布式锁
- 利用哈希结构实现重入
- 利用看门狗机制实现续期
- 利用信号量控制锁重试
3.7 Redission锁的MutiLock
问题:为了提高redis的可靠性,通常会搭建主从集群来扩展redis。试想一下下面的情况:
- 线程1获取锁,redis执行写操作,将锁从master上写入,而由于redis主从之间同步信息是需要时间的,主机上的信息还没有完全同步到从机上,结果主机宕机了。
- 此时根据哨兵机制,会从从机上选择一个作为新的主机,而新的主机上还没有保存之前的锁,就造成了线程安全问题。
解决方案:使用redisson中的MutiLock。
原理:不在使用主从机制。而是所有的redis都是地位相同的节点。此时获取锁需要分别从3个redis结点中获取锁,只有3个结点都写入锁成功,才算获取到锁。
3.8 秒杀优化-基于阻塞队列实现秒杀优化
修改下单动作,现在我们去下单时,是通过lua表达式去原子执行判断逻辑,如果判断我出来不为0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是0,则把下单的逻辑保存到队列中去,然后异步执行
//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户
Long userId = voucherOrder.getUserId();
// 2.创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 3.尝试获取锁
boolean isLock = redisLock.lock();
// 4.判断是否获得锁成功
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try {
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
//a
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
//3.获取代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
//4.返回订单id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("用户已经购买过了");
return ;
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
log.error("库存不足");
return ;
}
save(voucherOrder);
3.9 使用Lua脚本结合消息队列实现异步秒杀业务
问题分析
在之前的秒杀业务中,我们发现需要多次访问数据库,并且业务也是串行执行的。但是分析一下我们可以发现,我们可以将业务拆分成两个子业务,
- 一个业务只负责判断是否有购买资格,如果有购买资格则直接创建订单信息到消息队列。此时并没有真正的访问数据库创建订单,因此效率会非常高。
- 第二个业务开辟一个单独的线程,从消息队列中读取数据,保存的数据库。
第二个业务并不需要很高的即时性,当第一个业务判断完用户有购买资格后,直接返回,通知用户下单成功即可。
具体实现
- 首先这些优惠券的热点信息,包括库存等信息需要提前保存的Redis中
- 在Redis中一个优惠券对应一个键,value保存库存量
- 在Redis中一个优惠券对应一个set集合,里面保存不可重复列表,列表中每一个元素保存下单成功的用户id
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功,如果条件都满足则直接通知用户下单成功。
- 将订单信息(优惠卷id和用户id)保存到消息队列中,由独立进程慢慢的将所有的订单信息都写入数据库。
消息队列的好处在于解耦。最简单的例子生活中取快递的例子。消息队列就相当于菜鸟驿站。快递员就相当于生产者,快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,两者之间没有耦合。但是如果去掉菜鸟驿站,让快递员亲手交给我,那么如果我不在家,那么快递员就只能等待,这就浪费了大量的时间,耦合性高。
4.0 基于Redis的Stream结构作为消息队列,实现异步秒杀下单
需求:
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
修改lua表达式,新增3.6
VoucherOrderServiceImpl
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
//处理异常消息
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理pendding订单异常", e);
try{
Thread.sleep(20);
}catch(Exception e){
e.printStackTrace();
}
}
}
}
}
5.0 就拿店铺点评上的秒杀模块,说一下你是做接口测试的?
接口测试怎么测
jmeter
首先我一般进行完接口的开发编写后,对这个接口知晓它的功能和作用后,然后进行测试点的分析,主要是考虑正常场景与异常场景,正常场景,条件的组合,参数的格式校验等价边界值;异常场景,多一个参数,少一个必填参数,参数为空; 接着编写测试用例,用PostMan 或者 Jmeter 等工具去运行,创建线程组,建立 http 请求, 输入测试用例,请求参数,建立察看结果树,运行,看聚合报告,是否跟我预期的结果一致,其他用例,只需要修改里面的参数,请求地址,这些信息。
就拿我之前做过的店铺点评来说
这个秒杀业务是店铺点评上的一个核心业务,类似于现在我们用美团去团优惠券然后去店铺使用。这个秒杀是如何一步一步优化到实现肯定少不了测试。
首先整体的思路流程就是,先判断优惠券的库存是否充足,然后更新库存,创建订单。思路是没问题的,但场景是秒杀场景,高并发会导致线程安全出现超卖问题, 我用Jmeter去测试,设置200个线程组去发送请求,只有100张卷,期望正常的结果就是百分之五十的异常。结果发现最终库存成负数了,所以我给减库存的方法加乐观锁,但是线程安全问题又导致了卖不出去,因为在业务角度上,不一定要在修改库存时库存数必须要和查询到的库存数保持一致,只要有库存就可以修改。再就是还要保证一人一单,从理论上来说,只需要在代码中判断完库存充足以后,然后在判断一下用户是否下过单即可,如果下过单,则直接返回异常信息。但是在使用JMeter测试时,期望只能下一单,结果发现可以一个用户下多个订单的情况。这还是因为线程安全问题,所以就再次加锁,这次加的是悲观锁,因为这一次是插入数据,并不是更新数据,因此不存在所谓的版本号,也就无法使用乐观锁。
而这种秒杀场景后面肯定是要做集群来保证高可用的,
但是测试后又会发现发现这种加锁的方式在集群环境下是不可用的。不同的服务是不同的Jvm,他们之间的锁监视器是不共享的,所以利用了Redis实现了一个分布式锁,也可以用Redis的Redisson分布式锁框架,结合Lua脚本来判断用户的一个秒杀资格,以及Redis的Stream流来做消息队列来进行异步下单的操作。
就这样一步一步优化,其中每一步完成都要经过大量的测试找出问题后不断优化。
5. 好友点赞和关注
5.1 好友点赞
好友点赞
好友点赞的问题应该保证一个好友一个笔记只能点赞一次。因此考虑使用Redis中的set集合实现这个功能。
具体实现方法
在Redis中建立set集合,每一个笔记(blog)对应一个set集合,set集合中保存的数据就是用户点赞的用户id,这样在用户点赞的时候就可以先查询一下是否点过赞,如果点过赞,则返回错误信息,否则则将用户id记录到set集合中。
有些情况下还会显示最近点赞的用户,我们可以修改set集合为zset,value值就是点赞的时间戳,这样倒序排列求前五个用户即可
5.2 好友共同关注
好友共同关注可以利用Redis中的集合求交集运算来实现。在Redis中保存set集合,每一个集合对应一个用户,集合里面的内容就是该用户关注用户的用户id,这样两个用户求共同关注只需要使用交集运算即可实现。
6. Feed流的实现
6.1 Feed流的实现方式
实现Feed流的三种方式
- 该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
- 推模式,也叫做写扩散。推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了。
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
- 推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。推拉模式是一个折中的方案。
-
- 站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力
- 如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去
- 现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
6.2 Feed流的实现
在本项目中实现推模式的Feed流功能。
当用户发送笔记的时候,会获取到用户所有关注人的列表,然后将用户发送的笔记投放到其粉丝的邮箱中。
当用户刷动态的时候,会从自己的邮箱中读取到笔记,然后显示。
6.3 Feed流中的难点:滚动分页
问题描述
传统的分页一般是基于下标来实现的,但是在Feed流中,因为我们需要根据动态发布实现逆序排列所有值,也就表明最新发布的动态实际上在数据的最上面,那么一旦发布新动态,那么再使用这种传统的分页方式就会重复的读取部分动态。
传统的分页查询一般通过下标即可实现。但是在feed流中,使用下标会产生问题。原因是因为feed流中的数据是不断变化的,这就会导致消息的下标也是变化的,使用传统的分页方式就会造成数据的重读。下面通过一个例子说明
- 假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10~6 这几条记录
- 假设现在t2时候又发布了一条记录
- 此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做。
解决方案:使用滚动分页方式
和传统分页不同,我们使用滚动分页,每一次记录上次访问的所有动态中时间戳最小的数据,下一次再从这个数据开始访问pagesize个动态,这样就不会受新发布的动态的影响。
此外在极端情况下,还有可能出现同一个时间戳下发布了多个动态的情况。
此外,我们还需要考虑时间戳相同的情况。虽然概率很小。
举个例子:假设现在有5条数据,时间戳分别是 5 5 5 5 4 3 3 2 ,pageSize是2
- 第一次查询 5 5 lastId = 5 size = 2
- 第二次查询 4 3 lastId = 4 size = 2
显然发生了漏读,因此针对这种情况,我们还需要设置一个偏移量,它的值就是本次查询中最小的时间戳出现的次数。
- 第一次查询 5 5 lastId = 5 size = 2 offset = 2 因为查询结果 5 5 里面最小的时间戳就是5 出现了2次
- 第二次查询,从第1个5开始(包含第一个5)往后走offset个位置的下一个位置就是本次分页的起始位置。
引入了offset以后,就解决了滚动分页查询中时间戳相同导致出漏读问题。
通过使用这种方式,就可以解决Feed流中传统分页模式失效的问题。
7. 附近商铺
使用Redis中GEO数据类型实现。将所有的商铺信息按照商铺类型在Redis中建立对应的数据,然后进行查询即可。
8. 即时通讯模块
互相关注的好友之间会建立好友关系,并可以实现即时通讯功能。
8.1 即时通讯实现的步骤
- 首先所有注册用户都在环信服务器对应一个用户名和密码,这个用户名密码和用户信息都保存在本地的数据库中
- 此外,要想实现好友之间的通讯,还需要记录用户之间的好友关系,这个可以在点击关注的代码中添加判断如果是共同好友,则将好友关系注册到环信中的代码
- 当用户登录到APP后,会从数据库中获取到对应的环信用户名和密码,然后自动登录到环信服务器
- 两个手机端都链接到环信服务器后,就可以进行实时的聊天了,聊天实际上走的是环信的服务器,和本地的探花交友服务器之间没有交互信息。
8.2 代码修改
- 用户注册:在注册用户的同时注册环信用户,并注册到环信服务器
- 用户登录:同时从数据库中获取到环信用户和密码,然后登录到环信服务器
- 用户点击关注:判断是否为共同关注,如果是,则需要注册好友关系到环信服务器
- 用户取消关注:需要删除环信服务器中的好友关系
真正的即时通讯的相关服务器是借助了环信服务器,并不走本地
9. 定时任务
在APP的后台会对每一天,每一周和每一个月的相关用户活跃数据等信息进行实时的统计并展示。这些统计运算实际上非常消耗数据库的资源,因此如果每一次点击这个统计的页面都需要从数据库中重新计算是非常消耗资源的,因此我们的解决方法是将这些统计数据单独的存放到一张数据表中,并且使用定时任务,在服务器压力相对较小的时候来计算这些数据。
这样的话,前台再次访问这些统计数据的时候,就可以直接从数据表中获取到计算好的结果,从而降低数据库压力的同时提高了程序的响应速度。
实现方式就是使用了Spring Task的定时任务,通过编写CORN表达式来控制程序的执行。
10.面试准备
1. Redis的数据持久化策略有哪些?
- RDB(Redis DataBase) RDB是通过快照实现的数据持久化,就是当满足一定条件时,Redis会fork一份当前进程的子进程,并将当前内存中的数据以二进制的形式存储到临时文件中,然后替换原来的RDB文件
- AOF(Append only file) AOF方式是通过追加的方式将Redis的写入操作命令都记录到AOF文件中,当Redis重启时,会重新执行AOF文件中的操作命令以恢复数据
RDB适合用于备份和恢复数据,并且对性能影响相对较小
AOF适合用于确保数据的完整性和持久性
2. Redis在做缓存时,关于缓存穿透、击穿、雪崩的解决策略?
缓存穿透这种情况是说当用户发送的请求的数据在缓存和数据库中都不存在,这种总会请求会到达数据库,会给数据库带来压力。
解决方案:
- 缓存空对象:当缓存和数据库中都都没有数据时,可以给缓存中缓存一个空对象返回,还可以设置过期值,防止后面数据库又有值了
- 布隆过滤器:布隆过滤器主要是用于检索一个元素是否在这个集合当中,我们当时使用Redisson实现的布隆过滤器,布隆过滤器他底层是一个bitMap的大数组,里面存放二进制的0或1,当一个Key来了之后,会对这个key进行三次Hash,然后模于数组长度找到数据的下标,然后把数组中原来的0改为1,这样通过三个数组的位置就能标明一个key的存在,查找的过程也是一样的。
布隆过滤器也有缺点,他也可能会产生误判,我们一般可以设置这个误判率,大概不会超过5%,这样一般的项目也是能接收的,不至于压倒数据库。
缓存雪崩这种情况是指Redis中有大量设置了相同过期时间的key, 而在同一时间段内同时失效,这样用户的请求全部请求到数据库,会给数据库带来很大的压力。
解决方案:
- 设置不同TTL,就是在原有的过期时间基础值上增加随机值,这样每一个缓存过期时间的重复率就会降低。
缓存击穿是 当缓存中设置了过期时间的key,在某个时候突然失效了,而这时对这个key又有大量的并发请求请求过来,这些请求会全部访问到数据库,给数据库带来很大压力,或直接宕机。
解决方案:
- 用互斥锁(分布式锁),就是说当缓存失效时,我们先去数据库做缓存重建,先使用Redis的setnx去设置一个互斥锁,当拿到锁之后再去数据库重建缓存,否则重试get缓存的方法
- 设置当前key逻辑过期,大概逻辑有一下三步:
- 在给缓存中存入key时,可以添加一个过期时间的字段,不设置过期时间,也就是永不过期
- 在get缓存时,判断存入的过期时间是否过期
- 如果没有过期,就直接返回数据,但是如果过期了,那么就去开通另外一个线程去数据库进行数据同步,当前线程也是直接返回数据,当然这个数据不是最新的
总结:这两种方案各有利弊,当要求数据强一致性时,可以使用互斥锁方案来保证,但是性能不高,因为使用了锁,锁需要等待,还可能产生死锁的问题;而如果是要求实现高可用,性能较高的情况,那就采用逻辑过期的方案。
3. redis作为缓存,mysql的数据如何与redis实现同步呢?
在我之前做的点评项目当中,用户可以购买优惠卷,我是把这个优惠卷的库存存入到缓存中的, 这个需要实时的数据同步,为了保证数据的强一致性,我采用的是Redisson的读写锁实现的数据同步
使用Redisson的读写锁呢就是说,在读的时候添加共享锁也就是读锁,其他线程可以共享读的数据,而在写的时候,添加排他锁也就是写锁,和其他线程读写都互斥,这样就保证了在写数据时,其他线程不能读到数组,避免出现脏数据。注意的点就是 读方法和写方法需要使用同一把锁才行
排他锁是如何实现的 读读互斥和读写互斥呢?
排他锁的底层使用的时setnx命令,保证了同时只能由一个线程操作锁住的方法
说说另外一种 解决方案,延时双删?
延时双删就是 如果时写操作时,先删除缓存,再更新数据库,最后 延时一会 再删除缓存中的数据,这个延时多久不太好确定,而且再延时的过程中也可能会产生脏数据,并不能保证强一致性。
4.分布式锁
在秒杀抢单的业务场景下,而且使用集群的服务架构,那么就要用分布式锁来解决不同服务之前的线程互斥问题。
实现分布式锁,可以使用Redis的setnx命令,或者使用Redisson实现的更强大的分布式锁
在这15天的实训中,我参与了一个员工考勤管理系统的开发项目。这个项目旨在帮助公司更高效地管理员工的出勤情况,减少手工处理考勤数据的工作量,并提供准确的考勤统计信息。我在这个项目中担任了开发团队的一员,负责系统的前端设计和开发。
- 项目目标与需求: 在项目一开始,我们小组与导师进行了充分的讨论,明确了员工考勤管理系统的目标和需求。我们分析了现有考勤流程的痛点和需要改进的地方,以及用户希望系统提供的功能和特性。基于这些信息,我们定义了系统的功能范围和技术要求,确保开发过程中能够有一个明确的方向。
- 技术选型: 在项目的初期,我们进行了技术选型,选择了合适的技术栈来开发系统。我们考虑到系统需要具备良好的用户界面和响应速度,因此选择了现代化的前端框架(如React或Vue.js)来构建用户界面。而后端方面,我们选择了一种适合小型应用的轻量级框架,以确保系统在处理考勤数据时具备高效性能。
- 开发过程: 在开发阶段,我们遵循敏捷开发的方法,采用迭代式开发。我们将项目拆分成若干个小任务,并通过每日的短暂会议进行进度汇报和问题讨论。这有助于我们保持团队协作的效率,及时发现和解决潜在的问题。
- 前端设计与开发: 作为前端开发人员,我负责设计系统的用户界面,包括登录页面、考勤记录展示、员工信息管理等。我通过使用UI组件库和响应式设计,为用户提供了友好且易于操作的界面。同时,我确保了界面的良好性能,避免了因加载大量数据而导致页面卡顿。
- 后端开发: 虽然我主要负责前端,但我也积极参与了后端的开发过程。我们的后端团队负责处理数据存储和逻辑处理。我了解了后端与前端之间的数据交互流程,以及如何通过API与后端进行通信。
- 测试与调试: 在开发过程中,我们进行了严格的单元测试和集成测试,确保系统的功能和数据处理的准确性。同时,我们积极进行了系统的调试和性能优化,以保证系统的稳定性和可靠性。
- 项目演示和反馈: 在实训结束前,我们向导师和其他同学进行了项目演示,并接受了他们的反馈和建议。通过这些反馈,我们得到了一些建设性的意见,帮助我们改进了系统的一些细节。
- 学习与收获: 在这个实训项目中,我学到了很多与软件开发相关的知识和技能。我熟悉了前端开发的流程和工具,掌握了React/Vue.js等前端框架的使用。同时,我也了解了与后端开发和数据库交互的基本原理。在团队合作方面,我更加理解了协作的重要性,以及如何与他人有效地进行沟通和协调。
总的来说,这15天的员工考勤管理系统实训是一次非常有意义的经历。我在这个项目中不仅巩固了自己的技术能力,还提高了团队合作和项目管理的能力。我对软件工程领域有了更深刻的理解,并对未来的职业发展充满了信心。我相信这些经历将对我未来的学习和工作产生积极的影响。
- 在这个实训项目中,我学到了很多与软件开发相关的知识和技能。我熟悉了前端开发的流程和工具,掌握了React/Vue.js等前端框架的使用。同时,我也了解了与后端开发和数据库交互的基本原理。在团队合作方面,我更加理解了协作的重要性,以及如何与他人有效地进行沟通和协调。
总的来说,这15天的员工考勤管理系统实训是一次非常有意义的经历。我在这个项目中不仅巩固了自己的技术能力,还提高了团队合作和项目管理的能力。我对软件工程领域有了更深刻的理解,并对未来的职业发展充满了信心。我相信这些经历将对我未来的学习和工作产生积极的影响。