RocketMQ使用不当造成CPU 100%
技术背景:EDAS、HSF、RocketMQ
a) 2018-11月份,prod-center两台服务器总是莫名其妙宕机,监控(edas控制台)发现TCP连接数总是从项目刚启动时的几十,3天内增长到10000多,然后宕机。 排查了很久,发现没有内存泄漏,死锁等代码问题,内存使用率也还好,就是TCP连接很高。
- b) 在prod-center服务器上,无意中使用 netstat -anl | grep 8080 | wc -l 发现8080端口的连接数竟然上万,但是基本都是"ESTABLISHED off (0.00/0/0)"状态,而其他服务器(prod-app、prod-interface)最多只有100多。 尝试过很多方案,新增一台配置更高的prod-center-3(4核8G),还是不行。
c) 由于本地开发时,EDAS的轻量配置中心占用的端口也是8080(类似dubbo-zookeeper默认端口2181),所以严重怀疑是EDAS和HSF的问题(实在是对EDAS和HSF不熟悉)。尝试过很多hsf的配置,但都失败。eg:配置spring.hsf.max-pool-size=1000。
d) 想起前段时间MQ莫名抛异常,在阿里云工单上随口一提此事,他们的工程师怀疑是MQ的问题。注意力开始向MQ转移,从阿里云后台发现MQ占用的线程特别多。第一遍检查MQ代码,没找到毛病。
e) 我curl了线上的MQ路径,返回了一组地址列表
(注意该MQ的路径中明确指定了8080端口,我眼瞎没留意到)。
阿里云RocketMQ nameserver的端口是8080
我天真的认为上边curl MQ地址返回的ip列表,就是阿里云·华东2区所有MQ集群的服务器(但也太少了)
问题真正锁定MQ,开始仔细看代码。阿里云RocketMQ官方有示例代码:
import com.aliyun.openservices.ons.api.Message;
import com.aliyun.openservices.ons.api.Producer;
import com.aliyun.openservices.ons.api.SendResult;
import com.aliyun.openservices.ons.api.ONSFactory;
import com.aliyun.openservices.ons.api.PropertyKeyConst;
import java.util.Properties;
public class ProducerTest {
public static void main(String[] args) {
Properties properties = new Properties();
properties.setProperty(PropertyKeyConst.GROUP_ID, "CID_shanghai-kaipiao_prod");
// AccessKey 阿里云身份验证,在阿里云服务器管理控制台创建
properties.put(PropertyKeyConst.AccessKey,"${AccessKey}");
// SecretKey 阿里云身份验证,在阿里云服务器管理控制台创建
properties.put(PropertyKeyConst.SecretKey, "${SecretKey}");
//设置发送超时时间,单位毫秒
properties.setProperty(PropertyKeyConst.SendMsgTimeoutMillis, "3000");
// 设置 TCP 接入域名,到控制台的实例基本信息中查看
properties.put(PropertyKeyConst.NAMESRV_ADDR,
"http://onsaddr.cn-shanghai.mq-internal.aliyuncs.com:8080");
Producer producer = ONSFactory.createProducer(properties);
// 在发送消息前,必须调用 start 方法来启动 Producer,只需调用一次即可
producer.start();
//循环发送消息
for (int i = 0; i < 100; i++){
Message msg = new Message( //
// Message 所属的 Topic
"${TOPIC}",
// Message Tag 可理解为 Gmail 中的标签,对消息进行再归类,方便 Consumer 指定过滤条件在 MQ 服务器过滤
"TagA",
// Message Body 可以是任何二进制形式的数据, MQ 不做任何干预,
// 需要 Producer 与 Consumer 协商好一致的序列化和反序列化方式
"Hello MQ".getBytes());
// 设置代表消息的业务关键属性,请尽可能全局唯一。
// 以方便您在无法正常收到消息情况下,可通过阿里云服务器管理控制台查询消息并补发
// 注意:不设置也不会影响消息正常收发
msg.setKey("ORDERID_" + i);
try {
SendResult sendResult = producer.send(msg);
// 同步发送消息,只要不抛异常就是成功
if (sendResult != null) {
System.out.println(new Date() + " Send mq message success. Topic is:" + msg.getTopic() + " msgId is: " + sendResult.getMessageId());
}
}
catch (Exception e) {
// 消息发送失败,需要进行重试处理,可重新发送这条消息或持久化这条数据进行补偿处理
System.out.println(new Date() + " Send mq message failed. Topic is:" + msg.getTopic());
e.printStackTrace();
}
}
// 在应用退出前,销毁 Producer 对象
// 注意:如果不销毁也没有问题
producer.shutdown();
}
}
如下是项目中的错误代码:此处定义的send方法,每次生产消息时都会调用一次,导致producer.start()方法被执行N次,而每次执行,本机都会跟阿里云的MQ服务器集群ESTABLISH一次长连接,导致本机跟远程服务器的8080端口建立起大量连接。
复盘:jstack看一下线程堆栈的统计信息,就能找到异常点
在ajax跨域上传文件时,竟然报错:Access-Control-Allow-Origin
前言:本地想远程连接线上的redis,而线上redis是内网路径,只能先上到跳板机才能连接上,由于不敢直接操作线上服务器,所以先拿测试环境的daily-app(后端服务器的请求入口,所有前端请求都由此进入后端服务器)练手,把它当成跳板机。又由于不知道RedisDesktopManager等软件支持SSH跳板机连接,所以网上找了很多乱七八糟的方案,可以在跳板机上监听数据,进而连接redis。于是在daily-app上执行了某条指令,发现没有生效,就关掉CMD不管了。
晚上线上发版前,发现测试环境的图片不能异步上传,报错Access-Control-Allow-Origin,但是Ajax跨域问题早就解决了,而且线上都是OK的,说明肯定不是代码问题,而是配置问题。
一群人排查了2小时,无意中发现daily-app机器磁盘爆了(莫名其妙),之前使用率还不到20%,忽然间爆了,瞬间想起下午我对机器的疯狂操作,很可能是所谓的“数据监听”把磁盘撑爆了。
kaipiao项目文件上传到oss的流程:前端 -> 后端daily-app服务器 -> oss服务器
刚好文件上传需要一些空间(可能不仅限于内存,还需要一点磁盘支持),而普通请求用不着,所以文件上传就报了诡异的跨域错误,而普通请求却是OK的。
发票maven项目合并,引发运营项目spring security拦截问题
- 背景:
由于operate项目在发票项目合并后,依赖到新center的某些业务模块(eg:A模块),而A模块中一连串的级联,刚巧把spring security的maven依赖关联上了,而这个spring security比较粗暴,只要项目中有相关jar包,所有HTTP请求都会被强制鉴权,导致运营项目所有请求都被拦截401。
效果:在浏览器访问接口时,会直接弹一个登陆表单。
本人没有研究过security的原理,简单看到源码中有如下代码:
线程池(coreSize=0,submit方法)
- coreSize的问题
背景:
辅助开票功能,之前跟票易通联调时,票易通给过一份示例代码,自定义了线程池异步处理推送的发票数据。
代码:
**new**ThreadPoolExecutor(0, 50, 60,TimeUnit.***SECONDS***, **new**ArrayBlockingQueue(1000));
问题:
发到线上后,发现CPU莫名其妙多次100%,刚好这段时间收到了票易通推送的一堆发票数据。
原因:自定义线程池coreSize不能为0,否则该线程池永远只有一条线程在跑,数据量大时扛不住
- submit方法的问题
背景:
辅助开票时,数据发生丢失,但没有任何异常抛出
代码:
**threadPool**.submit(() -> { ...... });
原因:
submit方法需要使用Future捕获异常;而execute方法执行过程中,若有异常会直接抛出
SimpleDateFormat线程不安全
原因:
SimpleDateFormat的parse方法,会用到父类DateFormat中的成员变量:
**protected**Calendar **calendar**;
多线程共享一个变量,所以线程不安全。
- 典型错误用法:
解决方案之一:
Excel poi导出的异常--导致内存爆炸,项目宕机
然而,故事并没有结束,内存溢出的问题仍在继续......
技术补充:
excel2003是以二进制的方式存储,这种格式不易被其他软件读取使用;而excel2007采用了基于XML的ooxml文档标准,ooxml使用XML和ZIP技术结合进行文件存储,每次生成一份Excel,都会先在内存中生成一个巨大的对象,不能像CSV一样流式传输文本。
- 然而,故事好像还没有结束,内存溢出的问题好像仍在继续(不确定)......
- 除了把Excel更换CSV之外新的想法:
事务回滚aop
CenterLogAop代码截取:
spring事务AOP原理:异常最终抛到事务拦截层,才会触发回滚机制,但中途如果被try-catch了,不会生效。
而事务的order貌似都是0,这样的话是“包”在AOP集最外层的,所以仅靠@Transactional注解是无效的,必须在业务方法中手动回滚。
某些异常不打印堆栈,try-catch一下会显灵;另外有些请求控制台没反应,级别调成DEBUG可能会显灵。
eg: ClassCastException:CenterResult cannot cast to HashMap
一个事务方法内部,同时使用jpa和mybatis做DML操作,会报事务异常
Integer和int常见的空指针:
- BeanUtils.copyProperties:Integer -> int 拷贝会报错。
- 注:多留意某些属性拷贝工具类是否支持深拷贝
- Integer和int类型的==判断:如果Integer是null时,会报空指针
BigDecimal的坑
Controller层方法被私有--导致spring的bean无法被@Autowired注入
mysql左外连接不用on的后果:左外连接变成了内连接
mysql默认不区分大小写
select * from some_table where binary str='abc'
select * from some_table where binary str='ABC'
mysql索引注意事项
partner_store_no已存在索引,且是varchar类型:100万的表
SELECT * from active_code where partner_store_no=1300593 -->不会触发索引,耗时>1秒
SELECT * from active_code where partner_store_no='1300593'-->会触发索引,耗时<0.1秒
结论:varchar类型索引,搜索时不要用int类型(索引失效),因为它们完全是两种不同的比较方式
varchar类型,索引长度有限,不能超过191个字符
唯一索引
""会触发唯一索引,而null不会
索引的触发--探讨
需求:
update active_code set user_id = 38
where partner_store_no in (select查询条件);
注:partner_store_no已有索引
- 方案1(不好使):
想通过创建临时表提高性能:
create temporary table tem (select查询条件)
- 方案2(性能提高几百倍):
最后把数据直接粘出来了用
update active_code set user_id = 38
where partner_store_no in (1,2,3,4,5...)
Mybatis的坑
1.如果ResultMap中存在Collection,分页会出错,因为先查10条再映射数据会变少
2.如果关于A表的查询,返回字段中id的值有重复,就不要使用
,而是 。因为 标签有唯一性
JPA - JPQL语句查询结果映射
@Query(value = "from ResponsePush where beginTime < ?1 and endTime > ?1 and deleted = false")
List
注:select id, createTime ... 不能要,否则结果集映射就变成List
>了。
mysql in 关键字
where name in (...) -- 要求不能有null值,否则in失效
批量插入
一次性插入数据不能太多,否则报异常(UTF-8编码12M限制):
com.mysql.jdbc.PacketTooBigException: Packet for query is too large (27017902 > 4194304). You can change this value on the server by setting the max_allowed_packet' variable.
事务失效导致读写分离的数据不一致
@Transaction
public void method(){
update(); //status => 11
select(); //status => 1,跟update结果不一致
}
原因:事务失效,读写库延迟导致数据不一致。如果事务有效,则事务方法内部读写都走主库
超卖
订单关单时,会调用还库存接口,该接口没有做幂等;
而当时在做订单表的迁移,代码中是两库双写,以老库订单状态为准,而某个订单状态始终修改异常,而不断的还库存。
Broken pipe
调用方(Retrofit+OkHttp)接口100ms超时,被调用方接口一般在5~10ms处理结束,但是被调用方在高峰期(150QPS)会经常报错Broken pipe,高峰期报错概率万分之一,低峰期基本不报错。
链路追踪发现:调用方在调用之前就阻塞了近100ms,被调用方(5ms)还没来得及写回,调用方就断链了,导致被调用方Broken pipe异常。
数据库死锁
磁盘满了导致网关变慢
网关不会死,但是会变慢
log4j2日志不一定是完全顺序
连接过VPN导致zookeeper的127.0.0.1解析失败
127.0.0.1被解析成一个10.开头的IP,改成localhost就好了
第二天发现localhost也不好使了,电脑重启不好使,最后发现电脑关机,手动重启就好了。
说明:重启和关机不一样
基于数据库updateTime比对做数据update
最好用>=替代>,因为有些方法内部可能会连续update两次,代码层面造成并发
MQ重复消息违反DB唯一索引
第一次消费:成功
第二次消费:失败(违反唯一索引)
两次消费时间间隔100ms
代码逻辑:先查询是否存在,不存在则insert
理论上第二次消费,会查到第一次insert的数据,不应该抛异常。
原因:没有加事务,DB主从同步没有那么快(50ms)
定时任务/延迟消息的坑——边界逻辑
eg:startTime < 当前时间 < endTime时,表示商品上架
如果当前时间刚好等于startTime了,就会查出商品已下架
商品中台新增同名字段skuId并且全量刷数据引起的二次事故
有shop_goods表和shop_stock表,要把skuId从stock表迁移到goods表,提前提了工单给goods表新增skuId且默认值是0。
- 晚上10点发现:某处ES搜索(B端用的)订阅binlog时,skuId=0把正常的skuId覆盖了
- 商品中台基于stock表把存量的skuId刷到了goods表,共100万数据;
- B端用的ES搜索处及时对goods表的skuId做了过滤,所以第二步操作失去了意义,再一次全量刷新stock表update=update+1,生成100万binlog
- 晚上11点是商品上下架定时任务的时间点,上边刷数据是在晚上10点30结束的
- C端用户搜索的ES集群也订阅商品中台的binlog,他们消费速度比较慢,并且发现用户搜索大量空白之后,停止了binlog订阅,导致消费时间进一步延长,晚上11点半左右才结束,对C端用户影响时长在30分钟左右(晚11点到11点半)。
flink等不能直连Apollo
可以定时查询外部接口,来获取想要的数据。
自己搞不定,就从外部传入
mybatis-plus size=0时compile的SQL没有分页条件导致全表扫描
10分钟180次full gc
过程:
- 收到接口超时报警(8:30)
- mysql监控没有异常
- host维度的接口耗时异常,有几台机器异常,紧急摘除线上流量,容器重建(忘记保留一台机器做现场了)
- 监控发现这几台机器cpu idle和full gc异常,怀疑有海量数据查询的SQL
- 基于时间点,排查17号机器的错误日志,发现8:1x没有慢查询日志(druid日志),8:2x有几百条慢查询
- 聚焦到8:20多分的错误日志,和请求日志,尝试寻找线索
- 发现某慢SQL日志耗时21秒,其他SQL一般都是50~几百ms。SQL内容没有where和分页条件,初步定位问题
- 寻找方法入参
- 问题重现:线上摘掉一台机器的流量,请求重放
没有事务的方法insert之后select偶尔查不到
数据库主从分离了
Lists.partition()使用不当导致并发修改异常
原因:Lists.partition()只是逻辑分割成了多个Partition,在使用时是操作原数组,不是静态物理分割(省内存)
NoHttpResponse
可能是对方服务器没有连接池,而自己服务器有连接池。
我们要被迫Connection:close,取消连接池
survivor分配过小导致的full gc问题
8核16G内存:
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-Xmx11264M -Xms11264M -Xmn6200m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
-XX:SurvivorRatio=8
- 起因:无意中发现有机器Full gc频次异常,C端系统理论上没有full gc或者几天发生一次才正常
- 进一步发现Tomcat线程数异常过多,且时间点吻合
- 进一步发现cpu.idle异常,且时间点与Tomcat线程数激增时间吻合,说明cpu假死导致线程激增并达到配置的最大值1000
系统凌晨QPS最低单机50,Tomcat线程回收时间默认60秒,为何1000个线程在凌晨也无法被回收?
因为Tomcat线程池是轮询获取机制,默认从0号线程轮询到1000号线程,然后再从0号线程开始,所以最要QPS > 1000/60 =16,线程就不会被回收掉。C端接口请求耗时都是在100ms以下的,为何会导致晋升老年代?
CMS晋升老年代的场景有5种:
1. 年龄大于15岁
2. survivor剩余空间不足以分配当前对象(老年代对survivor的分配担保机制)
3. 大对象直接进入老年代,大于-XX:PretenureSizeThreshold的对象直接分配到老年代,但该值默认为0,表示所有对象都默认分配到Eden
4. 动态年龄判定:如果Survivor空间中小于等于某个年龄的对象,大小总和超过Survivor区的一半,则年龄大于等于它的对象会晋升老年代【此处命中】
5. 空间分配担保:Minor GC之前,检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间,如果成立则进行Minor GC;
否则进一步判定某个参数,是否允许担保失败的风险,如果允许,进一步检查老年代最大连续空间是否大于历史晋升老年代对象的平均大小,满足则Minor GC,否则Full GC
- 代码中有个延迟队列,上架时,延迟3分钟写商品快照
new Date(long date)是毫秒值
new Date(int * 1000)会int溢出
应该:new Date(int * 1000L)
spring bean偶而注入失败
现象
- sim环境在联调阶段,发现经常出现某些bean注入为null(eg: mybatis的mapper,dubbo的provider,普通service等随机出现),同一个jar包连续部署10次(或者重新打包部署10次),运气不好的话相关测试链路80%会出现NullPointerException问题(测试未用到的链路也可能有问题),新上的需求代码2万行以上,联调时间紧张没时间细究,起初以为是sim环境最近升级的sidecar环境或者dubbo-disf环境有关系。
- 上线前,担心线上出问题,在pre环境连续部署了3次,测试核心链路正常,没有发生sim环境的NullPointerException问题,于是慢慢上线并谨慎观测该问题,发现生产10台机器部署完,均未发现严重的NullPointerException问题(当时有个bpm审核流在回调时报错,由于审核回调比较少,没太留意)。
- 凌晨1点,有个同事代码上线(在X类加了几行log),某个大流量接口出现百万次空指针,同事过于自信,因为报错的类和自己改动的类完全不相关,误认为是发版瞬间的报错,版发好就没事了,结果10台机器全量发完(10分钟),报错从2级报警升级为1级报警,迫于压力回滚,异常不再报错。晚上仓库作业高峰期,加之回滚动作缓慢,造成30分钟的影响。
- 早上10点多,有同事不知情,该项目再一次发版,结果均顺利上线,核心链路无NullPointerException
- 白天上班后,我和事故同学开始排查凌晨的事故
- 下午6点,有同事要修复严重bug,我来协助他谨慎上线,发版1台机器后发现大量异常,紧急回滚未造成事故
- 后续又有几次试探发版,均未出现太多NullPointerException,但bpm回调的地方倒是有一些NullPointerException
难度
- 本地项目基于master分支无法正常启动,上周开发时还可以,本周花了2个小时都没有跑起来,暂时放弃本地debug
- sim环境复现概率变低,越想观测越不出现,生产环境后续多次发版,均未出现凌晨1的事故
- 最近一个月测试和运维同学要求我们做了一些配置和升级,接入了sidecar环境,dubbo也接入了disf注册中心
- 问题可能不是我们本次发版造成的,之前几周可能就存在,由于概率低没有翻车,代码diff要5万行代码,难度极大,只能作为最后兜底方案
- 该B端项目由于历史原因,被多个团队几十个同事同时维护(商品中台、供应商、采购等团队)
- 日志debug模式,没有有效信息
发现&猜测&验证&反思
- 猜测:可能是bean的循环依赖太多,本次发版代码量太大又增加了系统的熵,而发版后首次访问流量不同,很多懒加载的bean激活链路不同,所以造成了不同的后果。
验证:观测了3台事故机器的发版日志和3台正常机器的发版日志,未发现线索。
反思:spring 3级缓存的earlySingleObject机制,解决了循环依赖,而且项目启动不报错,而且相关报错的bean也没有加@Lazy注解,理论上是项目启动时注入bean - 发现:项目启动完成前几秒(Tomcat打印start success日志前几秒),有dubbo的外部流量进入。
猜测:外部dubbo流量提前入场,导致spring的bean初始化异常
验证1:配置dubbo服务延迟10秒暴露,再次发版没效果。
验证2:配置容器优雅退出kill -15,取代暴力kill -9,防止disf服务发现机制不能收到注销通知,而心跳有服务保护机制,并不会剔除所有异常机器(默认配置只剔除40%异常机器)。结果:不好使 - 猜测:是否跟项目打的jar包有关系:后续验证过程中发现,异常的jar重复部署基本都是异常的,正常的jar重复部署基本都是正常的。凌晨1点的发版事故,10台机器都异常,但jar包回滚却都能恢复
验证:diff两个不同的jar包。没发现线索
最后方案
花费大量时间把项目本地跑起来,debug模式下,找到某个null的bean,从spring 3级缓存开始慢慢debug,又花费了很长时间,无意中发现有个公司common包的堆栈信息(关于@Cache注解),查看源码发现:
异常被catch了!!!
排除这个包,重启项目失败报错:spring循环依赖!
多处@Lazy治标,重启项目好了!
后续
启动失败还报错:
Error creating bean with name 'xxx': Bean with name 'xxx' has been injected into other beans [某个BeanName] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example....
描述
B,C 两个对象互相依赖,PrintAOP对A B 对象的print() 方法进行前置代理。当系统启动时偶发无法启动的现象,系统抛如下异常。创建名为“b”的bean时出错:名为“b”的bean已作为循环引用的一部分被注入其原始版本中的其他bean[c],但最终已被包装。这意味着其他bean不使用bean的最终版本。
验证
@Component
public class B {
@Autowired
private C c;
@PostConstruct
public void init(){
System.out.println("B init");
}
@Async
public void print(){
System.out.println("B");
c.print();
}
}
@Component
public class C {
// @Lazy
@Autowired
private B b;
@PostConstruct
public void init(){
System.out.println("C init");
}
public void print(){
System.out.println("C");
}
}
在本demo中当B先被实例化时,在b对象初始化的过程中,b对象经过@Asyn注解相关的 BeanPostProcessor 处理后版本会发生变化,循环引用检测中发现c对象依赖了原始的b版本,而现在的b版本为经过@Asyn处理的bProxy版本。因此会抛出异常。而当C对象先被初始化实例化时,由于C 经过 BeanPostProcessor 处理后对象版本不回发生改变,所以不回抛出异常。而 B 和 C 的哪个会先被实例化初始化,取决于 Bean 在 BeanFactory 的注册顺序。也就是取决于C.class B.class 哪个先被扫描并注册,根据实验现象同一个Jar包在不同的机器上运行会产生不同的后果。我猜测这和物理机的文件排序规则有关。
解决
- 最合理和科学的解决方式就是在设计阶段避免循环引用。
- 在@Autowire 上方加入@Lazy 注解。将 Bean 延迟注入。问题似乎很简单就解决了,但是随着多次实现我发现了一个有趣的现象:在B先被扫描的场景,注释掉B类的@Asyn,B中添加@PostConstruct修饰的方法时和对B进行 @Aspect 代理, 启动后并未出现异常。这和我们之前得到的结论似乎有些冲突,因为无论是@Aspect 和 @PostConstruct 都会讲Bean进行后置处理,为什么只有@Asyn会产生异常呢?我们通过梳理源码Bean 的实例化和初始化过程对这背后的知识进行一个总结。
doCreateBean整体流程源码如下
protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args)
throws BeanCreationException {
// Bean 实例化流程
BeanWrapper instanceWrapper = null;
if (mbd.isSingleton()) {
instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
}
if (instanceWrapper == null) {
instanceWrapper = createBeanInstance(beanName, mbd, args);
}
final Object bean = instanceWrapper.getWrappedInstance();
Class beanType = instanceWrapper.getWrappedClass();
if (beanType != NullBean.class) {
mbd.resolvedTargetType = beanType;
}
// Allow post-processors to modify the merged bean definition.
synchronized (mbd.postProcessingLock) {
if (!mbd.postProcessed) {
try {
// 个人理解这个流程是识别并标识需要被后置处理器处理的元素(如被@Asyn注解标识的方法等)
applyMergedBeanDefinitionPostProcessors(mbd, beanType, beanName);
}
catch (Throwable ex) {
throw new BeanCreationException(mbd.getResourceDescription(), beanName,
"Post-processing of merged bean definition failed", ex);
}
mbd.postProcessed = true;
}
}
// Eagerly cache singletons to be able to resolve circular references
// even when triggered by lifecycle interfaces like BeanFactoryAware.
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
if (logger.isTraceEnabled()) {
logger.trace("Eagerly caching bean '" + beanName +
"' to allow for resolving potential circular references");
}
//BeanFactory 暴露 用于解决循环依赖
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
// Bean 初始化流程
Object exposedObject = bean;
try {
// Bean Field 属性注入,这个方法里边会递归的去 实例化和初始化依赖的Bean 并注入属性
populateBean(beanName, mbd, instanceWrapper);
// 初始化操作,包含BeanPostprocesser的增强处理,init-method,afterProperties等初始化逻辑的执行
// 经过BeanPostProcesser增强处理后 exposedObject bean版本可能发生变化
exposedObject = initializeBean(beanName, exposedObject, mbd);
}
catch (Throwable ex) {
if (ex instanceof BeanCreationException && beanName.equals(((BeanCreationException) ex).getBeanName())) {
throw (BeanCreationException) ex;
}
else {
throw new BeanCreationException(
mbd.getResourceDescription(), beanName, "Initialization of bean failed", ex);
}
}
if (earlySingletonExposure) {
// 获取 B类 提前暴露的 earlySingletonObjects,由 C 类调用 B 的提前暴露工厂锁产生。
Object earlySingletonReference = getSingleton(beanName, false);
if (earlySingletonReference != null) {
// 如果 exposedObject 经过initializeBean() 方法版本未变化,则
if (exposedObject == bean) {
exposedObject = earlySingletonReference;
}
else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
String[] dependentBeans = getDependentBeans(beanName);
Set actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
for (String dependentBean : dependentBeans) {
if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
actualDependentBeans.add(dependentBean);
}
}
if (!actualDependentBeans.isEmpty()) {
throw new BeanCurrentlyInCreationException(beanName,
"Bean with name '" + beanName + "' has been injected into other beans [" +
StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
"] in its raw version as part of a circular reference, but has eventually been " +
"wrapped. This means that said other beans do not use the final version of the " +
"bean. This is often the result of over-eager type matching - consider using " +
"'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.");
}
}
}
}
// Register bean as disposable.
try {
registerDisposableBeanIfNecessary(beanName, bean, mbd);
}
catch (BeanDefinitionValidationException ex) {
throw new BeanCreationException(
mbd.getResourceDescription(), beanName, "Invalid destruction signature", ex);
}
return exposedObject;
}
// 缓存BeanFactory,移除earlySingletonObjects,保存单利对象的名字
protected void addSingletonFactory(String beanName, ObjectFactory singletonFactory) {
Assert.notNull(singletonFactory, "Singleton factory must not be null");
synchronized (this.singletonObjects) {
if (!this.singletonObjects.containsKey(beanName)) {
this.singletonFactories.put(beanName, singletonFactory);
this.earlySingletonObjects.remove(beanName);
this.registeredSingletons.add(beanName);
}
}
}
//最终调用到 AbstractAutoProxyCreator 中的 getEarlyBeanReference
//获取early Bean引用(单例Bean发生循环依赖时才能回调该方法)
@Override
public Object getEarlyBeanReference(Object bean, String beanName) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
//1、将cacheKey添加到earlyProxyReferences缓存,beanPostProcesser 将不再处理
this.earlyProxyReferences.put(cacheKey, bean);
//2、包装目标对象到AOP代理对象,@Aspect 就是此处实现的
return wrapIfNecessary(bean, beanName, cacheKey);
}
protected Object initializeBean(final String beanName, final Object bean, @Nullable RootBeanDefinition mbd) {
if (System.getSecurityManager() != null) {
AccessController.doPrivileged((PrivilegedAction
总结与思考
- 为什么Spring 在处理 单例循环引用时为什么采用暴露 singletonFactory 的方式而不是直接暴露 earlySingletonObject。
思考:通过暴露 singletonFactory的方式 ,回调者可以通过 BeanFactory 的 getObject() 方法中对 earlySingletonObject 进行自定理逻辑的增强处理。Spring 中在此处做了 @Aspect 的增强处理,使得回调者可以获得经过了 @Aspect 代理增强处理后的earlySingletonObject。
- 为什么Spring 暴露的singletonFactory的回调方法中仅仅是只对 @Aspect 相关的代理做了逻辑处理,而不是对所有的后置处理器都进行逻辑处理呢,这样看似就可以消除这个循环引用版本不一致的问题。
思考一
先谈谈我最初稚嫩的理解,循环引用本身就应该在工程设计中进行规避,Spring不推荐使用循环引用也就为未对此进行过多处理。
思考二
而回过头认真思考一下BeanPostProcessor执行的时机 postProcessBeforeInitialization->初始化方法合集->postProcessAfterInitialization。我们就会发现,如果Spring 提前暴露的singletonFactory中所有的BeanPostProcessor 都对Bean进行了增强处理。初始化方法合集的执行顺序就由 B实例化->C实例化->C->C初始化方法合集->B初始化 变成了 B实例化->C实例化->B->B初始化方法合集->C初始化,这就违背了初始化的顺序原则。而且初始化方法合集中可能会有这种现象,B的初始化方法依赖了C的逻辑,很明显这种改变初始化顺序的方式是不可取的。而@Aspect 是一种可以看成一种和业务逻辑无关的增强代理,本身从设计上@Aspect只是对切面做增强处理,本身不需要与初始化方法合集有前后顺序关系。Spring可能是在解决循环引用的方式中做了一些妥协兼容了@Aspect 的情况。
思考三
仔细查看一个个BeanPostProcessor 的实现类,可以发现其中有的BeanPostProcessor并未改变Bean的版本,如@PostConstract。有的则是没有逻辑处理仅仅是对Bean进行了增强(代理)处理,如@Asyn,@Transactional。其实@Asyn,@Transactional这种改变Bean版本没有方法逻辑的。思考二实例化顺序规则貌似是可以打破的,代理动作完全可以与初始化方法无关。我们思考一下是否可以将这些只会使Bean发生版本变化的BeanPostProcessor都放在暴露工厂的逻辑中的?貌似这样就彻底解决了循环引用的版本不一致问题?但是BeanPostProcessor本身就是Spring对外提供的扩展接口,每个使用者都可以DIY属于自己的BeanPostProcessor。Spring本身不应该关心BeanPostProcessor 是否会让Bean产生版本变化。这个适配是一个需要依赖下游实现的适配,也是一个无休止的适配,这可能就是Spring只兼容了@Aspect的原因吧。
综上,从循环依赖导致天网后端的上线异常,引出了对Spring Bean初始化和依赖注入的重新学习。对于循环依赖问题,增加@Lazy注解只是治标的做法,本质上其实是出现了类的职责和调用关系的重度耦合,因此最好的解决方案其实还是重新梳理代码架构设计,对类的功能进行重构,避免循环依赖场景的出现。
循环依赖
spring构造注入的方式会循环依赖,阻止项目启动
注解注入一般不会启动失败