这是一个模拟了高并发场景的商城系统,它具备秒杀功能。我结合秒杀项目的【高并发】【高一致性】【高可用】三个特性,分别制定了一系列的解决方案。
在前端层面上:
分别对【秒杀页面渲染】、【安全性校验】、【削峰限流策略】进行了秒杀系统设计的优化。
层面 | 解决方案 |
---|---|
在秒杀页面渲染上 | 进行动静分离,将静态资源缓存至浏览器(最佳是CDN节点),用户信息等动态资源通过服务端异步的方式加载,同时结合FreeMarker进行页面动态渲染,前后端分离以降低服务端的压力,加快用户访问速度。 |
在安全性问题上 | 使用双重MD5密码校验,隐藏秒杀接口地址,设置秒杀令牌机制,根据IP访问频率设置接口防刷。 |
在限流削峰策略上 | 通过Nginx进行最大连接数的限制,同时在用户下单时设置数学公式验证码,分散同一时刻下单的用户数量。 |
在服务端层面上:
针对三高的问题,分别在【缓存预热】、【削峰限流】、【安全性校验】、【下单逻辑】上进行优化,保证服务端能够高效且安全的处理秒杀请求。具体的:
层面 | 解决方案 |
---|---|
在缓存预热上 | (1)系统初始化时,利用定时任务或者基于Bean的生命周期初始化,将商品信息及库存数量,以及秒杀令牌预先加载到Redis中做缓存预热,通过redis的单线程特性及其内存读取,保证线程安全的同时,提高响应速度。 |
在削峰限流上 | (2)为了保证高并发下的一致性问题,首要就要进行削峰限流,避免大量用户同一时间操作数据库导致数据不一致。直接对数据库加排他锁,会导致秒杀系统的吞吐量非常低,系统响应慢,用户购物体验差。 【秒杀令牌】 针对数据库直接加锁的吞吐量低下的问题,采用 秒杀令牌 的方式进行解决,在秒杀活动开始时,用户下单时服务端首先会为用户生成秒杀令牌(skill_token),然后存储到redis中。然后下单时,与redis中存储的令牌进行比对,比对成功后才能访问进行下单,否则返回秒杀令牌校验失败。在削峰的同时还避免了脚本恶意抢购。但是秒杀令牌只要活动一开始就可以无限制的生成,生成的海量令牌会影响秒杀系统的性能; 【秒杀大闸】 针对无限制生成秒杀令牌的问题,采用 秒杀大闸 的方式进行解决。具体的,根据redis中秒杀商品的数量,提前设定秒杀令牌的数量(一般为商品的数倍),用户下单时。服务端为其生成秒杀令牌,首先获取该商品对应秒杀令牌的剩余数量,若还有剩余令牌,则为其生成令牌。若剩余数量为0,则无法生成令牌,返回抢购失败。就能一定程度上的解决无限制生成秒杀令牌的问题。但是当秒杀商品库存特别多的时候,以数倍的方式发送令牌依然会造成大量秒杀请求的涌入,数据库依然会承受很大的瞬时压力。 【队列泄洪】 针对多商品、多库存下的瞬时流量涌入问题,采用 队列泄洪 的方式解决。依靠排队和下游拥塞的程度调整队列释放流量的大小。比如支付宝银行网关队列,虽然支付宝能够支持大并发,但是支付是在银行的数据库上操作的,银行支持不了如此高的并发量。因此,支付宝会调整释放流量的大小给银行处理,保证在银行能够支持的并发量能力范围内。因此,我们可以借鉴这种解决方案。 方法一: 利用线程池实现队列泄洪: 针对秒杀请求创建一个线程数固定,阻塞队列大小为(秒杀商品总数量 - 核心线程数)的线程池,使得线程池同一时间只能处理固定数量的请求,其它的请求放在队列中等待,从而实现队列泄洪。保证打到数据库上的请求量,都在数据库的并发处理范围内。方法二: 利用令牌桶算法来实现队列泄洪 ,导入 guava limiter 依赖包,设置服务器每秒处理的请求数量,在用户下单时校验,若服务器处理不了就返回秒杀失败或者正忙。方法三: 利用redis的分布式队列等实现队列泄洪,当队列出现问题时,采用降级的方式切回本地内存队列。 通过【秒杀令牌】+【秒杀大闸】+【队列泄洪】机制进行削峰限流后,就能够很大程度上的减轻服务端的压力,从而提高秒杀系统的吞吐量。 |
在安全性校验上 | (3)服务端收到收到秒杀请求后,首先验证秒杀是否合法(秒杀时间 / 秒杀数量 / 秒杀令牌)。具体的,就是基于Lua脚本或者利用redis中的原子性increase命令,进行预扣库存,当Redis中的库存数量不足时,直接返回秒杀失败,并将预扣的库存加回来,防止超买超卖问题; (4)同时,为了解决用户刷新页面时,订单重复生成的问题。规定用户一旦秒杀成功,就利用SETNX原子性操作将“userId_goodsId”放到redis中占坑,并根据秒杀规则设置token的过期时间(比如一场秒杀只能参与一次,token过期时间就设置到秒杀活动结束),token设置成功后,才能继续参与秒杀活动。若SETNX失败,则返回秒杀失败。 |
在数据库层面的下单逻辑上 | (5)经过上述的缓存预热、削峰限流、安全性校验等步骤后。用户可以下单成功,针对高并发下的库存一致性问题。通常有两种解决方案,一种就是付款减库存,但是这种模式下很有可能出现用户明明已经下单成功,却显示没有库存的问题。另一种就是就是下单减库存,能够保证下单成功的用户能够付款成功。 我的项目是采用的【下单减库存 + MQ异步通知型事务】的方式来实现订单与库存的最终一致性。创建订单时首先远程调用库存系统进行锁库存,锁库存的SQL语句通过版本号的机制(update自带的排他锁)来实现,若库存数量锁定后小于0,则锁定失败,返回秒杀失败,从而在数据库层面上解决超买超卖问题。库存锁定成功后,立刻向库存服务交换机发送一条解锁库存的消息,防止库存刚一锁定成功,订单系统就宕机了,从而确保库存一定能够释放。 库存锁定成功后,通过Feature异步的方式进行封装订单,同时将订单消息发送到订单系统交换机,路由到30min的延时队列,订单消息过期后经过死信路由器转发给订单系统,判断该订单的支付状态。若支付则完成收单,若未支付则向库存服务的交换机发送释放库存的消息, 保证库存能够顺利回滚,解决少卖问题。 |
最后通过Jmeter压力测试,秒杀系统的QPS从初始的120/s提升到5000/s。
概述 | 描述 |
---|---|
原理 | (1)秒杀接口需要依靠随机码(准入令牌)才能进入,随机码是在商品缓存预热的时候已经设置好了。当用户去刷新秒杀页面时,即调用getCurrentSeckillSkus()时,服务端会进行秒杀时间段的校验,如果秒杀时间段符合,才会为用户颁发随机码,用于下单时拿着这个随机码,就可以去访问秒杀接口进行下单(从session中获取随机码)。如果不在秒杀时间段内,则不为用户返回随机码,防止在秒杀前脚本恶意抢购的风险。 (2)秒杀的令牌由秒杀活动模块负责生成 (3)秒杀活动模块对秒杀令牌生成全权处理,逻辑收口 (4)秒杀下单前需要先获得秒杀令牌 |
作用 | (1)秒杀令牌只有在秒杀开始后才会生成,在有秒杀令牌后才能抢商品,所以就避免了提前通过token、itemId、promoId和url的脚本抢商品。 (2)验证代码放在生成令牌中,与下单代码隔离开,实现低耦合。 |
秒杀系统中存在的问题 | 解决方法 |
---|---|
超买超卖问题 | 1、秒杀系统中如何解决超买超卖问题的 |
少买问题 | 2、秒杀系统如何解决少卖问题? |
重复下单问题 | 3、秒杀系统中如何解决重复下单问题? |
缓存的三大问题 | 4、热点数据失效(缓存击穿)问题如何解决? |
数据库缓存不一致 | 5、缓存和数据库之间的一致性如何保证? 6、库存扣除成功了,订单生成失败了,怎么办? |
分布式线程安全问题 | 7、多机器扣减库存,如何保证线程安全? |
脚本恶意抢购 | 8、如何解决客户的恶意下单问题? |
高并发 | 9、限流削峰策略 |
解决层面 | 描述 |
---|---|
Redis缓存层面 | 利用redis的单线程特性预减库存处理秒杀超卖问题! (1)在系统初始化时,将商品以及对应的库存数量预先加载到Redis缓存中; (2)服务端收到到秒杀请求时,在Redis中基于Lua脚本进行预减库存 (商品数量减到负数后再加回来,保证减增商品数量的操作是原子性);当Redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步; (3)将请求放入异步队列中,返回正在排队中; (4)服务端异步队列(MQ)将请求出队,出队成功的请求可以生成秒杀订单,减少数据库库存,返回秒杀订单详情。 |
数据库层面 | 借助版本号机制,依靠MySQL中的排他锁,利用SQL语句避免超买超卖问题; updata table set num = num - #{cnt} where id = #{id} and num > #{cnt}; |
方法 | 描述 |
---|---|
MQ异步通知型事务实现库存数量的最终一致性 | 利用【下单减库存】 + 【MQ的延时队列】,实现订单消息的延时确认及库存回滚操作,保证商品库存的最终一致性。具体地: (1)创建订单时,首先远程调用库存服务的接口减库存。 (2)库存系统扣除对应商品库存后,发送一条延时释放库存的消息到MQ,等待45min后回滚库存。这是为了避免库存系统刚扣完库存,订单系统就炸了,导致库存迟迟加不回来,从而出现少买问题。 (3)回到订单系统,锁定完库存后,订单创建成功,同时发送一条延时订单确认的消息到MQ,等待30min后确认消费。30min后检查订单状态:若是已支付状态,则完成收单。若是未支付或者取消状态,则进行关单,同时发送一条释放库存的消息给MQ,进行库存回滚,防止少卖问题。 |
方法 | 描述 |
---|---|
唯一索引 + SETNX命令 | 利用【唯一索引:“userId_goodsId”】+【redis的SETNX命令占坑】,保证秒杀接口的幂等性 为了解决订单重复下单的问题。规定用户一旦秒杀成功,就利用SETNX原子性操作将 “userId_goodsId” 放到redis中占坑,并根据秒杀规则设置token的过期时间(比如一场秒杀只能参与一次,token过期时间就设置到秒杀活动结束),token设置成功后,才能继续参与秒杀活动。若SETNX失败,则返回秒杀失败。 |
阶段 | 解决方法 |
---|---|
秒杀活动–前期 | (1)设置秒杀商品的缓存永不过期 |
秒杀活动–中期 | (2)分布式互斥锁:缓存中为数据为空时,通过互斥锁来控制访问数据库的线程数量,某个线程抢到了互斥锁后就更新缓存,其它线程就可以在缓存中查到数据了。 (3)熔断机制与服务降级:当流量达到一定阈值,直接返回"系统拥挤",防止过多的请求打到数据库上。以此保证秒杀系统的部分可用性。 |
秒杀活动–后期 | (4)开启redis持久化(AOF/RDB),当然秒杀系统不建议开启,这只是常规解决方案。 |
方式 | 描述 |
---|---|
引入canal组件 | canal组件可以使得服务直接监听MySQL的binlog日志,将自己伪装成M有SQL的从库,只负责接收数据而不做其它处理。使得redis缓存可以实时的与数据库保持同步。 |
先更新数据库立即删除缓存 | 更新数据库后立即删缓存,然后下一次查缓存找不到数据后会再次从数据库同步到缓存。同时,为了避免缓存删除失败的情况,采用订阅MySQL的binlog日志的方式(canal组件),进行缓存删除失败的补偿,确保缓存一定能够删除成功。 |
场景 | 解决方式 |
---|---|
非分布式场景下 | 使用Spring提供的事务功能即可 |
分布式场景下 | 将【扣除库存】与【生成订单】操作组合为一个事务。分配全局事务唯一ID,要么一起成功,要么一起失败。常见的分布式事务协议有如下: (1)全局消息;(2)MQ异步通知型事务;(3)TCC协议;(4)最大努力通知;(5)2pc协议 |
采用分布式锁的机制,保证线程安全。常见的分布式锁技术如下:
方式 | 描述 |
---|---|
redis实现分布式锁 | (1)LUA 脚本:(SETNX命令 + expire键过期) + watch Dog机制 + LUA (删除时判断锁是不是自己要删的那把) (2)Redisson框架: 提供了显式lock锁、unlock锁等命令,以及分布式信号量等机制; |
Zookeeper实现分布式锁 | (3)基于Zookeeper的临时顺序节点: 监听前一个zk节点(序号最小)的状态,当前一个删除后,若当前节点的序号是最小的,则获取锁。 |
层面 | |
---|---|
前端限制 | (1)用户下单时输入数学公式验证码,增加购买复杂性。 (2)一次下单后,按钮置灰几秒钟。 (3)封IP地址,Nginx可以设置单个IP访问频率和次数多了之后进行拉黑 |
后端限制 | (1)秒杀接口需要依靠随机码(准入令牌) 才能访问,随机码是在商品缓存预热的时候已经设置好了(每个商品都有对应的随机码)。当用户去刷新秒杀页面时,即调用getCurrentSeckillSkus()时,服务端会进行秒杀时间段的校验,如果秒杀时间段符合,才会为用户颁发随机码,用于下单时拿着这个随机码,就可以去访问秒杀接口进行下单(从session中获取随机码)。如果不在秒杀时间段内,则不为用户返回随机码,防止在秒杀前脚本恶意抢购的风险。(2)而且下单时,还要校验当前用户是否拥有秒杀令牌(非随机码)。秒杀令牌由秒杀活动模块负责生成,切秒杀活动模块对秒杀令牌生成全权处理,逻辑收口。 (3)由于秒杀令牌的设置,用户没有令牌,就要先去获得令牌(秒杀大闸导致了秒杀令牌的数量上线)。用户有令牌的话直接返回 “正在抢购中”,不允许再下单。 |
层面 | 限流方式 |
---|---|
前端限流 | (1)通过Nginx设置每秒最大连接数。 (2)在用户下单时设置数学公式验证码,分散同一时刻下单的用户数量。 |
服务端限流 | 通过【秒杀令牌】+【秒杀大闸】+【队列泄洪】的机制去削峰限流; (1)在秒杀活动开启前,提前将商品信息缓存到redis中做缓存预热。并生成5倍商品数量的秒杀令牌,存入redis中; (2)用户下单需要先从redis中获取秒杀令牌后,才有资格参与抢购秒杀。 (3) 经过秒杀令牌+秒杀大闸的处理后,已经能够阻挡住大部分的请求了。但是在多商品、多库存下的场景下,依然会存在瞬时流量涌入问题,针对于此项目采用队列泄洪的方式解决。 比如支付宝银行网关队列,虽然支付宝能够支持大并发,但是支付是在银行的数据库上操作的,银行支持不了如此高的并发量。因此,支付宝会调整释放流量的大小给银行处理,保证在银行能够支持的并发量能力范围内。因此,我们可以借鉴这种解决方案。 方法一: 利用线程池实现队列泄洪 :针对秒杀请求创建一个线程数固定,阻塞队列无界的线程池(即FixedThreadPool线程池),使得线程池同一时间只能处理固定数量的请求,其它的请求放在队列中等待,从而实现队列泄洪。保证打到数据库上的请求量,在数据库的并发处理范围内。方法二: 利用令牌桶算法来实现队列泄洪 ,导入 guava limiter 依赖包,设置服务器每秒处理的请求数量,在用户下单时校验,若服务器处理不了就返回秒杀失败或者正忙。方法三: 利用redis的单线程分布式队列 等实现队列泄洪,当redis队列出现问题时,采用降级的方式切回本地内存队列。通过【秒杀令牌】+【秒杀大闸】+【队列泄洪】机制进行削峰限流后,就能够很大程度上的减轻服务端的压力,从而提高秒杀系统的吞吐量。 |
方式 | 描述 |
---|---|
原子性指令 | decrement API减库存,increment API回增库存。以上的指令都是原子性的。 |
Lua脚本 | 利用Lua脚本,如果扣减到负数,则加回来。两个操作是原子性的。 |
搭建redis集群,分为主从模式、哨兵模式。
模式 | 描述 |
---|---|
主从模式中 | 如果主机宕机,使用slave of no one 断开主从关系,并且把从机升级为主机。 |
哨兵模式中 | 自动监控master / slave的运行状态,基本原理是:心跳机制 + 投票裁决。 (1)每个sentinel会向其它sentinel、master、slave定时发送消息(哨兵定期给主或者从和slave发送ping包(IP:port),正常则响应pong,ping和pong就叫心跳机制),以确认对方是否“活”着,如果发现对方在指定时间(可配置)内未回应,则暂时认为对方已挂(所谓的“主观认为宕机” Subjective Down,简称SDOWN)。 (2)若master被判断死亡之后,通过选举算法,从剩下的slave节点中选一台升级为master。并自动修改相关配置。 |
应用 | 描述 |
---|---|
分布式锁 | 多台机器抢购商品,通过redis的分布式锁保证并发安全。 (1)LUA 脚本:(SETNX命令 + expire键过期) + watch Dog机制 + LUA (删除时判断锁是不是自己要删的那把) (2)Redisson框架: 提供了显式lock锁、unlock锁等命令,以及分布式信号量等机制; |
缓存预热 | 提前将秒杀商品的信息存储到redis中,作为缓存中间件,减轻数据库访问压力。 |
预减库存 | 内存读取,提高服务器响应速度,且redis的单线程模型天然具备分布式特性。 |
分布式会话 | 通过redis + token机制实现分布式会话。 |
秒杀令牌 | 将秒杀令牌存到redis中,进行限流。 |
解决重复下单 | 利用【唯一索引:“userId_goodsId”】生成的token令牌+【redis的SETNX命令占坑】,保证秒杀接口的幂等性。用户一旦秒杀成功,则取redis中进行占坑。占坑失败则表示已经购买过。 |
解决MQ消息重复性消费 | 消息一旦消费后,则去redis中进行占坑。如果设置失败,则表示消息已经消费过,消费者直接ACK不做其他处理。 |
应用 | 描述 |
---|---|
MQ异步下单 | 利用队列排队异步下单,加快响应速度,减轻数据库的并发压力 |
MQ延时队列 | 延时队列实现用户下单等待30min |
MQ通知型事务 | 设计库存回滚逻辑,保证商品库存最终一致性 |
类型 | 核心线程数取值 |
---|---|
CPU密集型业务 | N+1 |
IO密集型业务 | 2N+1 |
基础架构下的tps是120/s,经过动静分离、nginx反向代理做分布式扩展后,通过秒杀系统的设计过后,TPS达到了5400/s;
多台设备登录属于单点登录(SSO)问题,用户登录一端之后另外一端可以通过扫码等形式登录。虽然用户登录了多台设备,但是用户信息是一样的。项目中为同一用户颁发的token是相同的,因此多台设备登录时只会颁发一个token。
表结构 | 描述 |
---|---|
秒杀用户表 | 持有秒杀令牌的用户信息 |
商品信息表 | 记录商品信息 |
秒杀商品表 | 记录该商品的秒杀始末时间,秒杀价和剩余数量等信息 |
秒杀订单表 | 记录秒杀用户名和秒杀的商品还有订单号 |
订单详情表 | 通过秒杀订单号来查找对应的订单详情,里面记载更详实的业务信息 |
限流削峰 | 描述 |
---|---|
利用线程池实现队列泄洪 | 针对秒杀请求创建一个线程数固定,阻塞队列大小为(秒杀商品总数量 - 核心线程数)的线程池,使得线程池同一时间只能处理固定数量的请求,其它的请求放在队列中等待,从而实现队列泄洪。保证打到数据库上的请求量,都在数据库的并发处理范围内。 |
参数 | 描述 |
---|---|
corePoolSize (核心线程数) |
当提交一个任务时,线程池会创建一个新线程执行任务,此时线程不会复用。如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行。此时如果核心线程有空闲,回去阻塞队列中领取任务,此时核心线程复用。 |
maximumPoolSize (最大线程数) |
线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,此时若当前线程数小于maximumPoolSize,则可以创建新的线程(此时就是属于非核心线程)执行任务, |
keepAliveTime (非核心线程存活时间) |
当线程池中的线程数量大于corePoolSize时,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime才会被销毁,最终会收缩到corePoolSize的大小。 |
TimeUnit | keepAliveTime的时间单位 |
workQueue (阻塞队列) |
用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列: ① ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务 ② LinkedBlockingQuene:基于链表结构的无界阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene; ③ SynchronousQuene:一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。 ④ priorityBlockingQuene:具有优先级的无界阻塞队列; ⑤ DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素; |
handler (拒绝策略) |
线程池的拒绝策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种拒绝策略: ① AbortPolicy:直接抛出异常,默认策略; ② CallerRunsPolicy:用调用者所在的线程来执行任务; ③ DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务(最老的任务),并执行当前任务; ④ DiscardPolicy:直接丢弃任务,不报错; |
ThreadFactory (线程工厂) |
创建线程的工厂,可以设定线程名、线程编号等;默认使用 Executors.defaultThreadFactory() 来创建线程 通过自定义线程工厂,使用Thread.setDefaultUncaughtExceptionHandler()方法可以捕获线程池中的异常 |
步骤 | 方法 | 描述 |
---|---|---|
Step1 | 执行execute(任务) / submit(任务) |
① 调用ctl.get() 方法,获取当前线程池状态(Running-Shutdown-Stop-Tidying-Terminated ),若当前线程池状态不是Running状态 或者为Shutdown状态但是工作队列已经为空 ,那么就直接返回,执行失败;② 调用 workerCount() 方法,获取当前【工作线程数】;基于此判断是执行拒绝策略还是执行addWorker()方法 ;③ 当 【工作线程数 > 核心线程数】 并且 【阻塞队列未满】 ,那就向阻塞队列中添加任务;添加成功后,再次调用ctl.get() 方法判断线程池当前状态;若此时线程池状态不是Running ,则删除任务并执行拒绝策略 ; |
Step2 | addWorker(任务,boolean)方法执行时机; 传入true用核心线程执行任务,false用非核心线程执行任务 |
① 当【工作线程数 < 核心线程数】 那就调用addWorker(任务,true) ,创建一个核心线程 去执行任务;② 若任务已经添加到了阻塞队列中,但是此时 工作线程数为0 ,那么调用addWorker(null,false) ,创建一个没有绑定任务的非核心线程 ,当runWorker()中的getTask()方法获取到任务后,会将阻塞队列中的任务置换到Worker对象中的null任务;③ 若任务 【工作线程数 > 核心线程数】 并且 【阻塞队列已满】 ,那么也调用addWorker(任务,true) 方法; |
Step3 | 执行addWorker(任务,boolean) 方法,返回值是boolean; 任务执行成功为true,执行失败则为false; |
① 首先调用wokerCount() ,获取当前线程池工作线程数量,若【工作线程数 > 线程池最大容量】 ,则返回false,并执行【拒绝策略】 ;② 如果没有执行拒绝策略,那么正常执行;创建一个 Worker对象 ,并与当前要执行的任务绑定 【即new Worker(任务)】,将这个Worker对象添加到Workers容器(HashSet) 中;若添加成功则在addWorker()的最后调用了Worker中任务的start() 方法,使得任务进入就绪状态 ,等待run()方法执行;③ Worker对象继承了AQS并实现了Runnable接口,因此可以当作一个并发安全的线程使用,线程池中真正工作的线程就是绑定了任务的Worker线程; |
Step4 | 分配一个线程执行run() 方法,底层调用的是runWorker(Worker w) 方法其中还有一个 getTask() 方法 |
① runWorker(Worker w) 方法取出Worker对象中封装的Runnable任务 ;② 若 任务为null ,那么就while死循环的调用getTask()方法,从阻塞队列中获取任务 ;③ getTask() 方法通过调用workcount() 方法获取当前线程池的工作线程数量,判断当前线程是核心线程还是非核心线程 ;1) 若是非核心线程:那么就调用workQueue.poll(keepAliveTime) 方法,在存活时间内从阻塞队列中获取任务,若超过了keepAliveTime还没有获得任务 ,则该非核心线程就被回收掉 ,这就是【非核心线程被回收的原理】 ;2) 若是核心线程:那么就调用workQueue.take() 一直从阻塞队列中获取任务,直到获取成功,没有期限 ;【这也是为什么核心线程能够不被回收的原理】 ;④ 获取到任务后,Worker对象内部的空Runnable任务会置换为阻塞队列中获取的Runnable对象,来达到线程复用的效果;任务不为null之后,那么就直接执行接下来的逻辑;首先执行 beforeExecute() 方法,做一些线程执行任务之前的工作;然后执行任务.run() ,开始真正的执行任务;最后在执行afterExecute() 方法,做一些线程执行任务完的一些工作;⑤ 当任务执行结束后,就会从 Workers容器中移除 ; |
无效,会从redis中删除。当该用户再去秒杀抢购时,需要重新获取秒杀令牌。
jstat -gc vmid count
jstat -gc 12538 5000 // 表示将12538进程对应的Java进程的GC情况,每5秒打印一次
…其它JVM问题待补充
难点 | 描述 |
---|---|
问题 | 在单机环境下时,由于HTTP协议是无状态的,导致服务器无法记录我们的登录状态,此时我们是可以用【cookies】+【session】解决的。 但是进行分布式扩展后,会发现我们在已经登陆后,分别访问不同的微服务时,系统依然提示我们去登录。 |
原因 | 针对这个问题,我查阅了相关资料。发现session的本质实际上时tomcat为我们创建的一个对象,它使用ConcurrentHashMap保存属性值。 但是tomcat本质也是一个Java程序,由于每个微服务监听的是不同的端口。那么从一个tomcat容器中(8084)创建的session,另外一个tomcat容器(8085)是肯定获取不到的,因此也就出现了分布式扩展后,提示我们登录的问题。 |
解决 | 针对分析的原因后,我们可以通过【设置cookie跨域分享】+ 【redis存储token】+ 【SpringSession】的方式解决。解决原理如下: (1)首先针对tomcat这个问题,我通过定义一个CookieSerializer方法,放大Cookies的作用域,以方便进行跨域。 (2)用户在某个微服务中登录后,将session中的用户登录信息以token的形式存储在redis中。 (3)然后开始引入SpringSession。具体的: ① 通过@EnableRedisHttpSession注解,导入RedisHttpSessionConfiguration配置; ② 该注解给SpringBoot容器中添加了一个组件SessionRepository,这个类是session的增删改查封装类; ③ 配置完成后,每个request请求都会经过一个SessionRepositoryFilter类,这个类是一个增强器,它的内部将原始的request、response进行了包装增强,形成了新的类( SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper)。 ④ 经过包装的request,在调用request.getSession()方法时,实际上调用的是SessionRepositoryRequestWrapper的getSession()方法,相当于从redis中获取session。从而实现了分布式session会话。 至此,解决了分布场景下提示登录的问题。 |
项目中难点 | 描述 |
---|---|
背景 | 我们在写商城服务端项目的时候,总会限制对某些资源的访问,最常见的就是要求用户先登录才能访问资源。一般情况下,当用户登录后就会将此次会话信息保存进session,同时返回给浏览器指定的cookie键值,下次浏览器再次访问,请求头中就会携带这个cookie,我们也以次来识别用户的登录状态,做出正确响应。 |
问题 | 在项目的订单系统中,生成订单的方法里,首先要通过Feign进行远程调用库存服务进行库存锁定,然后再生成订单。但是却显示远程调用失败,原因是库存服务说我们没有登录。但是我们从浏览器直接调用库存服务的接口却能得到成功的响应,也就是说通过Feign远程调用时,请求头的数据丢失了。 |
查看Feign源码解决问题的过程 | 针对Feign远程调用时请求头丢失的问题,我查阅了Feign的底层源码,发现在 executeAndDecode() 方法内部,Feign通过targetRequest()方法构造出了一个新的request对象,库存服务的response就是对这个request请求的响应,但是这个request中不含有原来request中的任何信息。因此,会出现库存服务提示我们未登录的现象。 |
解决过程 | (1)问题在于feign自己创建出requestTemplate,再用它构建一个新的request对象去发送请求,而这个新的request不包含任何请求头信息。我们应该在它创造出这个request之后,在它真正发送请求之前,把原始请求头中的数据给它复制过去。 (2)然后我看到最后Feign最后构建出创建request对象的 targetRequest方法里边,调用了一系列的RequestInterceptor中的apply()方法进行了增强,最后才返回,但是默认情况下这些拦截器都是空的。 (3)因此 ,我们需要自己实现一个 RequestInterceptor 拦截器,在它的apply()方法中将原始请求头中的数据复制到 Feign 创建出的新的request中,并且将这个拦截器注入Spring容器中,这样Feign在执行目标方法之前会被其拦截,先对其进行增强后,然后再调用。 |
新的问题 | 解决了Feign远程调用过程中request请求头丢失的问题后,在生成订单的方法内,为了提高订单封装的效率。通常会使用异步编排的方式去优惠服务、库存服务、用户服务等查询相关信息进行封装,但是在我们实现了RequestInterceptor 拦截器后,依然会提示我们未登录。 后边又查看了相关源码,才知道当Feign调用出现在异步线程体内的时候,RequestInterceptor拦截器拦截到当前线程后,再使用RequestContextHolder获取的已经不是原来线程了,而获取的是线程池中不携带原请求头信息的线程。因此会出现,依然未登录的提示。 |
解决异步编排下的Feign请求头丢失问题 | 为了解决这个问题,我们可以提前同步数据,保证线程池中进行异步编排的的线程都有着主线程的请求头信息。具体地: 在进入新线程之前,可以拿出原线程绑定的requestAttributes,在新的线程体内,feign调用之前,将其赋值到本线程绑定的request中。这样,在执行Feign远程调用时,被拦截器拦截时,当前线程就已经持有了主线程中地请求头信息,就不会出现未登录的问题了。 |