苍穹外卖汇总笔记
苍穹外卖汇总笔记
项目包装:
总结四种包装项目的思路,分别从不同的角度来让你的项目更高大上,也可以互相组合实现,达到更加个性化的项目实现:
1.增加业务功能:这是目前网上最常见的方法,典型的有增加优惠券功能、加锁解决商品超卖功能等。但因为得改代码,其实并不是最简单的。也可以自己琢磨琢磨实际业务中可能还会遇到的用户痛点问题,实现新增的业务功能。
2.增加中间件:这也是常见的包装方法,最典型的就是加个消息队列。实际的效果很好,但是难度也很大,因为得掌握对应的中间件的使用,还得会背相应的面试题。不过也可以根据自己的实际情况去添加自己熟悉的中间件。
3.改功能(🌟):这是我最推荐的方法,具体做法就是把外卖的功能拓展或修改成其他的业务,比如改成xx买菜,xx送酒,xx买药等,因为实际的业务逻辑是相通甚至一样的,可能只需要改改前端图片或者商品描述就好,属于花小力气办大事,但实际观感和外卖项目很不一样。如果这里你能想出更棒的idea,只用稍微改改,就会让你的项目与众不同。
4.底层DIY:这里就是和你自己的研究方向去结合了。因为每个人可能研究方向都不一样,能结合出来的点便各不相同。比如我是研究区块链方向的,我在项目中用区块链作为存储,保证历史订单的透明、不可修改与可溯源,当顾客商家产生纠纷时就可以将链上信息作为证据等等(其实就是想应用场景这么改,然后考虑这么改的优点)。
-1.项目介绍
同城快送这个项目最初是我参与制作的大创项目,是一款为小型商家提供同城快送服务的软件,包含系统管理后台和小程序端两部分。其中系统管理后台主要提供给食堂内部员工使用,可以对食堂各个窗口、员工、订单、菜品等进行维护管理;小程序端可以在线浏览菜品、添加购物车、下单等,由学生兼职做跑腿送餐上门服务。这是个前后端分离的项目,我主要是负责后端的用户登录、员工管理,菜品管理,订单管理模块的编码实现。
项目亮点:
1.使用Redis,采用一主两从+哨兵的集群方案,缓存营业状态、菜品分类等信息,解决了双写一致性的问题;
2.使用工厂模式和策略模式实现布隆过滤器解决缓存穿透问题;
3.通过乐观锁解决超卖问题;
4.使用JWT令牌,用自定义拦截器完成用户认证,通过ThreadLocal来优化鉴权逻辑;
5.生成订单基于Redis,使用防重Token+lua脚本进行幂等性校验,防止重复提交。
-1 项目部署在哪里?
拆分为前后端分离的项目后,最终部署时,后端工程会打成一个jar包,运行在Tomcat中 (springboot内嵌的tomcat)。 前端工程的静态资源,会直接部署在Nginx中进行访问。
怎么打jar包?
- 使用命令
mvn package
(需要服务器安装maven环境) - 使用命令
cd target/
进入到目录就能看到打好的jar包(这里的jar包是ruoyi.jar) - 将jar包拷贝到上级目录,防止target目录清理导致jar包丢失
-1.建了哪些表?
每张表的说明:
1 | employee | 员工表 |
---|---|---|
2 | category | 分类表 |
3 | dish | 菜品表 |
4 | dish_flavor | 菜品口味表 |
5 | setmeal | 套餐表 |
6 | setmeal_dish | 套餐菜品关系表 |
7 | user | 用户表 |
8 | address_book | 地址表 |
9 | shopping_cart | 购物车表 |
10 | orders | 订单表 |
11 | order_detail | 订单明细表 |
employee表结构:
id | bigint | 主键 | 自增 |
---|---|---|---|
name | varchar(32) | 姓名 | |
username | varchar(32) | 用户名 | 唯一 |
password | varchar(64) | 密码 | |
phone | varchar(11) | 手机号 | |
sex | varchar(2) | 性别 | |
id_number | varchar(18) | 身份证号 | |
status | Int | 账号状态 | 1正常 0锁定 |
create_time | Datetime | 创建时间 | |
update_time | datetime | 最后修改时间 | |
create_user | bigint | 创建人id | |
update_user | bigint | 最后修改人id |
0.技术亮点
1.使用Nginx部署前端界面实现前后端分离,并实现反向代理和负载均衡
Nginx (engine x) 是一个高性能的HTTP和反向代理web服务器
前端页面部署到Nginx服务器中,后端代码部署到后端服务器中,使用Nginx对后端服务器进行反向代理,使用户只需要访问Nginx服务器便可获得后端服务器的服务(便于后期扩展集群,提高系统并发量)。
1.Nginx反向代理
前端请求地址:http://localhost/api/employee/login
后端接口地址:http://localhost:8080/admin/employee/login
前端请求地址后端接口地址
很明显,两个地址不一致,那是如何请求到后端服务的呢?
nginx 反向代理,就是将前端发送的动态请求由 nginx 转发到后端服务器
那为什么不直接通过浏览器直接请求后台服务端,需要通过nginx反向代理呢?
nginx 反向代理的好处:
- 提高访问速度因为nginx本身可以进行缓存,如果访问的同一接口,并且做了数据缓存,nginx就直接可把数据返回,不需要真正地访问服务端,从而提高访问速度。
- 进行负载均衡所谓负载均衡,就是把大量的请求按照我们指定的方式均衡的分配给集群中的每台服务器。
- 保证后端服务安全因为一般后台服务地址不会暴露,所以使用浏览器不能直接访问,可以把nginx作为请求访问的入口,请求到达nginx后转发到具体的服务中,从而保证后端服务的安全。
2.Nginx负载均衡
当如果服务以集群的方式进行部署时,那nginx在转发请求到服务器时就需要做相应的负载均衡。其实,负载均衡从本质上来说也是基于反向代理来实现的,最终都是转发请求。
在nginx的配置文件设置负载均衡策略。
nginx有很多负载均衡策略,比如轮询,weight权重方式,url分配方式,我们项目用的是轮询方式,共有3台后端服务器
nginx 负载均衡策略:
轮询 | 默认方式,每个请求会按时间顺序逐一分配到不同的后端服务器。 |
---|---|
weight | 权重方式,默认为1,权重越高,被分配的客户端请求就越多 |
ip_hash | 依据ip分配方式,这样每个访客可以固定访问一个后端服务 |
least_conn | 依据最少连接方式,把请求优先分配给连接数少的后端服务 |
url_hash | 依据url分配方式,这样相同的url会被分配到同一个后端服务 |
fair | 依据响应时间方式,响应时间短的服务将会被优先分配 |
http请求报文和响应报文
http报文的三个组成部分 http报文是一个格式化数据块。报文类型包括客户端请求,服务器响应。
http请求报文由3个部分组成:
请求行(start line):由请求方法(GET/POST/PUT)、请求URL(不包括域名 | 、HTTP协议版本组成
请求头(header):请求头部由关键字/值对组成,每行一对;主要包含Content-Length标头:实体的长度,Content-Tyep标头:实体的媒体类型
请求体(body) :GET(从服务器获取数据)请求体为空,POST(向服务器发送要处理的数据)有数据
http响应报文组成:
1.状态行:服务器HTTP协议版本,响应状态码,状态码的文本描述
状态码由3位数字组成,第一个数字定义了响应的类别:
1xx:指示信息,表示请求已接收,继续处理
2xx:成功,表示请求已被成功接受,处理。
3xx:重定向
4xx:客户端错误
5xx:服务器端错误,服务器未能实现合法的请求。
2.首部行:主要包含Content-Length标头:实体的长度,Content-Tyep标头:实体的媒体类型
3.实体:实体包含了Web客户端请求的对象
Apifox测试接口流程
step1.选择请求方法->填写请求url->填写url参数->填写body参数和header参数(如果有)
step2.手动发送请求
step3.查看返回参数是否正常,是否符合接口文档的约定
0.项目哪里用了设计模式?
使用工厂模式和策略模式实现布隆过滤器的大概流程如下:
- 定义布隆过滤器接口:首先定义一个布隆过滤器接口,包括添加元素和判断元素是否存在两个基本操作。
- 实现具体的布隆过滤器类:创建一个具体的布隆过滤器类,实现布隆过滤器接口中的方法。在这个类中,需要定义布隆过滤器的数据结构(比如位数组)、大小等属性。
- 定义哈希策略接口:定义一个哈希策略接口,包含计算哈希值的方法。
- 实现具体的哈希策略类:创建多个具体的哈希策略类,实现哈希策略接口中的方法,每个类对应一种哈希函数的计算方法。
- 创建布隆过滤器工厂类:定义一个布隆过滤器工厂类,其中包含一个用于创建布隆过滤器对象的工厂方法。工厂方法接受布隆过滤器的大小和哈希策略对象作为参数,并返回一个具体的布隆过滤器对象。
- 使用布隆过滤器工厂:在需要创建布隆过滤器对象的地方,调用布隆过滤器工厂的工厂方法来创建布隆过滤器对象,并传入相应的哈希策略对象。
使用工厂和策略设计模式的好处?
使用工厂模式和策略模式来实现布隆过滤器带来以下好处:
- 解耦性:工厂模式和策略模式的结合可以将对象的创建和哈希函数的选择分离,使得各部分之间的耦合度降低。这样在需要修改布隆过滤器的具体实现或者切换哈希函数时,只需要修改相应的工厂类或策略类,而不影响其他部分。
- 可扩展性:通过工厂模式和策略模式,我们可以方便地添加新的布隆过滤器实现类和哈希函数策略类,而不需要修改现有代码。这样在需要增加新的布隆过滤器类型或者新的哈希函数时,只需添加相应的类即可。
- 代码复用:工厂模式和策略模式可以提高代码的复用性。通过工厂模式,我们可以在不同地方调用工厂方法来创建布隆过滤器对象,避免重复的创建逻辑。通过策略模式,不同的哈希函数策略可以被多个布隆过滤器共享使用。
- 易于维护:将对象的创建和哈希函数的选择分开管理,使得代码结构清晰,易于维护和理解。当需要修改布隆过滤器的实现或者哈希函数时,只需修改相应的工厂类或策略类,而不会对其他部分造成影响。
1.使用工厂模式和策略模式实现布隆过滤器解决缓存穿透问题
1.什么是缓存穿透问题
缓存穿透:请求根本不存在的资源(DB本身就不存在,Redis更是不存在),这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。
使用BitMap作为布隆过滤器,将目前所有可以访问到的资源通过简单的映射关系放入到布隆过滤器中(哈希计算),当一个请求来临的时候先进行布隆过滤器的判断,如果有那么才进行放行,否则就直接拦截
2.什么是布隆过滤器?
布隆过滤器主要是用于检索一个元素是否在一个集合中。
布隆过滤器的核心思想是使用多个哈希函数来将元素映射到位数组中的多个位置上。当一个元素被加入到布隆过滤器中时,它会被多次哈希,并将对应的位数组位置设置为1
。当需要判断一个元素是否在布隆过滤器中时,我们只需将该元素进行多次哈希,并检查对应的位数组位置是否都为1,如果其中有任意一位为0,则说明该元素不在集合中
;如果所有位都为1,则说明该元素可能
在集合中(因为有可能存在哈希冲突),需要进一步检查。
3.怎么用布隆过滤器解决缓存穿透问题
使用BitMap作为布隆过滤器,使用多个hash函数对key进行hash运算,得到一个整数索引值,对位数组长度进行取模运算得到一个位置,每个hash函数都会得到一个不同的位置,将这几个位置的值置为1。
向布隆过滤器查询某个key是否存在时,先把这个 key 通过相同的多个 hash 函数进行运算,查看对应的位置是否都为 1,
只要有一个位为零,那么说明布隆过滤器中这个 key 不存在;
如果这几个位置全都是 1,那么说明极有可能存在但不是一定存在;
因为这些位置的 1 可能是因为其他的 key 存在导致的,也就是前面说过的hash冲突
什么是bitmap
bitmap是redis的一种数据类型
Bitmap 存储的是连续的二进制数字(0 和 1),本来int数字占4字节32位,但通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态(比如:01表示1,001表示2) 。,所以 Bitmap 本身会极大的节省储存空间。
# 将名为myBitmap的bitmap的第5位设置为1
SETBIT myBitmap 5 1 //SETBIT key offset value
获取位值:GETBIT key offset
java实现:redisTemplate.opsForValue().getBit(checkItem, index);
4.设置布隆过滤器的误判率
设置布隆过滤器的误判率为1%
布隆过滤器工厂接收预期数据量n和误差率p,根据上面两个数据计算出布隆过滤器的大小m(size)和哈希函数个数k。
当布隆过滤器实际的数据存储量超过预期数据量之后,误判率也会随之上涨。但是布隆过滤器是不能删除已有元素的,在这里我们采取的方案是再创建一个布隆过滤器
5.怎么用的设计模式?
使用工厂模式和策略模式实现布隆过滤器的大概流程如下:
- 定义布隆过滤器接口:首先定义一个布隆过滤器接口,包括添加元素和判断元素是否存在两个基本操作。
- 实现具体的布隆过滤器类:创建一个具体的布隆过滤器类,实现布隆过滤器接口中的方法。在这个类中,需要定义布隆过滗器的数据结构(比如位数组)、大小等属性。
- 定义哈希策略接口:定义一个哈希策略接口,包含计算哈希值的方法。
- 实现具体的哈希策略类:创建多个具体的哈希策略类,实现哈希策略接口中的方法,每个类对应一种哈希函数的计算方法。
- 创建布隆过滤器工厂类:定义一个布隆过滤器工厂类,其中包含一个用于创建布隆过滤器对象的工厂方法。工厂方法接受布隆过滤器的大小和哈希策略对象作为参数,并返回一个具体的布隆过滤器对象。
- 使用布隆过滤器工厂:在需要创建布隆过滤器对象的地方,调用布隆过滤器工厂的工厂方法来创建布隆过滤器对象,并传入相应的哈希策略对象。
使用工厂和策略设计模式的好处?
使用工厂模式和策略模式来实现布隆过滤器带来以下好处:
- 解耦性:工厂模式和策略模式的结合可以将对象的创建和哈希函数的选择分离,使得各部分之间的耦合度降低。这样在需要修改布隆过滤器的具体实现或者切换哈希函数时,只需要修改相应的工厂类或策略类,而不影响其他部分。
- 可扩展性:通过工厂模式和策略模式,我们可以方便地添加新的布隆过滤器实现类和哈希函数策略类,而不需要修改现有代码。这样在需要增加新的布隆过滤器类型或者新的哈希函数时,只需添加相应的类即可。
- 代码复用:工厂模式和策略模式可以提高代码的复用性。通过工厂模式,我们可以在不同地方调用工厂方法来创建布隆过滤器对象,避免重复的创建逻辑。通过策略模式,不同的哈希函数策略可以被多个布隆过滤器共享使用。
- 易于维护:将对象的创建和哈希函数的选择分开管理,使得代码结构清晰,易于维护和理解。当需要修改布隆过滤器的实现或者哈希函数时,只需修改相应的工厂类或策略类,而不会对其他部分造成影响。
5.使用Mysql自带的二进制日志功能,实现MySQL主从复制,Sharding-JDBC实现读写分离,减轻单台数据库读写压力,避免发生单点故障
1.为什么主从复制
刚开始系统只部署了一台服务器,读和写数据的所有压力全都由一台数据库承担,压力大,数据库服务器磁盘损坏则数据丢失,单点故障。后来使用 MySQL进行主从复制,一主一从,主库进行增删改操作,从库进行查询操作,从而减轻数据库负担。主库的数据会实时同步到从库中,实现了主库数据的备份,就算主库损毁也有备份,安全性大大提高。随着业务量的扩展、如果是单机部署的MySQL,会导致I/O频率过高。采用主从复制、读写分离可以提高数据库的可用性。本项目使用Sharding-JDBC在程序中实现读写分离(优点在于数据源完全有Sharding托管,写操作自动执行master库,读操作自动执行slave库。不需要程序员在程序中关注这个实现了。)。
主从复制实际上就是将主库的数据同步到从库数据,通过Mysql主从复制就可以实现从库中的数据和主库中的数据一致。
2.MySQL怎么主从复制
MySQL复制过程分为三步:
第一:主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中。
第二:从库读取主库的二进制日志文件 Binlog ,写入到从库的中继日志 Relay Log 。
第三:从库重做中继日志中的事件,在slave库上做相应的更改。
3.Sharding-JDBC怎么实现读写分离
对于同一时刻有大量并发读操作和较少写操作类型的应用来说,将数据库拆分为主库和从库,主库就负责处理事务性的增删改操作,从库负责处理查询操作,能够有效的避免由数据更新导致的行锁(innodb引擎支持的就是行锁),使得整个系统的性能得到极大改善。
Sharding-JDBC介绍Sharding-JDBC定位为轻量级java框架,在java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。使用Sharding-JDBC可以在程序中轻松的实现数据库读写分离,优点在于数据源完全有Sharding托管,写操作自动执行master库,读操作自动执行slave库。不需要程序员在程序中关注这个实现了。
6.使用Redis,采用一主两从+哨兵的集群方案,缓存营业状态、菜品分类等信息,解决了数据一致性问题;
1,为什么用redis
当用户数量较多时,系统访问量大,频繁的访问数据库,数据库压力大,系统的性能下降,用户体验感差。因此使用Redis对数据进行缓存,从而减小数据库的压力,在数据更新时删除缓存,从而保证数据库和缓存的一致性,同时有效提高系统的性能和访问速度。
2.redis为什么采用一主两从+哨兵的集群方案(解决高并发高可用问题)
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,可以搭建主从集群,实现读写分离。我们项目的redis采用一主二从的集群方案,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中,实现了读写分离,解决了高并发问题。普通的主从模式,当主数据库崩溃时,需要手动切换从数据库成为主数据库:,这就需要人工干预,费事费力,还会造成一段时间内服务不可用,即存在高可用问题。我们又使用了哨兵。哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送ping命令,等待Redis服务器响应,从而监控运行的多个Redis实例。哨兵可以实现自动故障修复,当哨兵监测到master宕机,会自动将slave切换成master,然后通过发布订阅模式通知其他的从服务器,修改配置文件,让它们切换master。同时那台有问题的旧主节点也会变为新主节点的从节点,也就是说当旧的主即使恢复时,并不会恢复原来的主身份,而是作为新主的一个从。
3.怎么保证Redis的高并发高可用
主从复制保证高并发,哨兵保证高可用
首先可以搭建主从集群,再加上使用redis中的哨兵模式,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知;如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主;同时Sentinel也充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端,所以一般项目都会采用哨兵的模式来保证redis的高并发高可用
4.redis主从同步流程:
主从同步分为了两个阶段,一个是全量同步,一个是增量同步
全量同步是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:
第一:从节点请求主节点同步数据,其中从节点会携带自己的replication id和offset偏移量。
第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致。
第三:在同时主节点会执行bgsave,生成RDB文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致
当然,如果在rdb生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个AOF日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步
增量同步指的是,当从节点服务重启之后,数据就不一致了,所以这个时候,从节点会请求主节点同步数据,主节点还是判断不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步
5.怎么解决数据一致性问题?
数据一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致
1.redisson读写锁解决
我们项目使用redisson实现的读写锁来解决双写一致性问题。在读的时候添加共享锁,可以保证读读不互斥,读写互斥(其他线程可以一起读,但是不能写)。当我们更新数据的时候,添加排他锁,它是读写,读读都互斥(其他线程不能读也不能写),这样就能保证在写数据的同时是不会让其他线程读数据的,避免了脏数据。这里面需要注意的是读方法和写方法上需要使用同一把锁才行。
那这个排他锁是如何保证读写、读读互斥的呢?
其实排他锁底层使用也是setnx,保证了同时只能有一个线程操作锁
2.采用旁路缓存模式
如果不想加锁的话,可以采用旁路缓存模式,先更新 db,后删除 cache
理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。举例:请求 1 先读数据 A,请求 2 随后写数据 A,并且数据 A 在请求 1 请求之前不在缓存中的话,也有可能产生数据不一致性的问题。
大概过程是:请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
1.在写数据的过程中,可以先删除 cache ,后更新 db 么?
不行,因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题。举例:请求 1 先写数据 A,请求 2 随后读数据 A 的话,就很有可能产生数据不一致性的问题。
过程如下:请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新。
2.什么是延迟双删?
延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不太好确定,在延时的过程中可能会出现脏数据,并不能保证强一致性,所以没有采用它。
2.通过乐观锁解决超卖问题
我在项目中用版本号发实现乐观锁
1.版本号法实现乐观锁
在商品表中增加一个版本号字段,每次更新库存时,都会携带这个版本号。如果版本号没有发生变化,说明在此期间没有其他线程修改过数据,更新操作可以进行;如果版本号发生了变化,则说明有其他线程已经修改过数据,此时需要重新获取最新的数据并尝试再次更新。
"UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = #{id} AND stock > 0 AND version = #{version}
//当商品的库存大于0且版本号与传入的版本号相同时,将库存减1,并将版本号加1。
2.CAS法实现乐观锁
CAS用库存量代替版本号,在执行操作时判断 where 用的库存量和在最初查询的时候,是否相等。
3.悲观锁与乐观锁
1.两者的区别
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证数据是否被其它线程修改。乐观锁最常见的实现就是CAS
。
适用场景:
- 悲观锁适合写操作多的场景。
- 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
3.什么是CAS
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS 涉及到三个操作数:
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
2.4.2 CAS的自旋
自旋: 就是不停的判断比较,看能否将值交换
在CAS操作中,自旋的概念指的是当线程发现无法立即完成操作时,不会让出CPU时间片,而是继续循环尝试,直到成功为止。这种机制可以避免线程频繁地挂起和恢复,减少了线程切换的开销,提高了效率。自旋锁通常适用于锁被占用的时间较短的场景,因为长时间的自旋会导致CPU资源的浪费。
总的来说,CAS的自旋是通过不断循环尝试来实现的一种锁优化机制,它在多线程编程中用于保证操作的原子性和提高性能。
4.CAS/乐观锁存在的问题(ABA)?
CAS 三大问题:
- ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从A-B-A变成了1A-2B-3A。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
- 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
- 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
1.生成订单的时候,基于Redis,使用防重Token和lua脚本进行幂等性校验,防止重复提交
1.什么是幂等性问题?
幂等性问题就是同一个接口,多次发出同一个请求,必须保证操作只执行一次。
解决幂等性问题有很多方法,比如:用防重token,设置提交按钮一段时间只能提交一次,使用唯一索引防止新增脏数据等等方法。
2.幂等性校验具体流程
生成订单时,为了防止重复提交,可以通过Redis结合防重Token和Lua脚本来实现幂等性校验。具体流程如下:
- 生成Token:用户发起请求时,服务端生成一个唯一的Token,这个Token可以是一个随机字符串或者包含时间戳等信息的凭证。
- 存储Token:将这个Token存入Redis中,以Token为键,可以设置一个过期时间来自动清理旧的Token,防止Redis内存溢出。
- 传递Token:将Token返回给客户端,客户端在后续的请求中需要携带这个Token,可以放在Header或者作为请求参数。
- 校验Token:服务端接收到请求后,从Redis中查询Token。这一步通常通过执行Lua脚本来完成,Lua脚本可以实现原子性的查询并删除操作,确保即使多个请求同时到达,也只有一个请求能够成功删除Token。
- 处理请求:如果Token存在且成功被删除,说明是第一次请求,服务器正常处理业务逻辑,如生成订单。如果Token不存在,说明是重复请求,服务器返回提示信息,如“请勿重复操作”。
- 删除Token:在处理完请求后,无论成功与否,都从Redis中删除该Token,避免后续的重复校验。
3.lua脚本
Lua脚本的具体写法如下:
if redis.call('exists', KEYS[1]) == 1 then
return redis.call('del', KEYS[1])
else
return 0
end
这个Lua脚本的作用是检查Redis中是否存在指定的Token,如果存在则删除该Token并返回1,表示校验通过;如果不存在则返回0,表示校验失败。
在Redis中执行Lua脚本可以使用EVAL
命令,具体语法如下:
EVAL script numkeys key [key ...] arg [arg ...]
其中,script
是要执行的Lua脚本,numkeys
是脚本中使用到的键的数量,key
是传入的键名,arg
是传入的参数。
例如,要执行上述Lua脚本,可以这样调用Redis命令:
EVAL "if redis.call('exists', KEYS[1]) == 1 then return redis.call('del', KEYS[1]) else return 0 end" 1 token_key
其中,token_key
是要校验的Token的键名。
3. 使用JWT令牌,用自定义拦截器完成用户认证,通过ThreadLocal来优化鉴权逻辑。
1.JWT登录流程
使用JWT令牌和自定义拦截器完成用户认证的流程如下:
- 用户登录时,客户端发送用户名和密码给服务器向服务器请求令牌,服务器验证用户的用户名和密码。如果验证成功,服务器生成一个包含用户信息的JWT令牌,并将其发送给客户端。
- 客户端收到JWT令牌后,将其存储在本地(例如localStorage或cookie)。
- 当客户端发起请求时,将JWT令牌添加到请求头中
- 服务器端的自定义拦截器会拦截所有请求,从请求头中提取JWT令牌。
- 拦截器解析JWT令牌,获取用户信息,并将其存储在ThreadLocal中,以避免在每次请求时都去解析JWT令牌。
- 接下来,拦截器可以检查用户是否已经通过认证。如果用户未通过认证,拦截器将拒绝请求并返回相应的错误信息。如果用户已通过认证,拦截器将继续处理请求,并将用户信息传递给后续的处理逻辑。
- 请求处理完成后,拦截器使用remove()清除ThreadLocal中的用户信息,以避免内存泄漏。
2.为什么不用Session用JWT
传统的Session用户认证方案:
- 用户向服务器发送用户名和密码。
- 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
- 服务器向用户返回一个 session_id,写入用户的 Cookie。
- 用户随后的每一次请求,都会通过 Cookie将 session_id 传回服务器。
- 服务器收到 session_id,找到对应的session并获取前期保存的数据,由此得知用户的身份。
这种传统的通过session的方式适用于前后端不分离的情况,因为session是保存在服务器端,因此对于跨域或服务器集群的情况很不友好。
为了解决传统用户认证的问题,就出现了JWT(JSON Web Token)这一种方案。可以简单的理解为session变为了保存在客户端而不是保存在服务器端了,用户每次请求都会携其内容。因此也就解决了跨域问题。
jwt的优点:
- jwt基于json,数据处理方便。
- 可以在令牌(token)中自定义内容,容易扩展。
- 使用非对称加密和签名技术,安全性高。
- 资源服务使用JWT,可不依赖认证服务即可完成授权。
jwt缺点:JWT令牌较长,占用存储空间较大。
3.jwt的组成:
它由三部分组成:header(头部)、payload(载荷)、signature(签名),以.
进行分割。(这个字符串本来是只有一行的,此处分成3行,只是为了区分其结构)
header
用来声明类型(typ)和算法(alg)。payload
一般存放一些不敏感的信息,比如用户名、权限、角色等。signature
则是将header
和payload
对应的json结构进行base64url编码之后得到的两个串用英文句点号拼接起来,然后根据header
里面alg指定的签名算法生成出来的。
4.登陆异常怎么删除jwt
1.从客户端手动删除: 如果JWT存储在浏览器的LocalStorage或SessionStorage中,可以通过浏览器的开发者工具手动删除它。 对于存储在Cookie中的JWT,可以通过浏览器设置中的隐私或Cookie选项进行删除。
- 等待Token自然过期: JWT通常有一个过期时间,一旦过了这个时间,Token将自动失效。
cookie和session的区别?
Cookie和Session都是用于在Web应用中跟踪用户状态的技术,但它们在存储位置、生命周期等方面存在一些区别。
- 存储位置:Cookie是存储在客户端浏览器中的小型文本文件,而Session是存储在服务器端的一段内存空间。
- 生命周期:Cookie的生命周期可以是长期的,甚至可以设置为几年,或者直到用户手动删除为止。相比之下,Session的生命周期通常较短,当用户关闭浏览器或者会话超时后,Session就会失效。
- 数据容量:Cookie由于存储在客户端,其容量受到浏览器的限制,一般较小(4KB左右)。Session存储在服务器端,因此可以存储更多的数据。
- 安全性:Session因为存储在服务器端,相对于Cookie来说更安全,不容易被篡改。Cookie中的信息可能会被截获或修改,存在一定的安全风险。
- 性能影响:使用Session时,每个用户的会话信息都保存在服务器内存中,如果用户数量非常多,可能会对服务器性能产生影响。而Cookie则不会直接影响服务器性能。
- 适用场景:Cookie适用于需要长期跟踪用户偏好的情况,如记住登录状态、购物车信息等。Session适用于需要在服务器端临时保存用户状态的场景,如在线考试、购物流程中等。
总的来说,Cookie和Session各有优势和适用场景。在实际开发中,开发者需要根据具体的应用需求和安全考虑来选择合适的技术。
5.ThreadLocal存储用户id
1.什么是ThreadLocal
ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。
2. ThreadLocal的实现原理
在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap
ThreadLocal 使用一个 ThreadLocalMap 来存储每个线程的变量副本,其中键为 ThreadLocal 实例,值为对应线程的变量副本。当一个线程创建一个 ThreadLocal 变量时,实际上是在当前线程的 ThreadLocalMap 中存储了一个键值对。当一个线程访问 ThreadLocal 变量时,实际上是在访问该线程自己的变量副本,而不是共享变量。这样可以保证线程之间的数据隔离,避免了线程安全问题。
3.ThreadLocal-内存泄露问题是怎么导致的?
每个线程都有⼀个ThreadLocalMap
的内部属性,map的key是ThreaLocal
,定义为弱引用,value是强引用类型。垃圾回收的时候会⾃动回收key,而value的回收取决于Thread对象的生命周期。一般会通过线程池的方式复用线程节省资源,这也就导致了线程对象的生命周期比较长,这样便一直存在一条强引用链的关系:Thread
--> ThreadLocalMap
-->Entry
-->Value
,随着任务的执行,value就有可能越来越多且无法释放,最终导致内存泄漏。
解决⽅法:每次使⽤完ThreadLocal
就调⽤它的remove()
⽅法,手动将对应的键值对删除,从⽽避免内存泄漏
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
- 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
- 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收
4.ThreadLocal具体使用流程:
每次执行请求时拦截器将用户信息用set保存在ThreadLocal中,ThreadLocal每次调用set时是一个独立的线程,当另一个用户调用ThreadLocal的set时方法 时,就会新建另一个线程,线程之间互不影响,当对应线程在调用get时候,就会请求到set时候的信息。在拦截器执行过后的方法中添加ThreadLocal线程remove()方法,这样每次请求结束后会将ThreadLocal线程中的数据删除,这样可以防止线程过多内存泄露
5.使用MD5加密方式对员工密码加密
我们项目对员工登录密码进行md5加密存储来保证安全性,食堂员工登陆的时候前端提交的密码进行MD5加密后再跟数据库中密码比对
md5原理大概是这样的:可以将MD5算法看作一台机器,计算机的任何内容(字符串,图片,视频等)被丢进去都将输出一个长度为128比特的MD5值。
因此在我们这个项目中,会将食堂用户密码进行加盐处理(密码末尾加随机的字符串)然后再进行MD5加密存入数据库。
MD5是否可逆:不可逆,原因是MD5是一种散列函数,使用的是hash算法,在计算过程中原文的部分信息是丢失了的。
6.为什么用拦截器不用过滤器、切面?
粒度更细: 拦截器可以针对特定的控制器或控制器方法进行拦截,实现精确的拦截逻辑,而过滤器是基于URL路径进行拦截,无法做到针对具体的控制器或方法,切面也是基于切点进行拦截,粒度相对较粗。
7.通过rabbitmq实现订单超时取消
可以使用RabbitMQ的延迟队列和死信队列实现订单超时取消。
- 生产者在订单生成后生成一条带有TTL(Time To Live,生存时间)的延迟取消订单消息。
- 该消息被发送到延迟队列交换机,并通过绑定的路由键投递到延迟队列。
- 在延迟队列中,消息的TTL到期后,消息会被发送到死信队列交换机。
- 死信队列交换机将消息投递到死信队列。
- 消费者监听死信队列的消息,一旦有消息,消费者会立即消费并执行取消订单的逻辑。
1.什么是延迟队列?怎么实现的?
延迟队列指的是存储对应的延迟消息,消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。
RabbitMQ 本身是没有延迟队列的,要实现延迟消息,一般有两种方式:
- 使用 RabbitMQ 的死信交换机(Exchange)和消息的存活时间 TTL(消息存活时间)。生产者将消息发送到一个特定的交换器,该交换器将这些消息路由到一个具有TTL属性的队列。当消息在队列中的时间超过设定的TTL值时,消息会被发送到死信队列。消费者可以从死信队列中获取这些消息并进行相应的处理。
- 可以使用rabbitmq_delayed_message_exchange插件:这个插件允许交换器直接支持延迟消息的发布。使用此插件,生产者可以在发送消息时指定一个延迟时间,然后交换器会在该时间过后才将消息路由到相应的队列中。这样一来,消费者只能在延迟时间过后才能接收到消息。
2.还能怎么实现订单超时取消?
除了使用延迟队列和死信队列实现订单超时取消外,还可以考虑以下几种方式:
- 定时轮询数据库:在订单表中增加一个字段记录订单的创建时间,然后编写一个定时任务(如每分钟执行一次),查询超过预定时间的未支付订单,将其标记为已取消或删除。
- 使用缓存过期机制:将订单信息存储在缓存中,并设置一个较短的过期时间。当缓存过期后,可以认为订单已经超时,此时再进行相应的取消操作。
- 消息队列延时处理:类似于上述的延迟队列方案,但可以通过消息队列自带的延时功能(如RabbitMQ的死信交换器)来实现。
- 使用优先队列:优先队列可以按照订单的创建时间进行排序,每次只处理最早创建的订单。当处理完一个订单后,再从队列中取出下一个最早的订单进行处理。
7.项目里面怎么用日志?
我们用slf4j搭配log4j日志组件来输出日志(先导入log4j的依赖,然后在类上加上@Slf4j注解,然后类里就可以用log.info()打印日志),将执行的SQL语句、参数、结果等信息记录下来。
对项目部署了 Prometheus + Grafana 监控组件(以看到CPU负载、磁盘空间占用量、接口请求量、接口响应时间)
8.你项目中怎么向前端传数据的
RESTful API:使用 HTTP 请求进行数据交换,前端可以通过 GET、POST、PUT 等方法请求服务端数据或者发送数据到服务端。