1.面试题-Redis 篇
1.1 Redis入门:
Redis 简介:
Redis 是一个基于内存的 key-value 结构数据库。
- 基于内存存储,读写性能高
- 适合存储热点数据(热点商品、资讯、新闻)
- 企业应用广泛
Redis 下载与安装:
下载:
安装:
Redis 服务启动与停止:
Redis 客户端图形工具:
Redis 5种常见数据类型:
Redis 各种数据类型的特点:
1.2 redis 在项目中的作用?
Redis 是高性能的,基于键值对的,写入缓存的 内存存储系统。它支持多种数据结构如字符串、哈希表、列表、集合、有序集合等,并提供了丰富的操作命令。
项目中引入 Redis 的地方是:
查询店铺营业状态 像这种店铺营业状态,本项目无非就两个状态:营业中/打样。而且它属于高频查询。只要用户浏览到这个店铺,前端就要自动发送请求到后端查询店铺状态。Redis 是基于键值对这种形式存储的,而且 Redis 也把将数据放到缓存中,而不是磁盘,有效缓解了这种高频查询给磁盘带来的压力。
缓存菜品 用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大。结果:系统响应慢、用户体验差。通过Redis来缓存菜品数据,减少数据库查询操作。
1.3redis的缓存原理,为什么速度快?
- Redis 将数据存储在内存中,相比于磁盘的数据库系统,内存存储具有更快的读写速度
- Redis 使用单线程模型来处理客户端请求,避免了多线程并发导致的线程切换开销与竞争
- Redis 内置了多种优化后的数据类型 / 结构实现,性能非常高
- Redis 使用的是非阻塞 IO 多路复用,使用了单线程来轮询描述,将数据库的开、关、读、写都转成了事件,减少了线程切换时上下文的切换和竞争。
1.4 Redis 的缓存淘汰机制是怎么样的?
Redis 的缓存淘汰(Eviction)机制是指当Redis 的内存空间不足以容纳新的数据时,Redis 将根据预先设置的策略,自动淘汰部分现有数据,为新数据腾出空间。
这个在Redis中提供了很多种,默认是 noeviction ,不淘汰任何key,但是内存满时不允许写入新数据。
- LRU(Least Recently Used,最近最少使用)LRU 会淘汰最近最久未使用的数据。当Redis 内存空间不足时,Redis 将淘汰最近最久未使用的数据,为新数据腾出空间。
- LFU(Least Frequently Used, 最不经常使用)LFU 会淘汰使用频率最低的数据。当Redis 内存空间不足时,Redis 将淘汰使用频率最低的数据,为新数据腾出空间。
- Random(随机淘汰)随机淘汰策略会随机选择一些数据进行淘汰,没有明确的淘汰规则。当Redis 内存空间不足时,Redis 会随机选择一些数据进行淘汰,并为新数据腾出空间。
- TTL(Time To Live)当数据设置了过期时间,在数据过期后,Redis 会自动将该数据从缓存中淘汰。
- Maxmemory Policy 通过配置max-memory-policy 参数来指定缓存淘汰策略,可以选择以上任意一种策略或它们的组合。
1.5 Redis的 io 多路复用是什么?
在 Redis 中,多路复用是通过select、poll、epoll这样的系统调用来实现的。
Redis 服务器通过使用这些系统调用来监听多个客户端连接,以及与其他 Redis 服务器进行通信的连接。当有连接准备好进行读写时,服务器就会立即知道并进行相应的操作。
1.6 Redis 作为缓存,MySQL 的数据如何保证与Redis 进行同步?
采用 redisson 实现的读写锁,保证了对共享资源的互斥访问。读写锁允许多个线程同时获取读锁,因此在读取共享资源时,可以实现并发读取,提高了系统的读取性能。但是当有线程获取写锁时,其他线程无法同时获取读锁,保证了操作的原子性,避免了读取到部分更新的数据。
1.7 说一下Redis 的缓存雪崩、缓存穿透、缓存击穿问题
- 缓存雪崩 在搞并发下,大量的缓存key 在同一时间失效,导致大量的请求落到数据库上,如活动系统里面同时进行着非常多的活动,但是在某个时间点所有的活动缓存全部过期。
解决方案:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
- 如果缓存数据是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
- 设置热点数据永不过期。
- 缓存穿透 访问一个不存在的key (查询 userId = -10),缓存不起作用,请求会穿透到DB,流量大时DB会挂掉。
解决方案:
- 接口层增加校验,如用户鉴权校验,id做基础校验,id <= 0的直接拦截;
- 从缓存取不到的数据,在数据库中没有取到,这时可以将key-value 对写成key-null ,缓存有效
- 缓存击穿 一个存在的key,在缓存过期的那一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。
解决方案:
- 设置热点数据永不过期
- 加互斥锁,业界比较常用的做法。简单来说,就是在缓存失效的时候(判断拿出来的值是否为空),不是立即去加载数据库,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis 的SETNX)去set一个metex key,当操作返回成功时,再进行加载数据库的操作并回设缓存;否则,就重试整个get 缓存的方法。
1.8 如何解决Redis 的缓存穿透问题
- 缓存穿透是指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。
- 使用布隆过滤器来解决缓存穿透:主要是用于检索一个元素是否在一个集合中。我们当时使用的是redisson 实现的布隆过滤器。它的底层主要是先去初始化一个比较大数组,里面存放的二进制 0 或 1。在一开始都是 0, 当一个 key 来了之后经过3次 hash 计算,模于数组长度找到数据的下标然后把数组1中原来的 0 改为 1,这样的话,三个数组的位置就能标明一个key 的存在。查找的过程也是一样的。当然是有缺点的,布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,5% 以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。
2.乐观锁和悲观锁
如果将悲观锁(Pessimistic Lock)和乐观锁(PessimisticLock 或 OptimisticLock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。
在程序世界中,乐观锁和悲观锁的最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。但是,相比于乐观锁,悲观锁对性能的影响更大!
2.1 什么是悲观锁:
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中的 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
2.2 什么是乐观锁?
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
像 Java 中java.util.concurrent.atomic
包下面的原子变量类(比如AtomicInteger
、LongAdder
)就是使用了乐观锁的一种实现方式 CAS 实现的。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升。
不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder
以空间换时间的方式就解决了这个问题。
理论上来说:
- 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如
LongAdder
),也是可以考虑使用乐观锁的,要视实际情况而定。 - 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考
java.util.concurrent.atomic
包下面的原子变量类)。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升。
不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder
以空间换时间的方式就解决了这个问题。
理论上来说:
- 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如
LongAdder
),也是可以考虑使用乐观锁的,要视实际情况而定。 - 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考
java.util.concurrent.atomic
包下面的原子变量类)。
2.3 如何实现乐观锁?
乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
2.3.1 版本号机制:
一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance
)为 $100 。
- 操作员 A 此时将其读出(
version
=1 ),并从其帐户余额中扣除 $50( $100-$50 )。 - 在操作员 A 操作的过程中,操作员 B 也读入此用户信息(
version
=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。 - 操作员 A 完成了修改工作,将数据版本号(
version
=1 ),连同帐户扣除后余额(balance
=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录version
更新为 2 。 - 操作员 B 完成了操作,也将版本号(
version
=1 )试图向数据库提交数据(balance
=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样就避免了操作员 B 用基于 version
=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。
2.3.2 CAS算法:
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS 涉及到三个操作数:
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。
- i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
- i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。
2.4 CAS算法存在哪些问题?
ABA 问题是 CAS 算法最常见的问题。
2.4.1 ABA 问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference
类就是用来解决 ABA 问题的,其中的 compareAndSet()
方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2.4.2 循环时间长开销大
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:
- 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
- 可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
2.4.3 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference
类把多个共享变量合并成一个共享变量来操作。
2.4 总结:
- 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
- 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
- CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- CAS 算法的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
3.Nginx 反向代理和负载均衡
nginx 反向代理,就是将前端发送的动态请求由 nginx 转发到后端服务器。
那为什么不直接通过浏览器直接请求后台服务器,需要通过nginx 反向代理呢?
3.1 nginx 反向代理的好处:
- 提高访问速度 因为nginx本身可以进行缓存,如果访问的同一接口,并且做了数据缓存,nginx就直接可把数据返回,不需要真正访问服务端,从而提高访问速度
- 进行负载均衡 所谓负载均衡,就是把大量的请求按照我们指定的方式均衡的分配给集群中的每台服务器。
- 保证后端服务安全 因为一般后台服务地址不会暴露,所以浏览器不能直接访问,可以把nginx 作为请求访问的入口;请求到达nginx 后转发到具体的服务器中,从而保证后端服务的安全。
3.2 正向代理:
正向代理是客户端发送请求后代理服务器访问目标服务器,代理服务器代表客户端发送请求并将相应返回给客户端。正向代理隐藏了客户端的真实身份和位置信息,为客户端提供代理访问互联网的功能。
3.3 反向代理:
反向代理是指服务器接受客户端的请求,然后将请求转发给后端服务器,并将后端服务器的响应返回给客户端。反向代理隐藏了服务器的真实身份和位置信息,客户端只知道与反向代理进行通信,而不知道真正的服务器。
4. 讲将员工登录流程
员工登录流程:
- 前端在 登录页面 登录,发送请求;(登录成功后,生成令牌)
- 进入 拦截器,拦截器放行所有登录页面的请求;
- 进入 三层架构 ,查询用户是否存在,若存在,则加密,返回JWT token,存放在请求头部。用户不存在,则不能登录。
- 当客户端需要访问资源时,通常会在请求头中携带 JWT。(后续每个请求,都要携带JWT令牌,系统在每次请求处理之前,先校验令牌,通过后,再处理)
4.1 JWT token ThreadLocal 拦截器
用户登录 ==》 认证通过 ==》生成 jwt token 返回给前端,前端发起请求时携带token,放行/不放行;
客户端发起的每一次请求都是一个单独的线程;
拦截器验证通过,将用户id 通过 ThreadLocal 放入内存,在serviceImpl 阶段使用时从内存中拿出获取用户id (在程序中我们已经将ThreadLocal 封装成一个类 BaseContext);
4.2 session 和 cookie
- Cookie 可以存储在浏览器或者本地,Session 只能存在服务器;
- session 能够存储任意的 java 对象,cookie 只能存储 String 类型的对象
- Session比Cookie更具有安全性(Cookie有安全隐患,通过拦截或本地文件找得到你的cookie后可以进行攻击)
- Session占用服务器性能,Session过多,增加服务器压力
- 单个Cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个Cookie,Session是没有大小限制和服务器的内存大小有关。
4.3 JWT(JSON Web Token)
会话技术:
会话跟踪方案对比:
JWT简介:
4.4 ThreadLocal
作用:ThreaLocal 为每一个线程提供一个单独的存储空间,具有线程隔离的作用,只有在同一个线程内才可以获得他的值,以保证线程安全。(线程之内,数据共享;线程之间,数据隔离。)
在本次开发中,在对JWT令牌进行解析,获得当前请求的用户ID 以后将该ID 保存至ThreadLocal 中,以便在之后的操作中查看当前用户。
5.Swagger作用、Knife 4j、PageHelper
5.1 Swagger
- 定义:Swagger 是一个用于设计、构建和文档化 RESTful Web 服务的工具集。
- 作用:用来在后端生成接口文档,辅助前端测试。使用 Swagger 只需要按照它的规范定义接口以及接口的相关信息,就可以做到生成接口文档,以及在线接口调试页面。
5.2 Knife 4j
Knife 4 j 是 Swagger 的一个增强工具,是基于 Swagger 构建的一款功能强大的文档工具。它提供了一系列注解,用于增强对 API 文档的描述和可视化展示。如在苍穹外卖项目中常用的 Knife 4 j 注解介绍:
- @Api:用于对 Controller 类进行说明和描述,可以指定 Controller 的名称、描述、标签等信息。
- @ApiOperation:用于对 Controller 中的方法进行说明和描述,可以指定方法的名称、描述、请求方法(GET、POST 等)等信息。
5.3 PageHelper (Mybatis 提供的插件),如何实现分页查询?
PageHelper是MyBatis的一个插件,内部实现了一个PageInterceptor拦截器。Mybatis会加载这个拦截器到拦截器链中。在我们使用过程中先使用PageHelper.startPage这样的语句在当前线程上下文中设置一个ThreadLocal变量,再利用PageInterceptor这个分页拦截器拦截,从ThreadLocal中拿到分页的信息,如果有分页信息拼装分页SQL(limit语句等)进行分页查询,最后再把ThreadLocal中的东西清除掉。
- 设置分页参数:在执行查询之前,首先通过
PageHelper.startPage(int pageNum, int pageSize)
方法设置分页的参数,调用该方法时,通过 ThreadLocal 存储分页信息。- 拦截查询语句:
PageHelper
利用 MyBatis 提供的插件 API(Interceptor
接口)来拦截原始的查询语句。MyBatis 执行任何 SQL 语句前,都会先通过其插件体系中的拦截器链,PageHelper
正是在这个环节介入的。- 修改原始SQL语句:在拦截原始查询语句后,
PageHelper
会根据分页参数动态地重写或添加 SQL 语句,使其成为一个分页查询。- 执行分页查询:修改后的 SQL 语句被执行,返回当前页的数据。
- 查询总记录数:如果需要获取总记录数,
PageHelper
会自动执行一个派生的查询,以计算原始查询(不包含分页参数)的总记录数。这通常通过移除原始 SQL 的排序(ORDER BY
)和分页(LIMIT
、OFFSET
等)条件,加上COUNT(*)
的包装来实现。- 返回分页信息:查询结果被封装在
PageInfo
对象中(或其他形式的分页结果对象),这个对象除了包含当前页的数据列表外,还提供了总记录数、总页数、当前页码等分页相关的信息,方便在应用程序中使用。
6. 什么是反射?
反射是一种在程序运行时检查和操作类的机制,通过获取类的信息并动态调用方法、创建对象等。这种机制让程序能够在运行时根据需要动态地获取和操作类的结构和成员。
- 获取 Class 对象 程序通过类的全限定名、对象的getClass()方法或 .Class 语法来获取对应的Class对象。
- 查询类信息 通过Class对象获取类的信息,包括类名、包名、父类、实现的接口、构造函数、方法、字段等。
- 动态创建对象 通过 Class 对象的 newInstance ()方法调用类的默认构造函数来创建对象,或者通过 Constructor 对象调用类的其他构造函数来创建对象。
- 动态调用方法 通过 Method 对象调用类的方法,传递参数并获取返回值。
- 动态访问字段 通过 Field 对象获取和设置类的字段值。
整个流程就是通过获取 Class 对象,然后根据需要动态地调用类的方法、创建对象、访问字段等操作,实现了对类的动态操作和调用。
7.公共字段自动填充 (涉及:枚举、注解、AOP、反射)
如果都按照上述的操作方式来处理这些公共字段,需要在每一个业务方法中进行操作,编码相对冗余、繁琐、那能不能对于这些公共字段在某个地方统一处理,来简化开发呢?
答案是可以的,我们使用AOP切面编程,实现功能增强,来完成公共字段自动填充功能。
实现步骤:
8.Spring Task 处理定时任务
Spring Task (Spring 任务调度)是Spring 框架提供的一种任务调度框架,用于执行定时任务,异步任务、任务监听、任务调度等。
应用场景:
苍穹外卖中使用Spring Task
- 通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为“已取消”
- 通过定时任务每天凌晨1点检查一次是否存在“派送中”的订单,如果存在则修改订单状态为“已完成”
9.后端如何与商家建立链接,实现实时通信?
使用 Websocket 来实现用户端和商家端通信:
WebSocket 是一种在 Web 应用程序中实现双向通信的协议。它允许客户端和服务器之间建立持久的、双向的通信通道,使得服务器可以主动向客户端推送消息,而无需客户端发送请求。
客户端和服务器之间可以实时地发送消息和接收消息,不需要频繁地发起请求。这样可以减少网络流量和延迟,并提供更好的用户体验。
应用场景:
用户下单并支付成功后,需要第一时间通知外卖商家。
来单提醒:
- 当客户支付后,调用WebSocket 的相关API实现服务端向客户端推送消息;
- 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报
- 约定服务端发送给客户端浏览器的数据格式为JSON,字段包括:type\orderId\content
- type 为消息类型, 1为来单提醒,2为客户催单
- orderId 为订单id
- content 为消息内容
用户在小程序中点击催单按钮后,需要第一时间通知外卖商家。
客户催单:
- 通过WebSocket 实现管理端页面和服务端保持长连接状态
- 当用户点击催单按钮后,调用WebSocket 的先关API实现服务端向客户端推送消息
- 客户端浏览器解析服务器推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报。
- 约定服务端发送给客户端浏览器的数据格式为JSON,字段包括:type\orderId\content
10.webSocket的作用是什么 怎么实现的 为什么要用ThreadLocal?底层原理是什么
- WebSocket 允许客户端和服务器之间建立持久的连接,可以在连接建立后双向实时地传输数据,实现实时通信功能。
- 相比传统的HTTP 请求-响应模式,WebSocket 的持久连接减少了每次通信的开销。不需要再没次通信时都重新建立连接,减少了通信的延迟和资源消耗。
- 在我们项目中就是支持用户端以及商家管理端与服务器进行双向通信。
10.1 你为什么要用ThreadLocal?底层原理是什么?
- ThreadLocal 是Java 中的一个类,用于多线程环境下保存线程局部变量。
- 它提供了一种线程安全的方式,让每个线程都拥有自己独立的变量副本,从而避免了线程间的数据共享和竞争。
- 我们使用ThreadLocal 是来存储在多个方法中需要共享的数据,具体来说就是项目中的用户Id,其他方法需要调用这个参数,我们就不用显示传递给它了。
- ThreaLocal 的底层原理是通过一个ThreadLocalMap 来实现的。在每个线程中都有一个ThreadLocalMa,用于存储线程局部变量。ThreadLocalMap 中的键是ThreadLocal 对象,值是对应线程的变量副本。当一个线程需要获取变量值时,它首先会获取自己线程的ThreadLocalMap,并根据ThreadLocal 对象获取对应的变量副本。这样就实现了每个线程都有自己的变量副本,互不影响。
10.2 Websocket 与 HTTP 有什么区别? 既然 WebSocket 支持双向通信,功能看似比 HTTP 强大,那么是不是可以基于 WebSocket 开发所有的业务功能?
HTTP 协议和 WebSocket 协议对比:
- HTTP 是短连接
- WebSocket 是长连接
- HTTP 通信是单向的,基于请求响应模式
- WebSocket 支持双向通信
- HTTP 和 WebSocket 底层都是 TCP 连接
不能使用 WebSocket 并不能完全取代 HTTP,它只适合在特定的场景下使用,原因如下:
- 资源开销:WebSocket 需要保持持久连接,对服务器资源有更高要求,不适合所有场景。
- 功能与约定:HTTP 提供丰富的功能和约定(如状态码、缓存控制),适合更广泛的业务需求。
- 安全性和兼容性:虽然 WebSocket 支持加密,但管理安全性可能更复杂;且某些环境下 WebSocket 不被支持或有限制。
- 设计和实践:RESTful API 和相关的 HTTP 设计原则不易直接应用于 WebSocket。
结论:WebSocket 并不能完全取代HTTP,它只适合在特定的场景下使用。
11.怎么保证在同时操作多张数据库表出现程序错误时保证数据的一致性?
我在涉及多表操作时使用了事务(Transaction): 将涉及到的数据库操作封装在一个事务中。在事务中,要么所有的数据库操作都成功提交,要么全部失败回滚,保证了数据的一致性。如果发生异常,可以通过捕获异常并执行回滚操作来保证数据的一致性。
具体操作:
- 在启动类上方添加@EnableTransactionManagement
- 开启事务注解之后,我们只需要在需要捆绑成为一个事务的方法上添加@Transactional
- 这样就把对两张表的操作捆绑成为了一个事务。
12.你用什么技术实现数据导出的功能的?
Apache POI 是一个处理Miscrosoft Office各种文件格式的开源项目。简单来说就是,我们可以使用 POI 在 Java 程序中对Miscrosoft Office各种文件进行读写操作。
一般情况下,POI 都是用于操作 Excel 文件。
Apache POI 的应用场景:
- 银行网银系统导出交易明细
- 各种业务系统导出Excel报表
- 批量导入业务数据
项目中的应用:(产品原型)
在数据统计页面,有一个数据导出的按钮,点击该按钮时,其实就会下载一个文件。这个文件实际上是一个Excel形式的文件,文件中主要包含最近30日运营相关的数据。表格的形式已经固定,主要由概览数据和明细数据两部分组成。真正导出这个报表之后,相对应的数字就会填充在表格中,就可以进行存档。
项目中的具体做法:
- 设计Excel模板文件
- 查询近30天的运营数据
- 将查询到的运营数据写入模板文件
- 通过输出流将Excel文件下载到客户端浏览器
13.SpringCache(底层基于cglib动态代理技术)
Spring Cache:
常用注解:
- SpringCache 是 Spring 框架提供的一个抽象层,旨在提供一种透明的方式来缓存应用中的数据。SpringCache 不是一个具体的缓存实现,而是一个集成不同缓存解决方案的接口,如 EHCache、Caffeine、Guava、Redis 等。它允许开发者通过简单的注解来控制方法的缓存行为,例如,使用 @Cacheable 来标记一个方法的返回值应该被缓存,以及使用 @CacheEvict 来标记何时移除缓存。SpringCache 为应用提供了一致的缓存视图,而开发者不需要关心具体使用哪种缓存技术。
- 简单的说:它也是一种缓存技术,使得所用工具不局限于 Redis。相比较于使用 Redis 的时候需要把相关代码内嵌到方法体种,Spring Cache 是一种基于注解方式来达到内嵌代码相同的效果。
项目中的应用:
存在问题:用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大。
结果:系统响应慢、用户体验差
解决方法:通过Redis来缓存菜品数据,减少数据库查询操作。
缓存逻辑分析:
- 每个分类下的菜品保存一份缓存数据
- 数据库中菜品数据有变更时清理缓存数据
- Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。Spring Cache 提供了一层抽象,底层可以切换不同的缓存实现,例如: EHCache、 Caffeine、Redis(常用)
为了保证数据库和Redis中的数据保持一致,修改管理端接口 DishController 的相关方法,加入清理缓存逻辑。
缓存套餐
- 导入Spring Cache和Redis相关maven坐标
- 在启动类上加入@EnableCaching注解,开启缓存注解功能
- 在用户端接口SetmealController的 list 方法上加入@Cacheable注解
- 在管理端接口SetmealController的 save、delete、update、startOrStop等方法上加入CacheEvict注解
14.AOP
AOP概念:AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范式,旨在通过将横切关注点(cross-cutting concerns)从核心业务逻辑中分离出来,以提高代码的模块化性、可维护性和复用性。
- 横切关注点 比如日志、事务、安全性等,这些关注点会横跨多个模块,导致代码重复、耦合性增加、难以维护等问题。AOP 通过将这些横切关注点抽象成一个个“切面”(Aspect),并将其独立于业务逻辑之外,以达到解耦的目的。
AOP 的核心概念包括以下几个要素:
- 切面(Aspect): 切面是横切关注点的抽象,它包含了一组横切关注点以及在何时何处应用这些关注点的逻辑。通常,切面由一组通知(Advice)和一个切点(Pointcut)组成。
- 通知(Advice): 通知是切面中具体的逻辑实现,它定义了在何时何地执行横切关注点的具体行为,包括“前置通知”(Before Advice)、“后置通知”(After Advice)、“环绕通知”(Around Advice)等。
- 切点(Pointcut): 切点是在程序中指定的某个位置,通知将在这些位置执行。切点可以使用表达式或其他方式进行定义,以便匹配到程序中的特定方法或代码块。
- 连接点(Join Point): 连接点是在程序执行过程中可以应用通知的具体位置,通常是方法调用、方法执行或异常抛出等。
- 织入(Weaving): 织入是将切面逻辑应用到目标对象中的过程,可以在编译时、加载时或运行时进行。织入可以通过源代码修改、字节码操作、动态代理等方式实现。
你的项目中有没有使用到AOP:
记录操作日志,缓存,spring实现的事务;
核心是:使用aop中的环绕通知 + 切点表达式(找到要记录日志的方法),通过环绕通知的参数获取请求方法的参数(类、方法、注解、请求方式等),获取到这些参数以后,保存到数据库。
Spring 中的事务是如何实现的?
其本质是通过AOP功能,对方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或回滚事务。
·15.代理技术
代理模式:
二十三种设计模式中的一种,属于结构型模式。它的作用就是通过提供一个代理类,让我们在调用目标方法的时候,不再是直接对目标方法进行调用,而是通过代理类间接调用。让不属于目标方法核心逻辑的代码从目标方法中剥离出来——解耦。调用目标方法时先调用代理对象的方法,减少对目标方法的调用和打扰,同时让附加功能能够集中在一起也有利于统一维护。
静态代理:
- 静态代理,主动创建代理类,将被代理的目标对象声明为成员变量,附加功能由代理类中的代理方法来实现,通过目标对象来实现核心业务逻辑。
- 静态代理确实实现了解耦,但是由于代码都写死了,完全不具备任何的灵活性。就拿日志功能来说,将来其他地方也需要附加日志,那还得再声明更多个静态代理类,那就产生了大量重复的代码,日志功能还是分散的,没有统一管理。
- 提出进一步的需求:将日志功能集中到一个代理类中,将来有任何日志需求,都通过这一个代理类来实现。这就需要使用动态代理技术了。
动态代理:
动态代理技术分类:
- JDK动态代理:JDK原生的实现方式,需要被代理的目标类必须实现接口!他会根据目标类的接口动态生成一个代理对象!代理对象和目标对象有相同的接口!(拜把子)
- cglib:通过继承被代理的目标类实现代理,所以不需要目标类实现接口!(认干爹)
代理总结:
- 代理方式可以解决附加功能代码干扰核心代码和不方便统一维护的问题!
- 他主要是将附加功能代码提取到代理中执行,不干扰目标核心代码!
- 但是我们也发现,无论使用静态代理和动态代理(jdk,cglib),程序员的工作都比较繁琐!需要自己编写代理工厂等!
- 但是,提前剧透,我们在实际开发中,不需要编写代理代码,我们可以使用Spring AOP框架,他会简化动态代理的实现!!!Spring AOP框架,基于AOP编程思维,封装动态代理技术,简化动态代理技术实现的框架!SpringAOP内部帮助我们实现动态代理,我们只需写少量的配置,指定生效范围即可,即可完成面向切面思维编程的实现!
16.Spring boot 全局异常处理器
什么是全局异常处理器:
软件开发springboot 项目过程中,不可避免的需要处理各种异常,sping mvc 架构中会出现大量的 try{...} finally{...} 代码块,不仅有大量的冗余代码,而且还影响代码的可读性。这样就需要定义个 全局统一异常处理器,以便业务层再也不必处理异常。
为什么需要全局异常
- 不用强制写try-catch,由全局异常处理器统一捕获异常。
- 自定义异常,只能用全局异常来捕获。不能直接返回给客户端,客户端是看不懂的,需要接入全局异常处理器
- JSR303规范的Validator参数校验器,参数校验不通过会抛异常,是无法使用try-catch 语句直接捕获,只能使用全局异常处理器。
原理和目标:
简单的说,@ControllerAdvice 注解可以把异常处理器应用到所有控制器,而不是单个控制器。借助该注解,我们可以实现:在独立的某个地方,比如单独的一个类,定义一套对各种异常的处理机制,然后在类的签名上注解@ControllerAdvice ,统一对不同阶段的,不同异常进行处理。这就是统一异常处理的原理。
对异常阶段进行分类,大致可以分为:进入Controller前的异常和Service 层异常。
在我们的项目中,异常处理都是通过spring的全局异常处理器来实现的,核心是两个注解:
- 一个是@RestControllerAdvice,标注在类上,可以定义全局异常处理类对异常进行拦截
- 一个是@ExceptionHandler,标注在异常处理类中的方法上,可以声明每个方法能够处理的异常类型
在我们的项目中,将异常分为了三大类:
在苍穹外卖项目的全局异常处理器中一般定义三种异常:
- 第一类是指定异常,指定异常指的是用户操作产生的与程序设计相关的异常,比如说字段重复异常、Validation校验异常等等,这类异常捕获之后,我们会根据异常的消息提示,给前端一个确定的返回结果
- 第二类是业务异常处理,业务异常是由于用户不正当操作产生的与业务相关的的异常,这种异常往往需要我们自定义,然后在程序的相关位置手动抛出,在抛出的时候还会指定异常提示信息。然后异常处理器捕获之后,直接将异常提示消息返回给前端
- 第三种异常是兜底异常,此处主要捕获的是不属于上面两种异常的异常,一般是一些程序员代码不够严谨引发的运行时异常,对于这些异常,我们处理方案是首先要把错误记录到日志系统中,然后给前端一个类似于服务器开小差了之类的统一提示
17. 项目是如何存储文件的
在我使用过的文件存储中,主要有三类存储方式
- 直接将文件保存到服务到硬盘,这种方式操作方便,但是扩容困难,而且安全保障不高,现在基本不再使用
- 使用一些付费的第三方存储服务,比如阿里云或者七牛云,这种方式无需自己公司提供服务器和相关软件,并且安全性和扩展性都不需要自己再进行考虑,但是不适合存储一些敏感文件
- 将文件保存在公司自己搭建的一些分布式系统中,比如一些公司用过MinIO和FastDFS,它需要我们自己准备服务器,安装维护软件,好处是文件都存放在自己的服务器上,隐私性比较好
至于如何选择服务器,我认为目前主要考虑的就是分布式文件存储系统和第三方服务
- 如果文件是隐私性比较高,建议使用自己搭建的分布式文件存储系统
- 如果文件隐私性不高,可以考虑使用第三方服务
我们项目中主要存储的文件是一些菜品或者套餐的图片,不涉及什么隐私问题,所以选择了阿里云服务
18.苍穹外卖的模块
苍穹外卖大方向上主要分为管理端和用户端
管理端使用vue开发,主要是商家来使用,提供餐品的管理功能,主要有下面几个模块:
- 员工模块,提供员工账号的登录功能和管理功能
- 分类、菜品、套餐模块,分别对分类、菜品和套餐进行增删改查和启用禁用
- 订单模块,可以搜索和查看订单,变更订单状态
- 统计模块,统计营业额、用户、订单和销量排名,还有Excel报表导出功能
- 工作台模块,提供今日运营数据数据以及订单、菜品、套餐的总览
用户端使用微信小程序开发,主要是给用户提供点餐功能
登录模块,调用微信小程序登录接口实现登录功能
菜品、套餐模块,用于查询菜品、套餐的信息
购物车模块,在购物车中添加或删除套餐菜品
订单模块,提供下单、微信支付、查询订单、取消订单、再来一单、催单功能。