瑞吉外卖技术栈:SpringBoot、MybatisPlus、springMVC
瑞吉外卖是我做的第一个项目,算是我做过所有的项目中最简单的,很适合新手入门,我当时是学完springboot就做了这个
传智健康这个项目用到了挺多我之前没有学过的东西,比如POI制作报表、七牛云保存图片、spring security实现权限控制等,做项目的过程中是真的可以学到很多知识,实践还是很重要的。
黑马点评这个项目真的很推荐大家去做,黑马那套视频的原理篇可以等到面试前再去看,之前我对缓存击穿、缓存穿透、缓存雪崩这些概念都是一知半解的,看完这个视频,做了这个项目就有一种茅舍顿开的感觉。
短信登录:Redis共享session
商户查询缓存:缓存雪崩、穿透等
达人探店:基于List的点赞列表,基于SortedSet的点赞排行榜
优惠券秒杀:Redis的计数器、Lua脚本Redis、分布式锁、Redis三种消息队列
好友关注:基于Set集合的关注、取关、共同关注、消息推送等
附近商户:Redis的GeoHash
用户签到:Redis的BitMap数据统计功能
UV统计:Redis的HyperLogLog的统计功能
一开始,我使用的是用户的昵称作为key值保存到redis中,这样在拦截器中就无法从redis中获取到用户信息,因为在拦截器中不知道用户的基本信息,无法获取到昵称、电话号码等信息
在保存用户信息到redis中的时候,要使用token作为key值进行存储,因为在拦截器对请求进行拦截的时候,用户会携带token发起请求,这样才能够在拦截器中获取到Redis中保存的用户信息。使用hash的形式把用户信息存入redis中可以减少存储空间,也可以使用String的形式,但是需要的存储空间就比较多。
短信登录要注意验证手机号码的正确性
使用拦截器拦截用户的请求
缓存:数据交换的缓冲区,存储数据的临时地方,一般读写性能较高
浏览器缓存
应用层缓存:tomcat
数据库缓存:mysql是把数据按页加载到内存的,如果查询的是已经加载到内存中的页的数据,就不用读磁盘了
这里注意static关键字随着类的加载而被加载到内存之中,作为本地缓存,被final修饰所以其引用和对象之间的关系是固定的
缓存更新策略
当内存数据过多时,redis会对部分数据进行淘汰
内存淘汰:redis自动进行,当redis内存达到设定的max-memery的时候,会自动触发淘汰机制,淘汰一些不重要的数据
超时剔除:给redis设置了过期时间TTL之后,redis会将超时的数据进行删除
主动更新:我们手动调用方法把缓存里面的数据删掉,通常用于解决缓存和数据库不一致的问题
数据库缓存不一致问题:先操作数据库,再删除缓存,这种情况出现线程不安全问题的概率比较低。
缓存穿透: 指客户端不断请求数据库和缓存中都不存在的数据,这样这些请求都会打到数据库上面
缓存穿透常见解决方案
缓存空对象:在第一次向数据库查询不存在的数据时,向redis中存放空对象(设置数据的过期时间),这样当客户端再次发起请求时,直接将空数据返回,就不会再次请求数据库。缺点是会造成额外的内存消耗、可能造成短期的数据不一致问题
布隆过滤:采用哈希思想来解决问题,即使用一个庞大的二进制数据,走哈希思想来判断当前客户端请求的数据是否存在,如果布隆过滤器判断数据存在,则放行该请求。若布隆过滤器判断不存在则直接拒绝该请求。
布隆过滤器优点在于节约内存空间,但是存在误判的情况,因为可能会存在哈希冲突的问题。
增强id的复杂度,避免被猜测id规律
做好数据的基础格式校验
加强用户权限校验
做好热点参数的限流
缓存雪崩: 在同一时间段内大量的缓存key同时失效或redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案: 1. 给不同的key的TTL添加随机值
2. 利用redis集群提高服务的可用性
给缓存服务添加降级限流策略 4. 给业务添加多级缓存
缓存击穿也称为热点key问题就是一个被高并发访问并且缓存重建业务较复杂的key突然失效,无数请求直接访问数据库带来巨大冲击。
常见解决方案
互斥锁:线程1先查询缓存之后,去查询数据库并重构数据到缓存中,在线程1未结束缓存重构的时间段内,其他线程想要访问这个数据都会被阻塞,这方法可能会出现死锁的问题。
逻辑过期:把过期时间设置在redis的value中,这个过期时间并不会真正起作用,若线程1查询缓存发现当前数据已过期,此时线程1获得互斥锁,并且开启另外一个线程去查询数据库完成缓存重构任务后释放锁,此时的线程1直接返回旧数据,若有其他线程也来访问这个数据并发现数据过期但无法获得锁,则直接返回旧数据。这个方法是异步构建缓存,在构建完缓存之前,返回的都是脏数据。
全局ID生成器
全局唯一ID生成策略:
UUID
Redis自增
snowflake算法
数据库自增
Redis自增ID策略: 时间戳+序列号
乐观锁和悲观锁
乐观锁:认为线程安全问题不一定会发生,所以不加锁,只有在更新数据的时候去判断有没有其他线程更改了数据,若没有则正常执行,若有则不执行
悲观锁:认为线程安全问题一定会发生,所以在操作数据之前先获得锁,确保线程串行执行,例如synchronized、lock,这种方法性能差一点
一人一单
实现一个用户只能购买同一张秒杀券就必须对同一用户并发的线程进行加锁。
在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度
intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法
@Override
public Result seckillVoucher(Long voucherId) {
// 用户购买秒杀券
// 先查询秒杀券的信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if(voucher == null){
return Result.fail("不存在该秒杀券!");
}
// 先判断秒杀券是否开始卖
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀暂未开始!");
}
// 判断秒杀券是否停止售卖
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已结束!");
}
// 判断秒杀券库存
if(voucher.getStock() < 1){
return Result.fail("库存不足!");
}
// 实现一人一单,即一个用户只能购买一张相同的秒杀券
// 这里需要对每一个用户加锁,就是属于同一个用户并发的线程加锁
// 查询订单表中该用户是否已经购买过该秒杀券
// 用户必须先获得锁才能创建订单,如果不在这加锁的话,spring事务是在方法执行完之后才会被提交上去,可能会导致当前事务还没提交,锁就被释放了
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return createVoucherOrder(voucherId, voucher);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, SeckillVoucher voucher) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
Result.fail("你已经购买过该秒杀券了!");
}
boolean success = seckillVoucherService.update().setSql("set stock = stock - 1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足!");
}
// 生成秒杀券订单
VoucherOrder order = new VoucherOrder();
// 调用前面自己写的redis自增生成全局ID
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);
order.setVoucherId(voucher.getVoucherId());
order.setUserId(UserHolder.getUser().getId());
this.save(order);
return Result.ok(orderId);
}
}
分布式锁
在分布式的情况下,我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,在不同tomcat的不同线程获取的锁对象是不同的
分布式锁:满足分布式系统或集群模式下多进程可见并互斥的锁
常见四种分布式锁
Mysql:MySQL本身自带锁机制,但mysql性能本身一般
Redis:使用redis作为分布式锁,利用setnx这个方法,如果插入key成功代表获得了锁,使用redis作为分布式锁就得考虑当redis宕机了,如何实现互斥
Zookeeper:
Redis分布式锁的实现:
获取锁:
互斥:确保只能有一个线程获得锁
非阻塞:尝试一次,成功返回true,失败返回false
释放锁:
手动释放
超时释放:获取锁后添加一个超时时间
Redis分布式锁误删问题
public class SimpleRedisLock implements ILock {
// 业务的名称
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取当前线程ID
long threadId = Thread.currentThread().getId();
// Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// 第一次优化,value值是UUID+线程id组成
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, ID_PREFIX + threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 释放锁
// 第一次优化,在线程释放锁的时候,先判断这个锁是不是自己的,有可能线程1的业务执行时间太长导致自己的锁过期了redis自动帮我们删除了
// 为了避免删除其他线程的锁,我们删除锁之前先判断value是不是我们一开始设置的
// 这里value要使用UUID生成的前缀原因是在分布式下不同JVM可能产生ID相同的线程id,同一jvm下的线程id是递增的
String value = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
String threadId = ID_PREFIX + Thread.currentThread().getId();
if(threadId.equals(value)){
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
Redis实现分布式锁实现思路
利用set nx ex获取锁,并设置过期时间,保存线程标识
释放锁时先判断线程标识是否与自己一致,一致则删除锁
set nx满足互斥性
set ex保证故障时锁依然能释放,避免死锁,提高安全性
利用Redis集群保证高可用和高并发特性
要保证拿锁、比锁、删锁的原子性
Redisson
重入问题:获得锁的线程可以再次进入到相同锁的代码块中,可重入锁防止死锁,synchronized和lock都是可重入
不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
主从一致性:redis若提供主从集群,当我们向集群写数据时,主从需要异步将数据同步给从机,若主机在同步之前就宕机了,就会出现死锁的问题
redisson:是一个在redis基础上实现Java驻内存数据网络
Redisson分布式锁的原理
使用阻塞队列实现秒杀优化
思路:将下单前判断优惠券库存、用户是否有下单资格通过lua脚本去实现原子操作执行判断逻辑,前提我们在创建优惠券的同时要把优惠券的库存保存到redis中,后面使用lua表达式判断也是使用redis缓存提高速度。在lua脚本判断后,若结果是0表示用户成功抢到优惠券,这时我们要开启一个线程去完成把订单信息写入数据库的操作,即创建一个阻塞队列,每当用户抢到一个优惠券就将订单信息塞入队列中,我们还需要创建一个线程用来处理获取队列中的订单信息和创建订单的操作,创建一个线程池用于提交线程任务
// 先读取lua脚本
private static final DefaultRedisScript SECKILL_SCRIPT;
static{
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 创建阻塞队列
private BlockingQueue orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 创建异步线程池
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{
// 获取阻塞队列中的订单消息
VoucherOrder voucherOrder = orderTasks.take();
// 创建订单
handleVoucherOrder(voucherOrder);
}catch(Exception e){
log.error("订单处理异常!", e);
}
}
}
}
private IVoucherOrderService proxy;
// 处理保存订单到数据库的业务逻辑
private void handleVoucherOrder(VoucherOrder voucherOrder){
// 获取用户id,这里注意不能从threadLocal中获取用户id,因为此时是异步处理,不是一个线程
Long userId = voucherOrder.getUserId();
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
if(!isLock){
// 获取锁失败
log.error("不可重复下单!");
return ;
}
try{
proxy.createVoucherOrder(voucherOrder.getVoucherId());
}finally{
// 释放锁
redisLock.unlock();
}
}
// 优化:使用lua脚本进行判断秒杀券的库存和用户的购买资格,这里使用redis存储秒杀券的库存和购买秒杀券的用户id
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 生成全局唯一的订单号
long orderId = redisIdWorker.nextId("order");
// 执行lua脚本
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId));
int r = result.intValue();
if(r != 0){
return Result.fail(r == 1 ? "库存不足!" : "不能重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
// 保存到阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象,防止在事务未被提交之前锁就被释放
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
Integer count = this.query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
Result.fail("你已经购买过该秒杀券了!");
}
boolean success = seckillVoucherService.update().setSql("set stock = stock - 1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足!");
}
// 生成秒杀券订单
VoucherOrder order = new VoucherOrder();
// 调用前面自己写的redis自增生成全局ID
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);
order.setVoucherId(order.getVoucherId());
order.setUserId(UserHolder.getUser().getId());
this.save(order);
return Result.ok(orderId);
}
}
Redis消息队列
消息队列:存放消息的队列
消息队列:存储和管理消息(消息代理)
生产者:发送消息到消息队列
消费者:从消息队列获取消息并处理消息】
基于List实现消息队列
基于PubSub实现消息队列
基于Redis的Stream的消息队列
消费者组:将多个消费者划分到一个组中,监听同一个队列
关注和取消关注
采用将用户的点赞记录保存到redis中,使用set集合
共同关注
首先在redis中获取当前登录用户的关注列表id和目标用户的关注列表,然后使用set集合的交集得到两个用户共同关注的用户id列表
好友关注(Feed流实现)
Feed流直译为投喂,即用户发布动态之后就会把数据推送给该用户的粉丝
Feed流产品两种常见模式:
Timeline:不做内容筛选,简单按照内容发布时间排序,常用于好友或关注
采用Timeline模式三种实现方案:
拉模式
推模式
推拉模式
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容
这里我们使用推模式将用户发布的笔记推送给粉丝
需求:
修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
查询收件箱数据时,可以实现分页查询
Feed流的滚动分页:记录每次操作的最后一条,然后从这个位置开始去读取数据
GEO数据结构
GEO就是Geolocation的简写,代表地理坐标,redis允许存储地理坐标信息,帮助我们根据经纬度来检索数据
BitMap(位图)
按月来统计用户的签到信息,签到记录为1,未签到为0
把每一个bit位对应每月的每一天
Redis中是利用string类型数据结构来实现bitmap,因此最大上限是512M,转换成bit是2^32个bit位
BitMap的操作命令有:
SETBIT:向指定位置(offset)存入一个0或1
GETBIT :获取指定位置(offset)的bit值
BITCOUNT :统计BitMap中值为1的bit位的数量
BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
BITOP :将多个BitMap的结果做位运算(与 、或、异或)
BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
用户签到
把年月作为bitmap的key,每次签到就把对应位上的数字从0变成1
这里使用Java api来操作bitmap是封装在字符串的相关操作中
@Override
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
获取到当前整个月的最后一次签到数据,不停验证最后一个bit位
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。
问题3:如何从后向前遍历每个bit位?
注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
List result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1.让这个数字与1做与运算,得到数字的最后一个bit位 // 判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
}else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return Result.ok(count);
}