1 Star 0 Fork 0

张晨曦 / SombreKnight

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
该仓库未声明开源许可证文件(LICENSE),使用请关注具体项目描述及其代码上游依赖。
克隆/下载
content.json 123.79 KB
一键复制 编辑 原始数据 按行查看 历史
张晨曦 提交于 2022-06-05 23:17 . Site updated: 2022-06-05 23:17:59
{"meta":{"title":"张晨曦的博客","subtitle":"个人博客","description":"技术分享 工作心得 生活感悟","author":"张晨曦","url":"https://sombreknight.gitee.io","root":"/"},"pages":[{"title":"分类","date":"2021-01-09T02:59:20.699Z","updated":"2021-01-09T02:59:20.699Z","comments":false,"path":"index.html","permalink":"https://sombreknight.gitee.io/index.html","excerpt":"","text":""},{"title":"404 Not Found:该页无法显示","date":"2021-01-09T03:02:07.343Z","updated":"2021-01-09T03:02:07.343Z","comments":false,"path":"/404.html","permalink":"https://sombreknight.gitee.io/404.html","excerpt":"","text":""},{"title":"关于","date":"2022-06-05T14:20:17.672Z","updated":"2022-06-05T14:20:17.602Z","comments":false,"path":"about/index.html","permalink":"https://sombreknight.gitee.io/about/index.html","excerpt":"","text":"1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980{ "name":"张晨曦", "officialName":"飞穻", "age":25, "profession":"主业Java,副业产品经理", "experience":"4年", "address":"四川省成都市", "education":"本科", "company":"蚂蚁金服", "github":"https://github.com/SombreKnight", "blog":"https://sombreknight.github.io", "email":"dlmu_zhangchenxi@foxmail.com", "skills":{ "Java":[ "Basic Java", "Java Collection", "Java IO", "Java Concurrent", "JVM & GC" ], "Web FrameWork":[ "Spring", "SpringMVC", "SpringBoot" ], "SpringCloud":[ "Eureka", "Ribbon", "Open Feign", "Hystrix", "GateWay" ], "SpringCloudAlibaba":[ "Nacos", "Sentinel", "Seata" ], "Web Security":[ "XSS", "CSRF", "SQL injection", "Upload vulnerability", "Download vulnerability", "Url Jump vulnerability" ], "MiddleWare":[ "Redis", "Zookeeper", "RabbitMQ", "Kafka" ], "Software Engineering":[ "Design Parttern", "Code Clean Way", "Refactor", "DDD" ], "Linux":[ "basic cmd", "shell", "basic ops skills" ], "BigData":[ "Hadoop", "Spark", "Flink" ], "Others":[ "SVN", "GIT", "Maven", "Docker", "ELK", "Python", "Php", "JavaScript", "VUE" ] }}"},{"title":"分类","date":"2021-01-09T03:00:41.011Z","updated":"2021-01-09T03:00:41.011Z","comments":false,"path":"categories/index.html","permalink":"https://sombreknight.gitee.io/categories/index.html","excerpt":"","text":""},{"title":"标签","date":"2021-01-09T02:59:59.946Z","updated":"2021-01-09T02:59:59.946Z","comments":false,"path":"tags/index.html","permalink":"https://sombreknight.gitee.io/tags/index.html","excerpt":"","text":""},{"title":"友情链接","date":"2021-01-23T15:41:26.940Z","updated":"2021-01-23T15:41:26.935Z","comments":true,"path":"links/index.html","permalink":"https://sombreknight.gitee.io/links/index.html","excerpt":"","text":""},{"title":"项目","date":"2021-01-09T06:50:06.768Z","updated":"2021-01-09T06:50:06.760Z","comments":false,"path":"repository/index.html","permalink":"https://sombreknight.gitee.io/repository/index.html","excerpt":"","text":""}],"posts":[{"title":"优化高并发下获取系统当前时间毫秒数性能问题","slug":"2VtWIcgxfEv5oIRA","date":"2021-04-19T00:35:14.000Z","updated":"2021-04-19T00:34:52.000Z","comments":true,"path":"2021/04/19/2VtWIcgxfEv5oIRA/","link":"","permalink":"https://sombreknight.gitee.io/2021/04/19/2VtWIcgxfEv5oIRA/","excerpt":"","text":"一、问题背景最近在网上看到有说Java当中根据System.currentTimeMillis()获取当前毫秒时间戳的方式在高并发下会有性能问题。这里不再去验证这个问题是否存在了,网上很多参考可以证明这个问题确实存在。 这里记录一个解决此问题的工具代码,原理是利用了一个定时任务去异步获取时间写入到原子长整型中,然后获取时间就可以直接从此字段上获取了。 二、源代码123456789101112131415161718192021222324252627282930313233343536373839404142434445import java.util.concurrent.Executors;import java.util.concurrent.ScheduledExecutorService;import java.util.concurrent.ThreadFactory;import java.util.concurrent.TimeUnit;import java.util.concurrent.atomic.AtomicLong;public class SystemClock { private final int period; private final AtomicLong now; private static class InstanceHolder { private static final SystemClock INSTANCE = new SystemClock(1); } private SystemClock(int period) { this.period = period; this.now = new AtomicLong(System.currentTimeMillis()); scheduleClockUpdating(); } private static SystemClock instance() { return InstanceHolder.INSTANCE; } private void scheduleClockUpdating() { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = new Thread(runnable, "System Clock"); thread.setDaemon(true); return thread; }); scheduler.scheduleAtFixedRate(() -> now.set(System.currentTimeMillis()), period, period, TimeUnit.MILLISECONDS); } private long currentTimeMillis() { return now.get(); } public static long now() { return instance().currentTimeMillis(); }} 三、逻辑分析可以看到这个SystemClock类只有一个静态public方法now()。所以要获取当前时间只需要SystemClock.now()这样调用一下就可以了。这里简单看下这个代码设计逻辑。 首先,可以看到这个SystemClock依赖了内部的一个InstanceHolder去获取一个单例的SystemClock对象。这里运用了私有静态类的方式做到懒汉式线程安全的单例模式, 可以完全不使用同步关键字, 完全利用JVM的机制去保证了线程安全,可以学习下这种单例模式的写法. 然后具体看SystemClock中的实现,我们可以看到通过SystemClock.now()获取的时间,实际上是来自于SystemClock中的私有成员变量now,这是一个AtomicLong的类型。既然知道我们的时间戳是从这个字段上获取的,那么我们就只需要顺藤摸瓜,看下这个成员变量是什么时候被赋值的就行了。 于是,我们看到scheduleClockUpdating这个方法: 12345678private void scheduleClockUpdating() { ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { Thread thread = new Thread(runnable, "System Clock"); thread.setDaemon(true); return thread; }); scheduler.scheduleAtFixedRate(() -> now.set(System.currentTimeMillis()), period, period, TimeUnit.MILLISECONDS); } 可以看到,这里实际上就是开启了一个ScheduledExecutorService线程池,来定时调用System.currentTimeMillis(),来将时间异步更新到成员变量now当中。那么这个scheduler是什么时候被创建的呢,可以看到scheduleClockUpdating()方法的调用点: 12345private SystemClock(int period) { this.period = period; this.now = new AtomicLong(System.currentTimeMillis()); scheduleClockUpdating();} 所以可以知道,scheduler是在SystemClock构造器中被创建的,又由于SystemClock是私有构造器,而其是InstanceHolder中作为静态成员变量存在的,故实际上是当InstanceHolder被类加载后,就创建了此调度器线程池。 四、总结通过这种异步定时更新时间戳变量的方式去获取时间戳,其实还是有一定的精度丢失的,毕竟定时去调度是会造成时间差的,及时是以很小的时间间隔去调度。但是在大多数场景下,这点误差是可以接受的。故而通过这种方式,优化获取时间的性能还是可取的。","categories":[{"name":"Java","slug":"Java","permalink":"https://sombreknight.gitee.io/categories/Java/"}],"tags":[{"name":"高并发","slug":"高并发","permalink":"https://sombreknight.gitee.io/tags/%E9%AB%98%E5%B9%B6%E5%8F%91/"},{"name":"性能优化","slug":"性能优化","permalink":"https://sombreknight.gitee.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"}]},{"title":"Spring 框架源码分析","slug":"qDf9nxMp5VywgXFc","date":"2021-03-21T07:31:55.000Z","updated":"2021-03-28T07:31:16.000Z","comments":true,"path":"2021/03/21/qDf9nxMp5VywgXFc/","link":"","permalink":"https://sombreknight.gitee.io/2021/03/21/qDf9nxMp5VywgXFc/","excerpt":"","text":"快速索引: 背景知识 Spring容器初始化流程 循环引用问题 1. 背景知识在开始梳理整个Spring IOC的创建对象流程之前,我们应该先具备一些基本的背景知识。如下: 在Spring中,我们可以将对象交给Spring来创建管理,Spring是通过反射的方式创建对象的; 所谓IOC容器,其实就是一个map,缓存了对象的标示和对象本身,当我们获取通过依赖注入等方式从IOC容器获取对象时,就是从此map中获取对象; 在Spring中创建对象的工作是交给BeanFactory来完成的,所以要想能够创建对象,先要有BeanFactory才行; 有了BeanFactory后,会根据BeanDefinition来创建对象,BeanDefinition就是我们开发者对于一个Bean的描述信息,BeanFactory根据BeanDefinition上的描述信息来创建我们需要的对象; Spring IOC作为Spring生态的基石,为了拥有强扩展性,在整个IOC流程做了大量的抽象,在各个环节也都给开发者留出了扩展的空间; Spring的基本初始化流程,这里先放上一张概括性的图,后续源码分析过程中,将一步步对其进行佐证。 2.Spring容器初始化流程接下来基于SpringBoot2.x版本来进行一个Spring容器的初始化流程源码分析,这里使用了一个基于注解的ApplicationContext来分析。 首先看下这个老生常谈的方法:AbstractApplicationContext # refresh(),众所周知,这个就是spring容器的整个初始化流程了。 可以看到这里一共有12个子方法一切协同完成Spring容器的初始化流程。解析来我就对这12个方法逐个击破,分析其最核心的源码逻辑。 先搞一个快速索引,方便以后查询: prepareRefresh() : refresh相关上文准备 obtainFreshBeanFactory() : 获取BeanFactory prepareBeanFactory(beanFactory) : 配置工厂的标准上下文特性 postProcessBeanFactory(beanFactory) : 模版方法,允许实现者自己添加postProcessors到BeanFactory invokeBeanFactoryPostProcessors(beanFactory) : 执行BeanFactory后处理器 registerBeanPostProcessors(beanFactory) :集中注册Bean后处理器 initMessageSource() :初始化国际化资源 initApplicationEventMulticaster() :初始化多播器 onRefresh() :模版方法,留给扩展者实现,可以用于初始化一些特殊的bean或其他用途 registerListeners() :注册消息监听器 finishBeanFactoryInitialization(beanFactory) :完成整个BeanFactory的初始化,包括对象的创建 finishReresh() : 完成refresh方法 1. prepareRefresh()这个方法不做深究,主要是进行refresh相关的上下文准备工作。但是值得注意的是,在这个方法内初始化了一个名为earlyApplicationEvents的集合。 此集合的用途,在其初始化的代码行上也注释说明了,这个东西存放一些早期的事件实体,当多播器可用的时候,就可以被发布出去。先对此留一个印象。 2. obtainFreshBeanFactory()见名知意,获取BeanFactory。 在背景知识中,我们就预先知道了,Spring为我们创建对象的工厂就是BeanFactory。而BeanFactory只是一个顶层接口,其有很多实现,在refresh过程中,我们获取的BeanFactory是一个叫做ConfigurableListableBeanFactory的对象。 获取beanFactory,在AbstractRefreshableApplicationContext和GenericApplicationContext中有不同的实现,在当前使用的SpringBoot框架中,使用的是GenericApplicationContext中的实现,其在初始化GenericApplicationContext时,就已经创建了beanFactory。 看GenericApplicationContext的构造器可知。 这里顺便看下这个DefaultListableBeanFactory和ConfigurableListableBeanFactory的关系: OK,其实DefaultListableBeanFactory就是ConfigurableListableBeanFactory的一个实现类。 那么,是什么时候创建的GenericApplicationContext呢? 其实我们在进入refresh方法时所使用的AbstractApplicationContext就是AnnotationConfigServletWebServerApplicationContext。 我们看下这个常量是什么: 而AnnotationConfigServletWebServerApplicationContext是继承自GenericApplicationContext的,所以在进入refresh方法前,其实这个beanFactory就已经被创建好了。其继承关系如下图所示: 至此,我们已经可以获取到一个BeanFactory了,需要留意这里创建的beanFactory类型是DefaultListableBeanFactory。后面将会围绕着这个beanFactory展开一系列的流程。 现在,我们可以确认,已经有BeanFactory了。要创建对象,剩下的步骤就是告诉BeanFactory 具体的BeanDefinition,然后让BeanFactory帮我们创建对象,并缓存到map中(也就是所谓的IOC容器)即可。 3. prepareBeanFactory(beanFactory)看注释的意思就是:配置工厂的标准上下文特性,例如上下文的类加载器和后处理器。 这里重点说下这个“后处理器”的概念,后处理器,在spring中都是以“postProcessor”结尾的。既然是叫后处理,肯定是在某个事件之后,进行一些处理,具体的处理,由扩展者自己来实现。当然Spring自己也给自己写了诸多的后处理器。比如说prepareBeanFactory方法中有这么一行,挺有意思的: 这一行代码的目的就是向beanFactory中添加了一个BeanPostProcessor,从这一行代码我们可以得出一个结论: beanFactory中应该有个容器,可以存放BeanPostProcessor; 跟踪了下这个add方法,确实: 有这么个List存放了各种各样的BeanPostProcessor,顺便又看到了这一行注释,意思很简单,在创建bean的过程中,会调用这些BeanPostProcessor。这就有意思了,我们来看下BeanPostProcessor的具体定义: 123456789101112131415public interface BeanPostProcessor { @Nullable default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { return bean; } @Nullable default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { return bean; } } 注释看得比较明白了,简单总结下。这个接口中的postProcessBeforeInitialization方法,会在bean初始化之前调用。postProcessAfterInitialization方法会在bean初始化之后调用。 这里需要补充说明下,初始化和实例化在spring中是两个不同的概念。比如说有个Student类,有name和sex属性。实例化就是Student s = new Student() ; 而初始化就是s.name=”张三”; s.sex = “男”。 所以这个BeanPostProcessor接口中的方法,一定是在bean创建(实例化)了之后被调用的。 看接口中的方法定义也可以佐证这一点,其中的两个方法都传入了一个bean参数,很明显,没有创建bean的话,肯定是没法传这个参数的。 OK,再回到我很感兴趣的这一行代码: 这里spring自己在拿到beanFactory后,添加了一个Bean后处理器,叫做ApplicationContextAwareProcessor。 这里有意思的是”Aware”和“Processor”同时出现了,“Aware”我相信用过Spring的都知道是干嘛的,话不多说,一起来看下这个ApplicationContextAwareProcessor在bean初始化前后都干了什么? 我们可以看出来,ApplicationContextAwareProcessor只实现了postProcessBeforeInitialization方法,内容其实就是执行Aware接口,传入了当前被创建的bean对象。 简而言之就是,当一个Bean被创建后,在其初始化之前,会调用这个bean相关的Aware接口。 通过看Aware接口,以及调用Aware的这个invokeAwareInterfaces方法的实现可以看出,Spring把Aware分成了这么6大类,每一类aware都有一个根接口,定义了一个这一类接口都符合含义的抽象方法。 至此,我们总结下,在prepareBeanFactory中,定义了aware的执行时机,是以beanPostProcessor的方式,在bean初始化之前被调用执行。当然prepareBeanFactory中还有执行了其他的逻辑,可以简单概括为,在这个方法中配置了BeanFactory的一些自己的特性。也是属于一些创建对象之前的准备工作。提前埋下伏笔,在后面的某个契机再来处理。 对了,还有个问题,到现在位置BeanDefinition这个东西还没出现过,是我们漏掉了什么吗? 其实不是,只是在SpringBoot的基于注解的上下文下,加载BeanDefinition的过程不在此出现而已 。 后面我们在分析BeanFactory如何根据BeanDefinition创建对象时,再去分析它是什么时候出现的,该来的总是会来的。 4. postProcessBeanFactory(beanFactory)这是一个空实现的方法,看注释可以知道,这个方法允许我们自己再添加一些BeanPostProcessor到beanFactory中。 具体是不是这样呢?我们找一个实现来看看。在ServletWebServerApplicationContext中,还真找到了这么一个实现,其实现是添加了一个AwareProcessor. 并且还可以添加一些其他的配置。 至此,可以知道这个postProcessBeanFactory其实就是对第三步prepareBeanFactory的一个补充,只不过要补充的东西就不是BeanFactory自己的特性了,而是补充者自己想要补充的配置。 5. invokeBeanFactoryPostProcessors(beanFactory)看注释说明可知,这个方法的作用就是:实例化并调用所有已注册的BeanFactoryPostProcessor对象,如果给定,则遵循显式顺序。 必须在单例实例化之前调用。 这里需要说明下什么是BeanFactoryPostProcessor。 其实根据BeanPostProcessor类比可以知道,BeanFactoryPostProcessor的实现是希望在BeanFactory创建后可以执行。 看这个注释还专门提到了一句,“必须在单例实例化之前调用”。 综合前面已经分析的流程,可以推断出,整个Spring初始化流程大概是: 创建BeanFactory —-> 执行BeanFactoryPostProcessor —> 实例化Bean —-> 执行BeanPostProcessor中的befor方法 —–> 初始化Bean —–> 执行BeanPostProcessor中的after方法。 另外还有几点需要挖掘: BeanFactoryPostProcessor的定义是什么? 可以看出,BeanFactoryPostProcessor是一个函数式接口,只有一个接口,就是具体的实现逻辑:“给你已经创建好的beanFacory,你想干嘛就干嘛,随便你!” BeanFactoryPostProcessor存放在哪里? 看着getBeanFactoryPostProcessors()方法,其实就是返回了一个属性: BeanFactoryPostProcessor都存放在AbstractApplicationContext中的这个列表当中的。接下来只要找到调用这个list的add方法就可以知道这些BeanFactoryPostProcessor是什么时候定义的了。 BeanFactoryPostProcessor什么时候定义的? 至此,我们已经清楚了,这一步调用执行BeanFactory后处理器,发生在Bean实例化之前,并且所调用的BeanFactoryPostProcessor是在子类中添加进来的。 6. registerBeanPostProcessors(beanFactory) 看注释的解释:这个方法,必然调用在实例化之前。这里是集中注册一波BeanPostProcessor。 查看了下这个方法的内部实现,大概是把所有的BeanPostProcessors分成了优先级/内部/有序/无序四类BeanPostProcessor分别注册到beanFactory中的beanPostProcessors集合中。 7. initMessageSource()此方法用于初始化beanFactory中的messageSource。目的用于处理一些国际化操作。可以先忽略。 8. initApplicationEventMulticaster() 初始化多播器(ApplicationEventMulticaster)。这个多播器有什么用呢?我们来看下其接口定义: 123456789101112131415161718public interface ApplicationEventMulticaster { void addApplicationListener(ApplicationListener<?> listener); void addApplicationListenerBean(String listenerBeanName); void removeApplicationListener(ApplicationListener<?> listener); void removeApplicationListenerBean(String listenerBeanName); void removeAllListeners(); void multicastEvent(ApplicationEvent event); void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType); } 可以看出来,这个ApplicationEventMulticaster其实具备的能力很简单,就是增删监听器(ApplicationListener)以及给监听器们多播事件(ApplicationEvent)。 具体看下ApplicationListener: 可以看出,这是一个函数式接口,就一个方法,而入参正是ApplicationEvent对象。 这里很明显就是对于观察者模式的一个应用。 向Multicaster注册了监听器,当我想通知监听器发生了什么事情时,就调用监听器的onApplicationEvent方法即可。 9. onRefresh() 看注释可以知道,这是一个模版方法,留给扩展者实现,可以用于初始化一些特殊的bean或其他用途。 我们可以看一个实现。下面是ServletWebServerApplicationContext对于此方法的实现。在调用到onRefresh时,创建了具体的webServer(Tomcat || Jetty || Undertow)。 10. registerListeners() 看名字就知道,这个方法是在注册监听器。看其实现,一是将监听器都注册到前面已经初始化好的ApplicationEventMulticaster中,二是将步骤一中,我专门留意的这个earlyApplicationEvents在此时发起多播。 11. finishBeanFactoryInitialization(beanFactory) 这一步就非常关键了,看注释的意思是,将剩余的非懒加载的单例对象都实例化出来。 我们知道BeanFactory创建对象需要依赖BeanDefinition,所以接下来的过程中,我们要关注两个事情: BeanDefinition哪儿来的? 怎么实例化bean的? 先看下finishBeanFactoryIntialization这个方法的实现: 根据注释可以看出主要做了两件事,一是将BeanFactory初始化完成,二是将剩余的非懒加载的单例对象都实例化了。 我们重点关注下是如何实例化对象的。在DefaultListableBeanFactory对preInstantiateSingletons是这样实现的: 第一部分:实例化对象。可以看出来这部分的逻辑其实就是,拿到所有要实例化对象的beanDefinition,然后根据bd的描述,去实例化对象。实例化对象实际上是通过beanFactory中的getBean实现的。 第二部分:如果有SmartInitializingSingleton对象,则主动调用以下这个afterSingletonsInstantiated方法,表示通知BeanFactory已经完成了所有对象的实例化。 接下来,我们重点关注第一部分的实现。首先,关于beanDefinition,在DefaultListableBeanFactory中有这么一个beanDefinitionNames的list。 查看其调用点如下,DefaultListableBeanFactory重写了一个registerBeanDefinition的方法,在这里对需要实例化的bean,保存了其BeanDefinition到beanDefinitionMap中,key是beanName,value是BeanDefinition. 观察到这么一行注释: 原来DefaultListableBeanFactory其实是实现了BeanDefinitionRegistry,这个BeanDefinitionRegistry应该就是我们所说的Bean定义注册表了。至此我们可以推断,这个bean定义注册表肯定是在bean实例化之前就初始化好了,否则实例化时肯定找不到BeanDefinition。 关于合适加载的beanDefinition,我们可以debug试试。 通过debug,发现了这个registerBeanDefinition确实是在refresh方法之前就被调用了若干次,每调用一次就注册一个bean定义信息到注册表。 这里补充说明下,在注册bean定义信息之前,还有很重要一步,那就是通过beanDefinitionReader去读取不同类型的配置信息将之转换为bean定义信息。因为对于bean的配置有很多方式,如xml、注解、配置类等等,所以这里我们就不深究了,只要知道是通过BeanDefinitionReader这一层抽象,我们成功获取到了beanDefinition,并注册到了bean定义注册表。 继续分析,beanFactory实例化对象的过程: 我们可以看出来,通过beanName,我们最终可以从bean定义注册表获取到一个RootBeanDefinitioni对象,然后如果在bean定义中确认了,这个bean不是抽象的且是单例的且不是懒加载,那么就通过beanFactory的getBean方法就可以获取/实例化对象了。 接下来就是关于这个getBean方法的具体实现,这里我们先不关注三级缓存和循环依赖的问题,我们只需要找到反射常见对象的实现即可,关于循环依赖的问题,这后面会具体分析到。 ​ 一路跟踪,发现到这一行才开始真正创建对象。 这里不继续截图深入了,套娃太多。说几个重点,在实例化前后会执行一些实例化BeanPostProcessor,实例化完了之后就是填充属性、初始化,在初始化中会先后调用Aware、初始化BeanPostProcessor的before、用户自定义的init-method、初始化BeanPostProcessor的after方法。 上面截图是最终真是实例化对象的代码逻辑,可以看到最终是通过反射创建的对象。 总结下, 在这一步中完成了全部单例对象的创建,在实例化和初始化过程中,大致流程是: 实例化beanPostProcessorBefore —> 实例化对象 —> 实例化beanPostProcessorAfter —-> Aware —-> 初始化BeanPostProcessor —-> 初始化对象 —–> 初始化BeanPostProcessorAfter 。其中实例化是通过反射来实现的。 12. finishReresh() 最后一步做的事情就比较简单了,大概是关闭一些资源、初始化生命周期对象、更新生命周期状态到running、发布事件(ContextRefreshedEvent)等。至此,整个Spring的初始化流程就算是结束了。 3. 循环引用问题在第二节中,我们已经知道了Spring是如何初始化的了,以及在何时、如何创建bean的了。那么现在关于Spring是如何帮我们解决循环依赖的就很简单了。就是发生在第11步,finishBeanFactoryIntialization这个方法中。众所周知,Spring可以帮我们解决单例模式下的循环依赖问题。接下来我们就看下它是怎么解决的。 先记住一个关键类:DefaultSingletonBeanRegistry。 接下来注意看下创建bean的核心方法doGetBean中有一行getSingleton的方法调用。 此方法的实现如下(这个就是解决循环引用的核心逻辑): 这个方法有什么作用呢,就是在创建对象的过程中,spring不会直接创建一个全新的,而是先要去缓存里看下,是不是已经创建好了??? 如果没有创建好,是不是有那种已经创建的不完整的半成品呢? 如果连半成品都没有,那有没有什么别的实现,要求按照其他的方式来创建对象呢??? 如果这三者任一一个都能得到一个对象,那么就不会重新实例化对象了,只需要继续完善这个对象即可。如果这都得不到,说明确实没创建过,那就创建个新的。 这里重点关注这三个map: 先直接把结论抛出来把,这就是spring创建对象时用于解决循环引用的三级缓存(其实这么说也不准切,先这么说吧) singletonObjects : 这个map作为第一级缓存,存放的是完整的bean。 singletonFactories : 这个map存放了一个函数式接口对象作为value,用于自定义实现获取bean的方法。这个主要用在AOP中 earlySingletonObjects : 这个map存放的是半成品对象,对象实例化后,都会先进入这个map,在后续的填充属性过程中,如果能够填充成功,则进入singletonObjects这个map,否则的话就保留在这个map中,直到所有的对象都完整的创建出来了。 下面说下这个循环依赖的处理过程: 假设类A有一个属性B, 类B有属性A。 创建A对象时,先将未填充属性的A对象放入这个earlySingletonObjects中,继续尝试给A对象填充属性,发现了类B,类B此时在三级缓存中都不存在,创建类B对象。然后又将类B对象还未未填充属性时丢进earlySingletonObjects。 此时A对象可以成功的关联上类B对象了,就A对象而言,已经是一个完整的对象了,所以从earlySingletonObjects中删除,放到singletonObjects中,这样在IOC容器中就可以通过这个map取出完整的类A对象了。此时进行类B对象的属性填充,发现IOC容器中可以直接获取到A对象,则直接关联上,由此B对象也完整了,从earlySingletonObjects中删除,然后进入singletonObjects即可。这样通过singletonObjects和earlySingletonObejcts这两个缓存就解决了循环依赖的问题。 而这个singletonFactories什么时候使用呢? 可以看出来,在getBean没有get到时会去创建bean,而创建bean时如果发现bean是单例的且允许循环引用的并且此bean已经处于创建中了,则对此对象封装函数式回调,存入singletonFactories缓存中。 看getEarlyBeanReference方法的实现可以直到,如果bean定义不是合成的,且有实例化BeanPostProcessor需要执行,并且这个BeanPostProcessor对象是一个SmartInstantiationAwareBeanPostProcessor时,就调用这个SmartInstantiationAwareBeanPostProcessor对象的getEarlyBeanReference方法获取对象。 这里可以看到一个具体的实现,就是AOP代理。 AbstractAutoProxyCreator就实现了SmartInstantiationAwareBeanPostProcessor接口的getEarlyBeanReference方法。也就是说当一个对象被AOP代理时,会将代理对象缓存,然后在填充属性过程中就可以依赖到代理对象了。但是需要注意的是, 这里存入singleFactories这个缓存中的是一个lambda对象,真正获取到代理对象是在这个value被取出来执行时,才会创建代理对象。这应该是Spring刻意为之的吧,Spring应该是希望创建对象的过程更存粹,像AOP的处理,应该放在最终填出属性时再处理。如果抛开这个设计原则,二级缓存也可以解决循环引用,也可以搞定AOP,甚至就一级缓存也可以解决循环引用和AOP的处理。只是Spring这样处理之后,代码更清晰更易维护了。 至此,我们就完成了Spring源码探索的第一站啦~ 还有很多细节没有深入,但是到目前为止,我相信这张图已经被很好的佐证了: Spring这个超级工厂,真是将“根据开发者需求创建对象”这件事真是做到了极致。","categories":[{"name":"Spring","slug":"Spring","permalink":"https://sombreknight.gitee.io/categories/Spring/"},{"name":"Java","slug":"Spring/Java","permalink":"https://sombreknight.gitee.io/categories/Spring/Java/"}],"tags":[{"name":"Java","slug":"Java","permalink":"https://sombreknight.gitee.io/tags/Java/"},{"name":"Spring","slug":"Spring","permalink":"https://sombreknight.gitee.io/tags/Spring/"},{"name":"源码分析","slug":"源码分析","permalink":"https://sombreknight.gitee.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"},{"name":"框架","slug":"框架","permalink":"https://sombreknight.gitee.io/tags/%E6%A1%86%E6%9E%B6/"}]},{"title":"全方位总结中间件之RabbitMQ","slug":"WNC8rxhCVjxb8IWX","date":"2021-02-25T02:18:38.000Z","updated":"2021-02-25T02:18:35.000Z","comments":true,"path":"2021/02/25/WNC8rxhCVjxb8IWX/","link":"","permalink":"https://sombreknight.gitee.io/2021/02/25/WNC8rxhCVjxb8IWX/","excerpt":"","text":"快速索引 消息队列 AMQP & RabbitMQ 交换机 RabbitMQ可靠性传输 死信队列 消息补偿机制 延迟队列 消费顺序&消息幂等 企业级RabbitMQ框架封装 1. 消息队列关于什么是消息队列,这里不赘述,可以参考这篇文章 消息队列(mq)是什么? 。 2. AMQP & RabbitMQ RabbitMQ是采用 Erlang语言实现AMQP协议的消息中间件,AMQP全称是 Advanced Message Queue Protocolg,高级消息队列协议。它是应用层协议的一个开放标准,为面向消息的中间件设计,基于此协议的客户端与消息中间件可传递消息,并不受产品、开放语言等条件的限制。 RabbitMQ是AMQP协议的一个开源实现,其内部模型实际上也是 AMQP的内部模型,如下图所示: AMQP模型的工作流程如下:消息(Message) 被发布者 (publisher) 发送给交换机(exchange),交换机常常被比喻成邮局或者邮箱,然后交换机将收到的消息根据路由规则分发给绑定的队列(queue),最后AMQP代理会将消息投递给订阅此队列的消费者,或者消费者按照需求从队列中拉取消息。 总结下就是,AMQP定义了完整的消息队列协议,包括消息队列模型、消息确认机制等等,而RabbitMQ就是AMQP协议的一个broker实现, 有了RabbitMQ之后,还需要生产者和消费者也按照AMQP协议实现,从而完成整个基于AMQP协议的[producer]-Broker-comsumer消息通信。 3. 交换机RabbitMQ中一共实现了四种类型的交换机(Exchange),分别是: Fanout Direct Topic Headers 下面来一一介绍: 1. Fanout类型的交换机 发送到Fanout交换机的消息都会路由到与该交换机绑定的所有队列上,可以用来做广播 不处理路由键,只需要简单的将队列绑定到交换机上 Fanout交换机转发消息是最快的 2. Direct类型的交换机 把消息路由到BindingKey和RoutingKey完全匹配的队列中 3. Topic类型的交换机 上面说到,direct类型的交换器路由规则是完全匹配RoutingKey和BindingKey。topic和direct类似,也是将消息发送到RoutingKey和BindingKey相匹配的队列中,只不过可以模糊匹配。 RoutinKey为一个被“.”号分割的字符串(如com.rabbitmq.client)BindingKey和RoutingKey也是“.”号分割的字符串BindKey中可以存在两种特殊字符串“*”和“#”,用于做模糊匹配,其中“*”用于匹配不多不少一个词,“#”用于匹配多个单词(包含0个,1个)BindIngKey 能够匹配到的RoutingKeyjava.# ====> java.lang,java.util, java.util.concurrentjava.* ====> java.lang,java.util..uti ====> com.javashitang.util,org.spring.util 4. Headers类型的交换机 headers类型的交换器不依赖于路由键的匹配规则来路由消息,而是根据发送消息内容中的headers属性进行匹配。headers类型的交换器性能差,不实用,基本上不会使用。 4. RabbitMQ可靠性传输1. 生产者到Broker的可靠性传输1)方案一:事务 事务机制虽然保证了消息投递端的可靠性,但因为每次投递都开启了事务,所以性能较低,一般不推荐使用 为了保证这一步的可靠性,AMQP 协议在建立之初就提供了事务机制。RabbitMQ 客户端中与事务机制相关的方法有三个:channel.txSelect、channel.txCommit 以及 channel.txRollback。channel.txSelect 用于将当前的信道设置成事务模式,channel.txCommit 用于提交事务,而 channel.txRollback 用于事务回滚。在通过channel.txSelect 方法开启事务之后,我们便可以发布消息给 RabbitMQ 了,如果事务提交成功,则消息一定到达了 RabbitMQ 中,如果在事务提交执行之前由于 RabbitMQ 异常崩溃或者其他原因抛出异常,这个时候我们便可以将其捕获,进而通过执行 channel.txRollback 方法来实现事务回滚。 代码逻辑框架如下: 123456789101112try { // 开启事务 channel.txSelect(); // 发送消息 channel.basicPublish(exchange, routingKey, props, body); // 事务提交 channel.txCommit();} catch(Exception e) { // 事务回滚 channel.txRollback(); e.printStackTrace();} 2)方案二:Confirm机制 Confirm 机制就比较好的兼顾了性能以及可靠性 注意:事务机制和 Confirm 机制是互斥的,两者不能共存,会导致 RabbitMQ 报错。 开启 Confirm 机制后,所有在该信道上面发布的消息都会被指派一个唯一的ID(从 1 开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ 就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一 ID),这就使得生产者知晓消息已经正确到达了目的地了。RabbitMQ 回传给生产者的确认消息中的 deliveryTag 包含了确认消息的序号,此外 RabbitMQ 也可以设置 channel.basicAck 方法中的 multiple 参数,表示到这个序号之前的所有消息都已经得到了处理。 代码逻辑框架如下: 12345678910111213// 开启confirm机制channel.confirmSelect();// 添加回调监听器channel.addConfirmListener(new ConfirmListener() { @Override public void handleAck(long deliveryTag, boolean multiple) throws IOException { // TODO 消息投递成功 } @Override public void handleNack(long deliveryTag, boolean multiple) throws IOException { // TODO 消息投递失败 }}); confirm的流程如下图所示: 关于mandatory参数 当 mandatory 参数设为 true 时,如果 Exchange 无法根据自身的类型和路由键找到一个符合条件的队列的话,那么RabbitMQ 会调用 Basic.Return 命令将消息返回给生产者。而 mandatory 参数设置为 false 时,出现上述情形的话,消息直接被丢弃。那么生产者如何获取到没有被正确路由到合适队列的消息呢?这时候可以通过调用 channel.addReturnListener 来添加 ReturnListener 监听器实现。 代码逻辑框架如下: 1234567// 添加回调监听器 channel.addReturnListener(new ReturnListener() { @Override public void handleReturn(int replyCode, String replyText, String exchange, String routingKey, AMQP.BasicProperties properties, byte[] body) throws IOException { // TODO 消息routingKey未匹配到队列 } }); 关于备份交换机 生产者在发送消息的时候如果不设置 mandatory 参数,那么消息在未被路由的情况下将会丢失,如果设置了 mandatory参数,那么需要添加 ReturnListener 的编程逻辑,生产者的代码将变得复杂化。如果你不想复杂化生产者的编程逻辑,又不想消息丢失,那么可以使用备份交换器,这样可以将未被路由的消息存储在 RabbitMQ 中,再在需要的时候去处理这些消息。可以通过在声明交换器(调用 channel.exchangeDeclare 方法)的时候添加 alternate-exchange 参数来实现。 2. Broker自身的可靠性1) 持久化持久化可以提高 RabbitMQ 的可靠性,以免在 RabbitMQ 意外宕机时数据不会丢失,RabbitMQ 的 Exchange、Queue 以及 Message 都是支持持久化的,Exchange 和 Queue 通过在声明的时候将 durable 参数置为 true 即可实现,而消息的持久化则需要将投递模式(BasicProperties 中的 deliveryMode 属性)设置为2(PERSISTENT)。但需要注意的是,必须同时将 Queue 和 Message 持久化才能保证消息不丢失,仅设置 Queue 持久化,重启之后 Message 会丢失,反之仅设置消息的持久化,重启之后 Queue 消失,既而 Message 也丢失。 2) 集群上述持久化的操作保证了消息在 RabbitMQ 宕机时不会丢失,但却不能避免单机故障且无法修复(比如磁盘损毁)而引起的消息丢失,并且在故障发生时 RabbitMQ 不可用。这时就需要引入集群,由于 RabbitMQ 是基于 Erlang 编写的,所以其天生支持分布式,而不需要像 Kafka 那样要通过 Zookeeper 来实现,RabbitMQ Cluster 集群共有两种模式。 a) 普通模式普通模式下,集群中的 RabbitMQ 会同步 Vhost、Exchange、Binding、Queue 的元数据(即其本身的数据,例如名称、属性等)以及 Message 结构,而不会同步 Message 数据,也就是说,如果集群中某台机器 RabbitMQ 宕掉了,则该节点上的 Message 不可用,直至该节点恢复。 b) 镜像模式镜像队列相当于配置了副本,绝大多数分布式的东西都有多副本的概念来确保 HA(High Availability)。在镜像队列中,如果主节点(master)在此特殊时间内挂掉,可以自动切换到从节点(slave),这样有效的保证了高可用性,除非整个集群都挂掉。 3. Broker到消费者的可靠性传输为了保证消息从队列可靠地达到消费者,RabbitMQ 提供了消息确认机制(message acknowledgement)。消费者在订阅队列时,可以指定 autoAck 参数,当 autoAck 等于 fals e时,RabbitMQ 会等待消费者显式地回复确认信号后才从内存(或者磁盘)中移去消息(实质上是先打上删除标记,之后再删除)。当 autoAck 等于 true 时,RabbitMQ 会自动把发送出去的消息置为确认,然后从内存(或者磁盘)中删除,而不管消费者是否真正的消费到了这些消息。 这时大家可能会问,如果 RabbitMQ 在等待回调的过程中,消费者服务挂掉怎么办?对于 RabbitMQ 而言,队列中的消息分成了两个部分: 一部分是等待投递给消费者的消息; 一部分是已经投递给消费者,但是还没有收到消费者确认信号的消息。 如果 RabbitMQ 一直没有收到消费者的确认信号,并且消费此消息的消费者已经断开连接,则 RabbitMQ 会安排该消息重新进入队列,等待投递给下一个消费者。RabbitMQ 判断此消息是否需要重新投递的唯一依据是消费该消息的消费者连接是否已经断开,这种设计允许消费者消费一条消息很久很久。 如果消息消费失败,也可以调用 Basic.Reject 或者 Basic.Nack 来拒绝当前消息,但需要注意的是,如果只是简单的拒绝那么消息将会丢失,需要将相应的 requeue 参数设置为 true,RabbitMQ 才会将这条消息重新存入队列。而如果 requeue 参数设置为 false 的话,RabbitMQ 立即会把消息从队列中移除,而不会把它发送给新的消费者。 代码示例: 123456// 确认消息channel.basicAck(deliveryTag, multiple);// 拒绝消息channel.basicNack(deliveryTag, multiple, requeue);// 拒绝消息channel.basicReject(deliveryTag, requeue) PS:basicNack 和 basicReject 作用基本相同,主要差别在于前者可以拒绝多条,后者只能拒绝单条,另外basicNack 不是 AMQP 0-9-1 标准。 5. 死信队列 死信队列和上面提到的备份交换机有类似之处,同样也是声明一个 Exchange,如果一个消息因为被拒绝、过期或是队列已满等情况变成了死信,那么它会被重新发送到这个 Exchange 并路由到死信队列。而判断一个消息是否是死信主要有如下几条: 消费方拒绝消息时没有将 requeue 设置为 true 消息在队列中过期了(队列的过期时间可以通过 x-message-ttl 参数控制,或者发送消息时声明,同时存在取小值) 队列已经满了 使用方式也跟备份交换机很像,只不过这个是在申明队列的时候设置 x-dead-letter-exchange 参数。 6. 消息补偿机制除了以上的保障措施之外,为了防止生产者发送消息失败或者接收 RabbitMQ confirm 的时候网络断掉等,我们还需要一套完善的消息补偿机制,以下是目前业界主流的两种方案。 1. 消息落库,对消息进行状态标记 2. 延迟投递,做二次确认,回调检查 7.延迟队列1. RabbitMQ 3.6.x之前,一般采用死信队列+TTL过期时间来实现延迟队列这里不赘述,在第9节中,具体代码介绍这种方式是如何实现延迟队列的。这里重点说下,这种实现延迟队列方式的缺点: 如果统一用队列来设置消息的TTL,当梯度非常多的情况下,比如1分钟,2分钟,5分钟,10分钟,20分钟,30分钟……需要创建很多交换机和队列来路由消息。 如果单独设置消息的TTL,则可能会造成队列中的消息阻塞——前一条消息没有出队(没有被消费),后面的消息无法投递。 可能存在一定的时间误差。 关于这些缺点的体现,在第9节中具体介绍。 2. RabbitMQ 3.6.x 开始,RabbitMQ 官方提供了延迟队列的插件 插件官方地址: https://github.com/rabbitmq/rabbitmq-delayed-message-exchange 安装方式这里不介绍了,可以看官方文档。如果安装成功后,应该看到一个x-delayed-message类型的交换机,如下图所示: 这里需要提一下使用此插件的一个问题,在其项目的README末尾也提到这个局限性。 关键原话翻译出来就是: 该插件是在考虑磁盘节点的情况下创建的。当前不支持RAM节点,并且对它们的支持并不是优先考虑的事情 所以如果要使用此插件实现RabbitMQ的延迟队列,这点需要评估下。 8. 消费顺序&消息幂等1. 消费顺序​ 一般来讲,消费者是无序处理消息的。但是对于一些需要保证顺序的异步操作,比如,减库存->发通知,这两件事必须要按顺序进行,但是又都想让他们异步进行,这个时候如果不做特殊处理可能就会变成先发通知后减库存了。这种情况下可以只异步执行发通知,或者将发通知放在减库存异步执行成功时调用,或者减库存和发通知在一个消费者逻辑中同步执行。 具体业务情况需要具体设计处理。 一般不会强制让MQ顺序消费,因为那样会大大的降低MQ的性能。 2. 消息幂等这里不罗列解决方案了,通常都是通过一个记录一个唯一id的状态来避免重复消费。当然具体到消费者逻辑里面,还需要保证业务上操作的幂等。 9. 企业级RabbitMQ框架封装1. 封装后的使用方式首先创建一个MQ处理器,继承自MqProcessor 然后重写init方法 在init方法中添加注册消息消费的代码逻辑 然后就可以这样发送消息了 2. 源码解析1)关键方法实现分析 : MqProcessor#addReceiver我们从这个方法的实现分析中最主要想获得是以下几点: 如何创建普通队列的? 如何创建延迟队列的? 如何创建失败队列的? 在调用addReceiver方法后,会创建队列; 我们仔细看这个方法的实现: 先分别创建交换机: 再创建队列: 最后绑定交换机和队列: 然后创建返回监听容器: 然后通过监听容器的回调进行消费逻辑处理。这里面的处理逻辑非常丰富。包含了消息幂等处理、延迟消息处理、消费业务逻辑执行、错误&异常处理、消费重试等。接下来具体分析。 如何保证消息幂等的? 看到这一行代码: 可以看到其中通过查询另一个redisKey来决定是否要继续往下执行。那么这个key是用来干嘛的呢? 没错,就是框架对消息幂等的一个处理。如果这个消息已经消费过了,那么不会重复消费。 我们多跳几行,看下这个key是什么时候设置上的。 可以看到,框架中是通过缓存唯一Id的方式来进行消息幂等处理的。通过traceId+traceLevel+queueName形成一个消息的标示,由此来保证同一个消息如果已经消费成功了,不会重复消费。 何时执行延迟消息? 延迟消息的处理关键逻辑如下: 这里是判断是否需要转发到延迟队列的逻辑。如果消息中包含了hdf-message-d这个头部属性,并且取出的延迟时间相对当前时间大于1秒,则发送到延迟队列,否则直接消费。 转发到延迟队列的逻辑也很简单。 延迟队列本质上也是一个普通队列,只不过没有这个队列没有绑定消费者。当消息设置的TTL到达时,会自动通过队列中设置的死信队列配置,被rabbitmq转发到正常的队列,然后被监听正常队列的消费者消费。 这里为什么正常的队列可以消费到呢,因为在创建队列时,有一行代码正是将正常队列绑定到了delay这个交换机。因此,转发消息到delay,通过正常的routingKey可以匹配到正常的队列中,这样消息就可以被放到有消费者的队列里了。 如何实现消费失败自动重试的? 可以看到当消费者返回false,当出现rpcBizException,或其他异常时,会产生非零的状态码,于是进入后处理逻辑中。 可以看到,processFail的逻辑是:当重试次数少于5此,就重新发送到延迟队列,不过会设置一个更长的TTL时间;如果重试次数大于5时,直接转发到失败队列了。此时,我们需要认为去处理失败队列。 最后,消费完成后,不管是消费失败还是成功,都会ack告诉MQ,这个消息我处理完了。可以从MQ中删除这个消息了。 最后总结下,这样一个企业级MQ实现,其实就是通过完全手动确认的方式,完成了正常的消息队列创建、发消息、消费消息、以及延迟消息的转发、消费失败通过延迟重试的方式补偿以及提供失败队列,供系统维护者手动处理失败队列。 这样一个封装下,基本可以满足业务开发需要了。但是还是存在几个缺点: 生产者到MQ的消息投递没有保证真正的可靠性,需要业务方自己兜底; 延迟队列是使用死信队列+TTL方式实现,需要创建大量梯队的延迟队列,无法做到无消耗的随机时间延迟,并且这种延迟实现的方式存在一定的时间误差; 当然,在业务量一般的情况下,基本满足生产需要了。比较适合的才是最好的,没必要过于追求完美。毕竟,追求完美也是有代价的!","categories":[{"name":"中间件","slug":"中间件","permalink":"https://sombreknight.gitee.io/categories/%E4%B8%AD%E9%97%B4%E4%BB%B6/"}],"tags":[{"name":"中间件","slug":"中间件","permalink":"https://sombreknight.gitee.io/tags/%E4%B8%AD%E9%97%B4%E4%BB%B6/"},{"name":"RabbitMQ","slug":"RabbitMQ","permalink":"https://sombreknight.gitee.io/tags/RabbitMQ/"},{"name":"消息队列","slug":"消息队列","permalink":"https://sombreknight.gitee.io/tags/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/"}]},{"title":"Java虚拟机的体系结构---运行时数据区","slug":"TwKQVMOtQ3nL1Dry","date":"2021-02-20T07:44:10.000Z","updated":"2021-02-20T07:44:15.000Z","comments":true,"path":"2021/02/20/TwKQVMOtQ3nL1Dry/","link":"","permalink":"https://sombreknight.gitee.io/2021/02/20/TwKQVMOtQ3nL1Dry/","excerpt":"","text":"官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html 一、宏观角度 Java虚拟机规范中对于虚拟机结构的描述大概分为以下几个方面: 关于class文件格式(The class file format) 数据类型(Data Type) 原始类型和值(Primitive Types and Values) 参考类型和值(Reference Types and Values) 运行时数据区(Run-Time Data Areas) 帧(Frames) 对象的表示(Representation of Objects) 浮点运算( Floating-Point Arithmetic) 特殊方法(Special Methods) 异常(Exception) 指令集概览(Instruction Set Summary) 类库(Class Libraries) 公共设计与私有实现(Public Design, Private Implementation) 故如果可以遵循这些Java虚拟机的规范去设计,就可以正确的实现一个执行JVM语系的语言的解释平台。这里只对比较关键的部分Java虚拟机规范进行深入学习,以提升自己的视野,从更底层的角度了解Java虚拟机的工作原理。 二、运行时数据区关于Java虚拟机中的运行时数据区,规范中这样描述: The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine exits. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread exits. 翻译过来就是: Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会被销毁。其他数据区域是每个线程的。每线程数据区域在创建线程时创建,在线程退出时销毁。 至此,大概清楚了Java虚拟机对于程序执行期间所使用的运行时数据区大概是分成了两个部分: 公共的运行时数据区:随虚拟机启动而创建,随虚拟机退出而销毁 每个线程所使用的运行时数据区:随线程创建时创建,随线程退出时而销毁 继续深入,深入前先看下目录,对于运行时数据区,官方文档这样定义了目录: 所以我们现在大概知道了运行时数据区的组成: 1. PC寄存器(program counter register 程序计数器)官方介绍: The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method (§2.6) for that thread. If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native, the value of the Java Virtual Machine’s pc register is undefined. The Java Virtual Machine’s pc register is wide enough to hold a returnAddress or a native pointer on the specific platform. 翻译过来就是: Java虚拟机可以同时支持多个执行线程。每个Java虚拟机线程都有自己的pc(程序计数器)寄存器。在任何时候,每个Java虚拟机线程都在执行单个方法的代码,即该线程的当前方法。如果该方法不是Native方法,则pc寄存器包含当前正在执行的Java虚拟机指令的地址。如果线程当前执行的方法是Native方法,则Java虚拟机的pc寄存器的值是未定义的。Java虚拟机的pc寄存器足够宽,可以容纳特定平台上的返回地址或本机指针。 总结: PC寄存器就是运行时数据区中生命周期与线程关联的部分。每个线程的PC寄存器上记录了正在执行的Java虚拟机指令地址,但如果当前线程执行的是Native方法,PC寄存器上的值就是未定义。 2. Java虚拟机栈(Java Virtual Machine Stacks)官方介绍: Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames (§2.6). A Java Virtual Machine stack is analogous to the stack of a conventional language such as C: it holds local variables and partial results, and plays a part in method invocation and return. Because the Java Virtual Machine stack is never manipulated directly except to push and pop frames, frames may be heap allocated. The memory for a Java Virtual Machine stack does not need to be contiguous. In the First Edition of The Java® Virtual Machine Specification, the Java Virtual Machine stack was known as the Java stack. This specification permits Java Virtual Machine stacks either to be of a fixed size or to dynamically expand and contract as required by the computation. If the Java Virtual Machine stacks are of a fixed size, the size of each Java Virtual Machine stack may be chosen independently when that stack is created. A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of Java Virtual Machine stacks, as well as, in the case of dynamically expanding or contracting Java Virtual Machine stacks, control over the maximum and minimum sizes. The following exceptional conditions are associated with Java Virtual Machine stacks: If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a StackOverflowError. If Java Virtual Machine stacks can be dynamically expanded, and expansion is attempted but insufficient memory can be made available to effect the expansion, or if insufficient memory can be made available to create the initial Java Virtual Machine stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError. 翻译过来就是: 每个Java虚拟机线程都有一个与线程同时创建的私有Java虚拟机栈。Java虚拟机栈存储“栈帧”。Java虚拟机栈类似于传统语言(如C)的堆栈:它保存局部变量和部分结果,并在方法调用和返回中起作用。因为除了推送和弹出帧之外,Java虚拟机栈从不被直接操作,所以帧可以被堆分配。Java虚拟机栈的内存不需要是连续的。 在Java虚拟机规范的第一版中,Java虚拟机栈被称为Java栈(Java Stack)。 这个规范允许Java虚拟机栈具有固定的大小,或者根据计算的需要动态地扩展和收缩。如果Java虚拟机栈的大小是固定的,则在创建该堆栈时,可以独立选择每个Java虚拟机堆栈的大小。 Java虚拟机实现可以向程序员或用户提供对Java虚拟机栈的初始大小的控制,以及在动态扩展或收缩Java虚拟机栈的情况下,控制最大和最小大小。 以下异常情况与Java虚拟机栈相关: 如果线程中的计算需要比允许的更大的Java虚拟机栈,则Java虚拟机抛出stackoverflower。 如果可以动态扩展Java虚拟机栈,并且尝试扩展,但无法提供足够的内存来实现扩展,或者如果内存不足,无法为新线程创建初始Java虚拟机栈,则Java虚拟机将抛出OutOfMemoryError。 总结:Java虚拟机栈也是数据运行时数据区中与线程生命周期关联的部分。 Java虚拟机栈在第一版规范中也被叫做Java堆栈。 Java虚拟机栈的功能就是保存线程中的局部变了和部分结果,并且在方法调用和返回中起作用。Java虚拟机栈内存不需要是连续的。Java虚拟机栈可以设置固定大小,也可以动态扩展收缩。 3. 堆(Heap)官方介绍: The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated. The heap is created on virtual machine start-up. Heap storage for objects is reclaimed by an automatic storage management system (known as a garbage collector); objects are never explicitly deallocated. The Java Virtual Machine assumes no particular type of automatic storage management system, and the storage management technique may be chosen according to the implementor’s system requirements. The heap may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger heap becomes unnecessary. The memory for the heap does not need to be contiguous. A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the heap, as well as, if the heap can be dynamically expanded or contracted, control over the maximum and minimum heap size. The following exceptional condition is associated with the heap: If a computation requires more heap than can be made available by the automatic storage management system, the Java Virtual Machine throws an OutOfMemoryError. 翻译过来就是: Java虚拟机有一个堆,在所有Java虚拟机线程之间共享。堆是运行时数据区域,从中为所有类实例和数组分配内存。 堆是在虚拟机启动时创建的。对象的堆存储由自动存储管理系统(称为垃圾回收器)回收;对象从不显式释放。Java虚拟机不假设特定类型的自动存储管理系统,存储管理技术可以根据实现者的系统需求进行选择。堆可以是固定大小的,也可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩堆。堆的内存不需要是连续的。 Java虚拟机实现可以让程序员或用户控制堆的初始大小,如果堆可以动态扩展或收缩,还可以控制堆的最大和最小大小。 以下异常情况与堆关联: 如果计算需要的堆超过了自动存储管理系统所能提供的堆,Java虚拟机将抛出OutOfMemoryError。 总结:Java虚拟机中的堆是运行时数据区中的公共部分,生命周期与虚拟机绑定,所有的线程可共享。主要功能就是为所有的类实例和数组分配内存。垃圾回收针对的就是这个堆内存区域。堆的大小可以是固定的,也可以动态扩展收缩。Java虚拟机不设定限制垃圾回收器,使用者可以自己根据实际的系统需求选择使用哪些垃圾回收器。 4. 方法区(Method Area)官方介绍: The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the “text” segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization. The method area is created on virtual machine start-up. Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it. This specification does not mandate the location of the method area or the policies used to manage compiled code. The method area may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger method area becomes unnecessary. The memory for the method area does not need to be contiguous. A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the method area, as well as, in the case of a varying-size method area, control over the maximum and minimum method area size. The following exceptional condition is associated with the method area: If memory in the method area cannot be made available to satisfy an allocation request, the Java Virtual Machine throws an OutOfMemoryError. 翻译过来就是: Java虚拟机有一个在所有Java虚拟机线程之间共享的方法区域。方法区类似于常规语言编译代码的存储区,或类似于操作系统进程中的“文本”段。它存储每个类的结构,如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。 方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能选择不进行垃圾收集或压缩。此规范不要求方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小的,或者可以根据计算的需要进行扩展,并且可以在不需要更大的方法区域时收缩。方法区域的内存不需要是连续的。 Java虚拟机实现可以提供程序员或用户对方法区域的初始大小的控制,以及在大小不同的方法区域的情况下,控制最大和最小方法区域大小。 以下异常情况与方法区域有关: 如果方法区域中的内存无法用于满足分配请求,Java虚拟机将抛出OutOfMemoryError。 总结:方法区是运行时数据区中的公共部分,生命周期与虚拟机绑定,所有线程可共享。其实方法区只是一个逻辑概念,本质上方法区就是堆的一部分。但是因为方法区存储的通常都是常量、字段、方法数据、代码文本等,所以可以不进行垃圾回收,故Java虚拟机规范单独把方法区拆出来作为运行时数据区的一部分。方法区的内存不需要是连续的,方法区的大小也可以由用户自行设定。 5. 运行时常量池(Run-Time Constant Pool)官方文档: A run-time constant pool is a per-class or per-interface run-time representation of the constant_pool table in a class file (§4.4). It contains several kinds of constants, ranging from numeric literals known at compile-time to method and field references that must be resolved at run-time. The run-time constant pool serves a function similar to that of a symbol table for a conventional programming language, although it contains a wider range of data than a typical symbol table. Each run-time constant pool is allocated from the Java Virtual Machine’s method area (§2.5.4). The run-time constant pool for a class or interface is constructed when the class or interface is created (§5.3) by the Java Virtual Machine. The following exceptional condition is associated with the construction of the run-time constant pool for a class or interface: When creating a class or interface, if the construction of the run-time constant pool requires more memory than can be made available in the method area of the Java Virtual Machine, the Java Virtual Machine throws an OutOfMemoryError. See §5 (Loading, Linking, and Initializing) for information about the construction of the run-time constant pool. 翻译过来就是: 运行时常量池是类文件中常量池表的每个类或每个接口的运行时表示。它包含几种常量,从编译时已知的数字字面值到必须在运行时解析的方法和字段引用。运行时常量池的功能类似于传统编程语言的符号表,尽管它包含的数据范围比典型的符号表更广。 每个运行时常量池都是从Java虚拟机的方法区域分配的。类或接口的运行时常量池是在Java虚拟机创建类或接口时构造的。 以下异常情况与类或接口的运行时常量池的构造相关: 在创建类或接口时,如果构建运行时常量池所需的内存超过了Java虚拟机的方法区域中可用的内存,则Java虚拟机将抛出OutOfMemoryError。 总结:运行时常量池属于方法区,方法区数据堆,所以运行时常量池同方法区一样,也是一个逻辑概念,同样也是运行时数据区中的公共部分,与虚拟机生命周期绑定,所有线程共享。 6. 本地方法栈(Native Method Stacks)官方文档: An implementation of the Java Virtual Machine may use conventional stacks, colloquially called “C stacks,” to support native methods (methods written in a language other than the Java programming language). Native method stacks may also be used by the implementation of an interpreter for the Java Virtual Machine’s instruction set in a language such as C. Java Virtual Machine implementations that cannot load native methods and that do not themselves rely on conventional stacks need not supply native method stacks. If supplied, native method stacks are typically allocated per thread when each thread is created. This specification permits native method stacks either to be of a fixed size or to dynamically expand and contract as required by the computation. If the native method stacks are of a fixed size, the size of each native method stack may be chosen independently when that stack is created. A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the native method stacks, as well as, in the case of varying-size native method stacks, control over the maximum and minimum method stack sizes. The following exceptional conditions are associated with native method stacks: If the computation in a thread requires a larger native method stack than is permitted, the Java Virtual Machine throws a StackOverflowError. If native method stacks can be dynamically expanded and native method stack expansion is attempted but insufficient memory can be made available, or if insufficient memory can be made available to create the initial native method stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError. 翻译过来就是: Java虚拟机的实现可以使用传统的堆栈(俗称“C堆栈”)来支持Native方法(用Java编程语言以外的语言编写的方法)。Native方法栈也可用于实现Java虚拟机指令集的解释器,如C语言。不能加载本机方法且本身不依赖传统堆栈的Java虚拟机实现不需要提供本机方法栈。如果提供了本机方法堆栈,则通常在创建每个线程时为每个线程分配。 此规范允许本机方法堆栈可以是固定大小的,也可以根据计算的需要动态扩展和收缩。如果本机方法堆栈的大小是固定的,则在创建该堆栈时,可以独立选择每个本机方法堆栈的大小。 Java虚拟机实现可以为程序员或用户提供对本机方法堆栈初始大小的控制,以及在大小不同的本机方法堆栈的情况下,控制最大和最小方法堆栈大小。 以下异常情况与本机方法堆栈相关: 如果线程中的计算需要比允许的更大的本机方法堆栈,Java虚拟机将抛出StackOverflowError。 如果可以动态扩展本机方法堆栈并尝试扩展本机方法堆栈,但可用内存不足,或者如果可用内存不足,无法为新线程创建初始本机方法堆栈,则Java虚拟机将抛出OutOfMemoryError。 总结:本地方法栈是用来支持native方法的,本地方法栈不是每个JVM实现所必须的(如果你设计的这个JVM实现根本就不需要执行native方法,那你就根本没必要设计这个本地方法栈),如果需要设计本地方法栈,则需要和线程生命周期绑定。这个本地方法栈是每个线程独享的,属于运行时数据区中非公共部分。 根据官方虚拟机规范文档,可以得出运行时数据区的逻辑关系如下: 补充: 实际上Hotspot虚拟机并没有区分Java虚拟机栈和本地方法栈,直接通用了。 三、运行时数据区在Java6/7/8中的对比 在Java6的运行时数据区中,运行时常量池–(属于)-> 方法区 –(属于)–> 堆 , 按照堆内存的分代划分,方法区属于永久代。直接内存用于NIO。Java堆用于存放对象的实例。而方法区存放类信息、运行时常量池、静态变量、字符串常量池。 在Java7的运行时数据区中,运行时常量池–(属于)-> 方法区 –(属于)–> 堆 , 按照堆内存的分代划分,方法区属于永久代。直接内存用于NIO。Java堆用于存放对象的实例、静态变量、字符串常量池。而方法区存放类信息、运行时常量池。 在Java8的运行时数据区中,方法区直接放到了本地内存中的元空间中(Java8取消了永久代,新增了元空间,元空间直接放在本地内存中的)。所以此时本地内分为NIO所使用的直接内存和元空间。Java堆用于存放对象的实例、静态变量、字符串常量池。而方法区存放类信息、运行时常量池。 以上图片来自于网络","categories":[{"name":"Java","slug":"Java","permalink":"https://sombreknight.gitee.io/categories/Java/"},{"name":"虚拟机","slug":"Java/虚拟机","permalink":"https://sombreknight.gitee.io/categories/Java/%E8%99%9A%E6%8B%9F%E6%9C%BA/"}],"tags":[{"name":"Java","slug":"Java","permalink":"https://sombreknight.gitee.io/tags/Java/"},{"name":"JVM","slug":"JVM","permalink":"https://sombreknight.gitee.io/tags/JVM/"}]},{"title":"《高并发红包炸弹项目性能优化》系列三:接口性能优化","slug":"N2ci7yWyasJw5Jbu","date":"2021-01-15T12:27:03.000Z","updated":"2021-01-15T12:26:45.000Z","comments":true,"path":"2021/01/15/N2ci7yWyasJw5Jbu/","link":"","permalink":"https://sombreknight.gitee.io/2021/01/15/N2ci7yWyasJw5Jbu/","excerpt":"","text":"一、开始​ 在上一篇文章《高并发红包炸弹项目性能优化》系列二:方案设计 中,我具体介绍了下该红包炸弹需求的实现方案,包括数据库表的设计、前端的限流请求措施、服务端关键接口的代码实现。在这一篇博客中,我将从服务端接口代码优化开始,逐步展开对整个红包炸弹项目的优化历程。毕竟,最重要的还是核心的业务接口。接口写得性能够好了,就成功一半了。剩下的无非是采取一些常见的手段,进行二次优化而已。真正需要动刀子的,还是在业务接口上。 二、接口优化原则​ 面对接口的性能优化,我这样思考: ​ 接口,做了什么事?无非就是读/写。所有的网络请求都是在读/写。那优化的最终目标是什么? ​ 答案就是:让单位时间能够支持“更多”的读/写请求! ​ 剩下的问题就很简单了,我们采取各个击破的办法。 如何让读取更快 如何让写入更快 接下来就这两点,我来谈谈自己的处理意见。 1. 如何让读取更快 减少DB查询,能走缓存就一定要走缓存;特殊业务场景要,可以在硬件上加大投入,给Redis集群进行扩容什么的都是OK的; 既然要走缓存,一定要考虑到数据一致性、缓存穿透、缓存击穿、缓存雪崩这三个问题,既然要用缓存就一定要用好缓存; 实在要走库的查询,一定要注意查询性能。主要从以下两方面优化: 建立好自己的业务数据表模型,该垂直拆表的地方就拆,该冗余字段的地方就冗余,尽量保证自己的业务接口使用简单SQL查询。当然不管拆表还是冗余都是有代价的,可能需要维护数据的一致性,可能需要开事务进行多表写入。具体问题具体分析。 一旦数据表一定,很难再做修改的情况下,那就要更多地在SQL优化上下功夫。一般建议SQL查询计划至少要达到ref级别,能到const级别那当然更好; 2. 如何让写入更快 写入的话,能写缓存,当然不建议写库。但是对于缓存,要考虑的问题更多,选用哪种数据结构,怎样保证数据一致性,什么时候缓存落库,缓存淘汰策略是什么,还有不要造成大面积缓存失效导致的缓存雪崩; 直接写库的话,要考虑下是否要优化表结构,尽可能在设计的时候就考虑减少锁冲突,表设计时还可以考虑使用乐观锁,能不用事务就不用事务,可以考虑定时任务来兜底,也可以考虑走MQ进行异步写库; 要考虑数据所使用的事务隔离级别,隔离级别越高,并发性越低;如果根本不存在事务问题,不使用带事务支持的数据库引擎也OK; 三、高并发下还需要考虑哪些优化 如果用到了分布式锁,一定要保证锁的及时释放,锁不及时释放,反而可能导致接口的吞吐量降低;另外就是分布式锁的选型,一般有基于Redis、Zookeeper以及数据库实现这三种,一般来说Redis锁更适合高并发下使用,Redis实现分布式锁相对于ZK和数据库来说也更简单; 还需要再提下分布式锁的使用,在高并发下不推荐使用带有超时退出的自旋锁,因为线程自旋会加大CPU的使用负担,同时也会持续占用线程,如果有大量的线程自旋,会导致把机器上的最大线程数打满(这个机器的线程数在Linux上是可以配置的,但仍然有上线),如此一来服务就不能分配出可用的线程继续提供服务了,会直接导致高并发下接口的吞吐量降低; 线程池技术的使用。要注意线程池的参数配置、以及阻塞队列是否有界、容量最大为多少,避免高并发下造成OOM,另外还需要注意使用线程池时线程复用下会不会有内存泄漏的问题,在合理的节点进行内存释放; 接口限流。在不考虑实际业务场景时,限流根本目的就是就是为了保护DB,因为在大多数情况下,DB是公共资源,不能因为一个业务,就把整个公共资源搞挂吧; 优化代码逻辑。所谓条条大路通罗马,但是通往罗马所付出的代价却是不一样的。简单的说就是用更好的算法处理业务逻辑。更好的算法往往可以更快的处理完业务逻辑,占用更小的空间,更少的IO次数。面对高并发,我们有必要绞尽脑汁去思考更聪明的做法。 OK,至此,我总结了一些自己对于优化接口性能这件事上的一些宏观上的认识。当然性能优化可以从各个角度入手,远远不止我提到的这些。对于性能优化,我们应该采用贪心算法的思想,在各个环节优化到最好,最后的整体性能一定不会低到哪儿去。接下来,我们就正式开始进行红包炸弹项目的关键接口性能优化工作。 四、简单优化创建红包接口​ 在《高并发红包炸弹项目性能优化》系列二:方案设计 我贴上了项目交接前红包炸弹的具体创建接口的实现代码。 这里我再次把红包炸弹的创建逻辑再次说明下,如下图中的表结构所示,创建红包炸弹其实就是在红包表中新增一条红包记录,以及新增与此红包记录所关联的”若干”条子红包记录。这里的“若干”就是红包表中的num字段所指定的数量。 图1-红包炸弹的表设计 这里再补充一下需求,产品认为同一时间只能有一个正在进行中的红包。也就是说,如果在创建红包时还有处于未开抢的,或者正在被抢的红包,或者还没有置完成的红包,那么这时不能投放新的红包的。另外,投放红包只有管理员才可以进行操作。因为对于此接口我们并没有太多的并发度要求。我只要保证此接口可以逻辑正确、可靠的执行完成就OK了。 图2-创建红包接口的优化前后diff截图 如上图2所示,是对创建接口的一些基本优化后的前后diff截图。 在创建接口的入口处,添加了一处判断逻辑: 12345//爆炸时间必须至少在当前时间的1分钟后,这样才能做到爆炸前60s通知IM if (!startTime.after(new HaoDate().offsetMinutes(1))){ return RpcResponse.error(ErrorCode.RED_ENVELOPE_TIME_ERROR); } ​ 为什么加这么一个判断呢,因为按照需求,我们需要在爆炸前60s发送一个IM消息,通知前端开始进行红包炸弹的一分钟倒计时。如果在创建红包时所指定的红包炸弹炸开时间在将来的不到一分钟内。那么我们无法就无法满足需求了。即红包一创建,里炸开时间就已经小于60秒了,这种情况下进行红包60秒倒计时显然是不合适的。 优化锁使用,这里应该明显是应该先获取锁,再判断是否还有进行中的红包。使用try…finally…保证锁最终会被释放。 12345678910111213HdfAssert.isTrue(redisLocker.lock(redisLock),ErrorCode.ACTION_FAST);try { if (redEnvelopeLogic.isExistDoingRedEnvelope()){ return RpcResponse.error(ErrorCode.EXIST_DOING_RED_ENVELOPE); } redEnvelopeLogic.create(num, price, inspectId, TYPE_AUDITOR, startTime); redEnvelopeLogic.sendNoticeToIM(redEnvelopeDAO.queryLatestRedEnvelope(), CelebrationConstants.Hdf_Celebration_Bomb);}finally { redisLocker.unlock(redisLock);}return RpcResponse.success("创建成功"); 另外这里,为了防止MySQL的主从延迟导致调用上面的第3行redEnvelopeLogic.isExistDoingRedEnvelope()这个方法得到错误的结果,对于此查询强制走主库。 深入优化创建红包逻辑,如下图3所示 图3-创建红包的核心逻辑修改后的diff 这里改动逻辑是: 创建红包记录,要关注其返回值,确认创建成功了,才继续进行后续的创建子红包任务、创建自动完成延迟任务、创建倒计时延迟任务; 创建红包成功后,这里将红包id缓存了一份在Redis中。用于记录最新的正在进行中的红包id。这里缓存此id其实是为了后续优化用户端调用的接口查询,将在后续讲解到解决IM推送不及时的问题时提到其用途; 红包炸开后15秒自动完成以及红包炸开前1分钟倒计时,原本是使用延迟MQ实现,这里改使用ScheduledThreadPoolExecutor实现了。因为这个延迟MQ需要随机计算出一个从创建红包的时刻到目标时刻的延迟时间,每次创建都有可能产生一个延迟MQ队列,受限于公司的MQ架构延迟队列数量限制,此方法不可取。所以这里使用了ScheduledThreadPoolExecutor来实现。 具体封装如下: 123456789101112131415161718192021222324@Componentpublic class ScheduledThreadPool { private ScheduledThreadPoolExecutor scheduledThreadPoolExecutor; @PostConstruct public void init() { int coreSize = Runtime.getRuntime().availableProcessors(); scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(coreSize , Executors.defaultThreadFactory() , new ThreadPoolExecutor.CallerRunsPolicy()); } /** * 延迟调用 * * @param runnable runnable * @param time 秒数 */ public void delayCall(Runnable runnable, long time) { scheduledThreadPoolExecutor.schedule(runnable, time, TimeUnit.SECONDS); } @PreDestroy public void destroy(){ scheduledThreadPoolExecutor.shutdown(); }} 因为在这个业务可以提前确认只会有1分钟倒计时和15秒自动完成这两个延迟任务,所以对于这个ScheduledThreadPoolExecutor的参数配置,并无太多考究。可以用即可。 但是ScheduledThreadPoolExecutor也存在一个严重的问题,那就是这不是一个分布式的调度器,也不具备持久化任务的能力,所以在服务重启等特殊情况下,延迟任务可能丢失。具体使用一定要看场景。 比如,在红包炸弹里,15s自动完成的延迟任务是这样使用ScheduledThreadPoolExecutor的: 123456789101112131415161718192021222324252627282930313233private void setAutoComplete(RedEnvelopeDO redEnvelopeDO) { long now = HaoDate.currentTimeSecond(); long delay = redEnvelopeDO.getTime().getTimeSecond() - now + Long.parseLong(cloudCountDown) + 5; if (delay <= 0) { delay = 0; } RedEnvelopeMessage msg = new RedEnvelopeMessage(); msg.setRedEnvelopeId(redEnvelopeDO.getId()); //存redis标记,表示又一个异步任务待执行 redisClient.set(REDIS_KEY_TASK_WAITING_AUTO_COMPLETE_FLAG, redEnvelopeDO.getId().toString()); scheduledThreadPool.delayCall(() -> { this.autoComplete(msg); //清redis标记,表示该异步任务执行完成了 redisClient.delete(REDIS_KEY_TASK_WAITING_AUTO_COMPLETE_FLAG); }, delay);} @PostConstructprivate void init(){ ... //查询异步任务标记是否存在,存在说明上一次任务没有执行完,bean就被销毁了,重新拉起调度 String redEnvelopeIdStr = redisClient.get(REDIS_KEY_TASK_WAITING_AUTO_COMPLETE_FLAG); if (StringUtils.isNotEmpty(redEnvelopeIdStr) && NumberUtils.isNumber(redEnvelopeIdStr)) { long redEnvelopeId = Long.parseLong(redEnvelopeIdStr); if (redEnvelopeId > 0) { RedEnvelopeDO redEnvelope = redEnvelopeDAO.findById(redEnvelopeId); if (Objects.nonNull(redEnvelope) && redEnvelope.getCompleteTime().isZeroTime()) { //需要重新拉起任务 this.setAutoComplete(redEnvelope); } } }} 如代码所示,在将延迟任务丢给ScheduledThreadPoolExecutor前,将红包id缓存到了用于表示未完成的任务标志redis key中,在延迟任务调度完成后,删除缓存key。这样在服务重启后,当bean被初始化时,回调到init方法,此时再次检查redis中是否还有记录有未完成的红包id。有的话,就让他重新进延迟。说到底,这是一个兜底的方案,因为真有高并发的抢红包状况发生的话,一定在红包炸开后很快就被抢完,用户的动作会主动触发置红包完成。 再回到图3中改动后的178行,看下创建子红包的改动: 图4-子红包的创建与子红包缓存 如上图中的diff所示,主要改动的是关注了子红包创建的返回值,只有当子红包创建成功的时候,才会向Redis中维护子红包id的List进行Rpush。可以说这里非常重要,如果无法维护子红包和缓存中的子红包List的数据一致性,则可能出现以下两种情况: 某一子红包写库成功了,但是写入redis的失败了:这种情况还好,因为抢红包不可能抢到没有写入库的子红包,不会出现超抢 某一子红包写库失败了,但是写入redis的成功了:这种情况就会出现问题,因为可能抢到的子红包id,在子红包表中并不存在,如果抢红包的接口逻辑不足够健壮,可能导致用户虚抢一场 最后,可能大家会发现,这里并没有使用事务来批量创建数据。原因是,这里可以不用考虑事务,因为创建丢失某个子红包也无伤大雅,顶多就是用户抢不到这个红包了,可以接受。 五、深入优化抢红包接口在第四节里,我简单介绍了下创建红包的接口做了哪些小优化,因为创建接口并不难,所以对此接口也没有太高的性能要求,只要保证接口是好使的,子红包缓存是可靠的即可。接下来的优化抢红包接口才是重头戏。 图5-创建红包后的存储模型 如上图5所示,在创建红包成功后,数据的存储模型应该是这样的。缓存中既存入了正在进行中的红包id,也存如了子红包的id所组成的List。 所以抢红包要做的事情很简单: 基本的条件校验,不满足则不能抢 尝试从子红包id列表缓存中Lpop一个子红包id出来,如果pop不出来数据了,说明红包被抢完了 Lpop出有效的红包后,更新子红包状态,创建一条红包明细记录 看下整体代码优化Diff如下: 这里具体说下在抢红包接口中都做了哪些优化: 首先是在原来的基础校验逻辑上补充了判断,当红包已经完成后,就直接返回,不需要在走后续的代码逻辑了。这里需要说明的是原来的表结构对于状态位的设计是太合理的。红包的状态目前是需要根据status和complete这两个字段共同判断的,但是由于项目已上线,红包表并非只有红包炸弹这个模块在使用,无法更改了,只好对两个字段都进行考虑了; 然后是对于判断用户是否报名了,原来是直接sql查询报名表。现在在这个applyUserLogic.getApplyUserByUserId(userId)方法上维护了一层缓存,处理逻辑如下: 123456789101112131415161718192021222324public ApplyUserDO getApplyUserByUserId(Long userId) { ApplyUserDO applyUser = null; String redisKey = CELEBRATION + GET_APPLY_USER_BY_USER_ID + userId; String cache = cacheUtil.getCache(redisKey); if (Objects.equals(NULL,cache)){ //这里是刻意缓存了NULL,表示不用再次查库了,查库也是null,以防止恶意缓存穿透,从而保护DB return null; } if (StringUtils.isNotEmpty(cache)){ applyUser = JsonUtils.toObject(cache, ApplyUserDO.class); } if (Objects.nonNull(applyUser)){ return applyUser; } applyUser = applyUserDAO.findApplyUserByUserIdAndStatus(userId, ApplyUserConstants.STATUS_DONE); if (Objects.nonNull(applyUser)){ //随机缓存5到10分钟,防止缓存大面积失效导致缓存雪崩 cacheUtil.setCache(redisKey, JsonUtils.toJson(applyUser), (long) RandomUtils.randomInt(MINUTE_5,MINUTE_10)); }else{ //如果不存在,缓存一个NULL,防止恶意的缓存穿透 cacheUtil.setCache(redisKey, NULL,(long) RandomUtils.randomInt(MINUTE_5,MINUTE_10)); } return applyUser; } 如上面的代码所示,这里编程式地维护了一层缓存,并且考虑到了缓存穿透和缓存雪崩。另外在新增报名记录、报名状态变更时对缓存进行了失效处理,这里就不贴代码了。 再往后面,原来通过sql查询,分别先后判断了此用户是否已经抢过当前红包了、一天内是否已经成功抢了三次红包。这里优化为一处缓存查询就可以了。如下面的代码所示,getGrabSuccessEnvelopeIds方法的作用就是获取此用户在今天所有抢到的红包id所组成的List。所以可以将代码逻辑优化为先判断是否超过次数,再判断是否已经抢过此红包。将两次sql查询,转换为一次Redis IO。 1234567891011121314151617181920212223List<Long> grabSuccessEnvelopeIds = getGrabSuccessEnvelopeIds(userId); //判断是否超过次数 if (grabSuccessEnvelopeIds.size() >= overLimit){ grabRedEnvelopeInfoVO.setType(OVER_GAIN.getType()); return RpcResponse.success(grabRedEnvelopeInfoVO); } //判断是否已经抢过红包,这里不可能进来昨天的红包id,前面就拦截了 if (grabSuccessEnvelopeIds.contains(redEnvelopeId)){ grabRedEnvelopeInfoVO.setType(ALREADY_GAIN.getType()); return RpcResponse.success(grabRedEnvelopeInfoVO); } /** * 获取今日已抢成功的红包id列表 * @param userId 用户id * @return 已抢成功的红包id列表 */ private List<Long> getGrabSuccessEnvelopeIds(long userId){ String todayDateString = new HaoDate().dateString(); List<String> envelopeIdStrList = redisClient.lrange(ONE_DAY_GRAB + todayDateString + userId, 0, -1); return envelopeIdStrList.stream().map(Long::parseLong).collect(Collectors.toList()); } 需要注意的是,这里没有用HASH来存储此缓存是因为目前公司的框架对于Redis提供的Hash相关的api实在难用之际,还不如使用List来实现。getGrabSuccessEnvelopeIds方法中使用了redis的LRANGE操作,这是一个时间复杂度为O(N)的操作,一般情况要慎用。但是这里这处缓存,我们明确可以知道list最大长度为3。因为一条最多能抢成功三次红包。所以LRANGE的时间复杂度对接口的影响可以忽略。 再往后,我优化了Redis锁的使用。 首先是把原本的tryLock设定的5秒超时时间置0了。之所以这样做是因为,在当前这样的高并发需求下,我们需要接口能够处理更多的请求,但是机器可以开启的线程数是有上限的,所以我们不能让线程在获取分布式锁这件事情上自旋等待超时了,正确的做法应该是拿不到锁就立即返回,把线程资源释放出来给别的请求使用。 然后是调整了锁的位置。原来的锁位置台靠前了。获取锁后,还有各种条件判断,在返回语句中还要释放锁。万一中间某一步有异常抛出,这么操作的无法保证锁真的可以及时释放。分布式锁应该锁住的是最核心的需要处理并发的代码块。 获取到锁后,在原先的实现中会先从redis中弹出子红包id,而后又llen查询了一遍子红包id缓存的List长度,如果为空的就将红包置完成。这里的实现又有两个问题被优化: 尝试LPOP一个子红包id,原逻辑中通过try…catch…来区分是不是取到了非正常的子红包id,如果出现异常,则认为子红包已经抢完了。这里完全不需要这么搞,完全可以避免此处异常的判断。 主动LLEN查看子红包idList的长度,来置红包状态完成。这也是一个不必要且拉低接口性能的操作。首先完全可以由用户在LPOP为空时置完成,其次就又回到了LLEN的时间复杂度问题,List的长度越大,LLEN的性能越低,再List没有取空的时候,做这么多LLEN操作完全是在浪费时间。这一次IO完全是可以省去的。 优化代码如下,只需要一次IO操作进行一个时间复杂度为O(1)的LPOP操作即可: 123456789String redEnvelopeItemIdStr = redisClient.lpop(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId()); //如果不是一个数字,说明取空了,置红包状态即可 if (!NumberUtils.isNumber(redEnvelopeItemIdStr)) { setRedEnvelopeComplete(redEnvelopeId); grabRedEnvelopeInfoVO.setType(NO_GAIN.getType()); //将缓存中记录的正在进行的红包id值清0 redEnvelopeLogic.removeDoingRedEnvelopeIdCache(); return RpcResponse.success(grabRedEnvelopeInfoVO); } 再往后这一处优化,我觉得都不能算优化吧。应该是意识问题了,原逻辑这么写的: 12345678910111213141516171819RedEnvelopeItemDO redEnvelopeItemDO = redEnvelopeItemDAO.findById(redEnvelopeItemId);//这里都不需要对redEnvelopeItemDO判个空么? jdbcDAO.withTransaction(()->{ RedEnvelopeRefDO redEnvelopeRefDO = new RedEnvelopeRefDO(); redEnvelopeRefDO.setPrice(redEnvelopeItemDO.getPrice()); redEnvelopeRefDO.setType(TYPE_AUDITOR); redEnvelopeRefDO.setRedEnvelopeItemId(redEnvelopeItemDO.getId()); redEnvelopeRefDO.setApplyUserId(applyUserDO.getId()); redEnvelopeRefDO.setUserId(applyUserDO.getUserId()); redEnvelopeRefDAO.save(redEnvelopeRefDO); redEnvelopeItemDO.setStatus(ITEM_STATUS_DONE); redEnvelopeItemDAO.update(redEnvelopeItemDO); }); grabRedEnvelopeInfoVO.setGain(true); grabRedEnvelopeInfoVO.setType(GAIN.getType()); grabRedEnvelopeInfoVO.setPrice(redEnvelopeItemDO.getPrice()); ... return RpcResponse.success(grabRedEnvelopeInfoVO); 这里有什么问题,问题就出在存在多处不严谨: findById后返回的redEnvelopeItemDO对象竟然没判空。如果说创建红包时,能够确认存子红包到数据库成功后再Rpush到子红包id缓存List,我觉得都没有问题。问题就是,创建红包时,并没有关注新增子红包后的返回值就直接写Redis了。这种情况下,很有可能在redis中写入了一个不存在的红包id。 如果去除了一个不存在的红包id,好吧,也罢。事务中肯定会报NPE,事务一定会失败。但为何后续也不关注事务的返回结果就直接设置接口响应的VO,标记已经抢红包成功了,并返回了结果???这将会直接导致用户端展示为抢红包成功了,但根本看不到自己抢了多少钱… 优化后如下,具体关键步骤逻辑如注释描述: 123456789101112131415161718192021222324252627282930313233343536373839RedEnvelopeItemDO redEnvelopeItemDO = redEnvelopeItemDAO.findById(Long.parseLong(redEnvelopeItemIdStr));//先对redEnvelopeItemDO判空,不为空,说明子红包表才是真的存在这么一个红包 if (Objects.isNull(redEnvelopeItemDO)) { grabRedEnvelopeInfoVO.setType(NO_GAIN.getType()); return RpcResponse.success(grabRedEnvelopeInfoVO); }//关注事务的返回结果 boolean transaction = jdbcDAO.withTransaction(() -> { RedEnvelopeRefDO redEnvelopeRefDO = new RedEnvelopeRefDO(); redEnvelopeRefDO.setPrice(redEnvelopeItemDO.getPrice()); redEnvelopeRefDO.setType(TYPE_AUDITOR); redEnvelopeRefDO.setRedEnvelopeItemId(redEnvelopeItemDO.getId()); redEnvelopeRefDO.setApplyUserId(applyUser.getId()); redEnvelopeRefDO.setUserId(applyUser.getUserId()); int saveRes = redEnvelopeRefDAO.save(redEnvelopeRefDO); //保存完红包明细记录后,也要关注返回结果,如果不成功,需要主动抛出异常 if (!Objects.equals(CONST_1, saveRes)) { //如果没保存ref成功,抛异常使事务失败 throw new RuntimeException("创建红包明细失败~"); } redEnvelopeItemDO.setStatus(ITEM_STATUS_DONE); int updateRes = redEnvelopeItemDAO.update(redEnvelopeItemDO); //更新子红包状态不成功,也需要主动抛出异常 if (!Objects.equals(CONST_1, updateRes)) { //如果更新红包项状态失败,抛异常使事务失败 throw new RuntimeException("更新红包项状态失败~"); } }); if (!transaction) { //事务失败了,那就是抢红包失败了,此时子红包缓存list中此id被消耗掉了也无所谓 grabRedEnvelopeInfoVO.setType(NO_GAIN.getType()); return RpcResponse.success(grabRedEnvelopeInfoVO); } //将此已经抢成功的红包id记录到缓存,用于下一次接口请求进来判断,今天是否已经抢成功三次了,当前红包是否已经抢过了 recordGrabSuccess(userId, redEnvelopeId); grabRedEnvelopeInfoVO.setGain(true); grabRedEnvelopeInfoVO.setType(GAIN.getType()); grabRedEnvelopeInfoVO.setPrice(redEnvelopeItemDO.getPrice()); 至此,对与抢红包这个接口就算时基本优化完成了。目前从整体接口逻辑来看,我们做了的优化如下: 业务逻辑 优化前 优化后 好处 查询是否报名成功 SQL查询报名表 查询缓存 性能提升 查询一天成功抢红包的次数 SQL查询 查询一次缓存 性能提升 查询是否已经抢过了当前红包 SQL查询 利用上面的缓存查询结果即可 性能提升 获取一个子红包id LPOP LPOP(不变) 不变 查询子红包Id缓存List的剩余长度 LLEN 省略此次查询 性能提升 分布式锁 锁位置不对,不一定能释放,锁带有超时时间,线程自旋获取锁,影响接口吞吐量 优化锁位置,不自旋等待,获取不到锁直接返回 性能提升 记录红包明细,更细子红包状态 子红包在表中和缓存中数据可能不一致,并且没有关心事务结果 保证子红包在表中和缓存的一致性,关心事务结果 可靠性提升 经过这么一番优化后,结果的性能提升是可以预估的。几乎所有的读操作全都打到了缓存上。而真正能写次数最多只有红包设定的子红包个数次。 六、总结​ 这样就算优化完了吗?当然还没有!对于高并发的处理,接口仅仅是比较重要的一步罢了。在后续的文章中,我将继续从其他角度对此项目进行优化。","categories":[{"name":"高并发项目","slug":"高并发项目","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/"},{"name":"系列文章","slug":"高并发项目/系列文章","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/%E7%B3%BB%E5%88%97%E6%96%87%E7%AB%A0/"}],"tags":[{"name":"高并发","slug":"高并发","permalink":"https://sombreknight.gitee.io/tags/%E9%AB%98%E5%B9%B6%E5%8F%91/"},{"name":"性能优化","slug":"性能优化","permalink":"https://sombreknight.gitee.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"},{"name":"红包","slug":"红包","permalink":"https://sombreknight.gitee.io/tags/%E7%BA%A2%E5%8C%85/"}]},{"title":"《高并发红包炸弹项目性能优化》系列二:方案设计","slug":"0z9h4N46Yu0gPPfg","date":"2021-01-14T01:52:38.000Z","updated":"2021-01-14T01:52:05.000Z","comments":true,"path":"2021/01/14/0z9h4N46Yu0gPPfg/","link":"","permalink":"https://sombreknight.gitee.io/2021/01/14/0z9h4N46Yu0gPPfg/","excerpt":"","text":"一、开端​ 在上一篇文章《高并发红包炸弹项目性能优化》系列一:项目介绍 中,我介绍了下该红包项目的需求流程,以及一些此项目会涉及到的难点。这一篇文章,主要介绍针对此红包炸弹需求,我们的方案是如何设计的。由于项目是1.0版上线之后才交接到我手上的,因此方案并非我牵头设计的。但是对于别人的设计,我们还是可以怀着自己的思想去审视下,此方案的好坏,以及是否可以进一步优化。 二、方案设计前的思考​ 1. 首先我们对此项目有一个整体的交互模型,如下图所示, 管理员负责发红包,用户负责抢红包; 发红包最需要考虑的事情 发红包后用户抢到的子红包是提前生成好,还是用户抢的时候实时计算能抢多少?(离线计算还是实时计算?) 投放的红包炸弹在炸开前1分钟,如何通知到用户端,让用户端展示出红包炸弹倒计时?(推还是拉?) 怎样设计让一个红包拆分出的子红包能够具备一定的分布特性(如正态分布),使用户抢到的单个子红包金额在一个控制返回?(单个子红包不能太大,也不能太小) 抢红包最需要考虑的事情 如何应对瞬时高并发请求,怎样保护服务器,怎样保护DB?(羊毛党可能只是为了抢到红包,但黑客可能只是单纯地想把你的服务器打挂) 如何防止超抢?即所有用户抢到的子红包之和应该小于等于红包总金额; 正常用户和黑客一起抢红包,黑客利用技术手段可以更及时的在红包炸弹爆开的一瞬间发起请求,如何识别非正常的请求?如何保证正常用户都能够公平的参与竞争? 三、看看别人的方案设计1. 数据库表设计​ 如下图所示,是为满足红包炸弹相关需求所设计的表结构: 红包炸弹相关的有三张表: 红包表 : 记录了谁在什么时间投放了一个红包炸弹,该红包炸弹的金额,可拆分的子红包数目,红包的炸开时间,红包的状态,以及记录了此红包什么时间被抢完; 子红包表: 记录了当前子红包属于哪个红包,当前子红包的金额,当前子红包的状态; 红包明细表:记录了哪个用户,在什么时间抢了一个怎样的子红包,明细表中冗余了子红包金额字段 支持红包炸弹的一张业务表: 报名记录表:记录了用户的报名信息和状态,相对于红包炸弹来讲,需要满足”用户报名成功才能抢红包“。 2. 消息通知流程整个红包炸弹需要进行消息通知的有这么几处地方: 后台创建了红包炸弹,用户端需要收到通知,然后在页面展示有一个红包炸弹即将在某个时间点炸开; 当距离红包炸弹炸开前1分钟时,用户端需要收到通知,然后在页面开始红包炸弹炸开的60秒倒计时; 当红包炸弹被管理员取消,或者红包炸开后15s用户没有抢完子红包,系统自动将红包置完成时,会通知用户端,红包已失效或者进入红包历史界面; 基于公司的中台IM系统(依赖第三方云服务即时通信),可以进行消息的及时下发,采用的是长链接的方式。 3. 前端请求限流当红包炸弹60秒倒计时完成,就可以点击红包云朵,进行抢红包了。这里为了限制请求,前端做的处理是,必须在发起一次抢红包接口返回后,才能发起下一次请求。目前用户端分别做了小程序版和H5版。很明显这种请求限流方式,在H5方案情况下,如果用户开多个浏览器,多个Tab窗口进行同时请求,是拦截不住的。这里我让前端做了一些优化,因为H5页面最终是嵌套在客户端内打开的,所以只要在判断当前环境是在客户端才能进行请求即可。单独抓包H5页面在浏览器是无法请求接口的。 4. 服务端核心接口逻辑实现申明:先申明下以下代码并非我写的,而是此红包项目交接到我手里时的代码。代码的问题多,后续我将会对这些代码进行针对性的问题优化,我们先只关注业务主流逻辑,我在其源码上加了必要的注释方便大家看懂,暂不要关心代码的严谨性和性能等问题 1)创建红包 创建红包接口逻辑大概如下: 12345678910111213141516@ApiOperation(value = "后台创建红包炸弹")@PostMapping("/createForAuditor")@ValidateBodypublic RpcResponse<String> createForAuditor(@RequestBody CreateRedEnvelopeVO createRedEnvelopeVO){ //省略一些基本条件校验逻辑 String redisLock = "createForAuditor" + inspectId; //获取分布式锁 redisLocker.lock(redisLock); //创建红包 redEnvelopeLogic.create(num, price, inspectId, TYPE_AUDITOR, startTime); //通过IM通知用户端,红包已经创建了,用户端收到通知后将会在页面显示一个红包炸弹将在什么时间开爆 redEnvelopeLogic.sendNoticeToIM(redEnvelopeDAO.queryLatestRedEnvelope(), CelebrationConstants.Hdf_Celebration_Bomb); //释放锁 redisLocker.unlock(redisLock); return RpcResponse.success("创建成功"); } 看下上面第10行里调用的创建红包的核心逻辑 123456789101112131415161718private void create4Auditor(Integer num, BigDecimal price, Long inspectId, HaoDate startTime) { RedEnvelopeDO redEnvelopeDO = new RedEnvelopeDO(); redEnvelopeDO.setNum(num); redEnvelopeDO.setSourceId(inspectId); //红包的类型还有其他多种,这个类型代表时红包炸弹 redEnvelopeDO.setSourceType(RedEnvelopeConstants.TYPE_AUDITOR); redEnvelopeDO.setPrice(price); redEnvelopeDO.setStatus(RedEnvelopeConstants.STATUS_VALID); redEnvelopeDO.setTime(startTime); //保存红包到数据库 redEnvelopeDAO.save(redEnvelopeDO); //异步创建子红包 celebrationMqProcessor.sendCreateRedEnvelopeItem(redEnvelopeDO); //创建一个延迟MQ消息,在红包炸开后15s自动置红包炸弹完成 celebrationMqProcessor.sendAutoComplete(redEnvelopeDO); //创建一个延迟MQ消息,在红包炸开前1分钟,通知用户端,开始60s倒计时 celebrationMqProcessor.sendCountDownMsg(redEnvelopeDO); } 可以看出,这里是提前创建好子红包的,我们来看下子红包是如何生成的 12345678910111213141516171819202122232425//createItems此方法已经是在MQ的消费者线程中执行的了public boolean createItems(RedEnvelopeMessage message){ log.info("MQ Monitor createItems msg: {}", message); RedEnvelopeDO redEnvelopeDO = redEnvelopeDAO.findById(message.getRedEnvelopeId()); if(redEnvelopeDO == null){ return true; } //这里给价格乘以了100,看起来意思是把元/角/分的分单位整数化 Integer totalAmount = redEnvelopeDO.getPrice().intValue() * 100; Integer num = redEnvelopeDO.getNum(); //可以知道就是这个工具方法,把一个红包分成了指定份数的子红包 List<Integer> redEnvelopeList = RedEnvelopeUtil.divideRedEnvelope(totalAmount, num); redEnvelopeList.forEach(integer -> { RedEnvelopeItemDO redEnvelopeItemDO = new RedEnvelopeItemDO(); redEnvelopeItemDO.setRedEnvelopeId(redEnvelopeDO.getId()); redEnvelopeItemDO.setPrice(BigDecimal.valueOf((double) integer / 100)); redEnvelopeItemDO.setStatus(ITEM_STATUS_DOING); redEnvelopeItemDO.setDescription("红包炸弹"); //子红包存库 redEnvelopeItemDAO.save(redEnvelopeItemDO); //把子红包的id Rpush到了redis的一个list redisClient.rpush(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId(), String.valueOf(redEnvelopeItemDO.getId())); }); return true; } 拆分红包为若干子红包的工具方法逻辑 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455/** * 生成红包一次分配结果 * @param totalAmount 总红包量 * @param totalNum 总份数 * @return List<Integer> */public static List<Integer> divideRedEnvelope(Integer totalAmount, Integer totalNum) { //这个sendedAmount变量原作者应该是想表达,已经生成的红包消耗了多少钱了 Integer sendedAmount = 0; //这个sendedNum变量原作者应该是想表达,已经生产几个红包了 Integer sendedNum = 0; //这里min应该代表一个子红包最低应该是平均子红包金额的十分之一 int min = (totalAmount / totalNum ) / 10; //rdMin代表红包最低的真实价格,如果算出来min==0了,最少也要为1,此时单位应该是分,即子红包最小1分钱 Integer rdMin = min == 0 ? 1 : min; //rdMax代表子红包最大为平均值的2倍 Integer rdMax = (totalAmount / totalNum * 2); List<Integer> redEnvelope = new ArrayList<>(); while (sendedNum < totalNum) { //循环的计算每个子红包的价格,子红包价格区间在【1分,2倍平均红包的价格】 Integer bonus = randomOneRedEnvelope(totalAmount, totalNum, sendedAmount, sendedNum, rdMin, rdMax); redEnvelope.add(bonus); sendedNum++; sendedAmount += bonus; } //返回由计算出来的子红包金额组成的List return redEnvelope;}/** * 随机分配第n个红包 * @param totalAmount 总红包量 * @param totalNum 总份数 * @param sendedAmount 已发送红包量 * @param sendedNum 已发送份数 * @param rdMin 随机下限 * @param rdMax 随机上限 * @return Integer */private static Integer randomOneRedEnvelope(Integer totalAmount, Integer totalNum, Integer sendedAmount, Integer sendedNum, Integer rdMin, Integer rdMax) { Integer boundMin = Math.max((totalAmount - sendedAmount - (totalNum - sendedNum - 1) * rdMax), rdMin); Integer boundMax = Math.min((totalAmount - sendedAmount - (totalNum - sendedNum - 1) * rdMin), rdMax); return getRandomVal(boundMin, boundMax);}/** * 返回min~max区间内随机数,含min和max * @param min * @param max * @return Integer */private static int getRandomVal(int min, int max) { return rand.nextInt(max - min + 1) + min;} 2)抢红包 抢红包主接口 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106@GetMapping("/grabRedEnvelopes") @ApiOperation(value = "抢红包接口", notes = "userId 用户Id | redEnvelopeId:红包id") public RpcResponse<GrabRedEnvelopeInfoVO> grabRedEnvelopes(@RequestParam Long userId, @RequestParam Long redEnvelopeId){ //空参校验 if(!ObjectUtils.allNotNull(userId, redEnvelopeId)){ return RpcResponse.error(ErrorCode.PARAMS_EXCEPTION); } //校验红包是否存在,公司的当前基础服务框架中,DAO查询的findById方法会查询实体缓存 //实体缓存存放于redis中,如果实体缓存失效了,会去查询MySQL集群中的主库 RedEnvelopeDO redEnvelopeDO = redEnvelopeDAO.findById(redEnvelopeId); if(null == redEnvelopeDO){ return RpcResponse.error(ErrorCode.PARAMS_EXCEPTION); } //校验是否红包还没开爆,还没开爆就进来抢红包的,肯定要拦截 if(HaoDate.currentTimeSecond() - redEnvelopeDO.getTime().getTimeSecond() < 0){ return RpcResponse.error(ErrorCode.RED_NO_BEGAIN); } //判断红包是否已经被取消了,如果已经取消了,也是抢不了的 if(ObjectUtils.notEqual(STATUS_VALID,redEnvelopeDO.getStatus())){ return RpcResponse.error(ErrorCode.RED_BE_OVER); } //构造结构返回的VO,gain字段表示是否抢到了红包 GrabRedEnvelopeInfoVO grabRedEnvelopeInfoVO = GrabRedEnvelopeInfoVO.builder().gain(false).userId(userId).build(); //获取分布式锁 String lockName = "celebration_" + userId; //这里用了tryLock,本质上是一个带超时返回的自旋锁 boolean isUserLock = redisLocker.tryLock(lockName, 5, 5); if(!isUserLock){ //超时都没拿到锁,就返回请稍后重试 return RpcResponse.error(ErrorCode.NO_GET_LOCK_TIP); } //判断有没有报名,这个getApplyUserByUserId接口逻辑是查询报名表,是否有报名成功的记录,此接口没有做缓存 ApplyUserDO applyUserDO = applyUserLogic.getApplyUserByUserId(userId); if(null == applyUserDO) { //提示没报名,释放锁 grabRedEnvelopeInfoVO.setType(NO_SIGN_UP.getType()); redisLocker.unlock(lockName); return RpcResponse.success(grabRedEnvelopeInfoVO); } //判断是否已经抢过当前红包了,这里Dao层查询了红包明细表,但是红包明细表中没有红包id,只有子红包id //所以这里连表查询了,这个dao方法一会儿放在下面的代码块中 Long redEnvelopeRefId = redEnvelopeRefDAO.getRedEnvelopeRef(redEnvelopeId, userId); if(null != redEnvelopeRefId){ //如果已经抢过此红包了,就提示已抢过并释放锁,接口返回 grabRedEnvelopeInfoVO.setType(ALREADY_GAIN.getType()); redisLocker.unlock(lockName); return RpcResponse.success(grabRedEnvelopeInfoVO); } //判断一天内是否已经抢过三次 //这个dao查询方法一会儿也放在下面的代码块中 List<RedEnvelopeRefDO> redEnvelopeRefDOList = redEnvelopeRefDAO.getRedEnvelopeRefList(userId); if(redEnvelopeRefDOList.size() >= overLimit){ //如果已经抢过三次了,则提示您已经三连冠了,把机会让给别人吧。 grabRedEnvelopeInfoVO.setType(OVER_GAIN.getType()); //释放分布式锁 redisLocker.unlock(lockName); //接口返回 return RpcResponse.success(grabRedEnvelopeInfoVO); } try { //从redis中Lpop一个子红包id出来,这个是在创建子红包时Rpush进入这个list的 //这里进行了id转换,如果抛NumberFormatException异常了,则说明list中已经空了 //代表红包已经被抢完了,在catch中处理了抢完的逻辑 Long redEnvelopeItemId = Long.valueOf(redisClient.lpop(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId())); log.info("redis itemId:{}",redisClient.lrange(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId(), 0, -1).toString()); //这里llen查询了下redis中存放子红包id的list长度,如果空了,并且当前红包还没有置完成,就直接给红包置完成了 if(!ObjectUtils.notEqual(0L,redisClient.llen(RED_ENVELOPE_ITEM_KEY + redEnvelopeDO.getId())) && HaoDate.isZeroTime(redEnvelopeDO.getCompleteTime())){ redEnvelopeDO.setCompleteTime(new HaoDate()); redEnvelopeDAO.update(redEnvelopeDO); } //这里根据拿到的子红包id查询子红包实体。findById会先查询实体缓存,没有就查主库 RedEnvelopeItemDO redEnvelopeItemDO = redEnvelopeItemDAO.findById(redEnvelopeItemId); //开启事务,创建红包明细,更新子红包状态为完成,代表已经抢成功了。这里居然没关心事务返回结果,后面也会优化处理 jdbcDAO.withTransaction(()->{ RedEnvelopeRefDO redEnvelopeRefDO = new RedEnvelopeRefDO(); redEnvelopeRefDO.setPrice(redEnvelopeItemDO.getPrice()); redEnvelopeRefDO.setType(TYPE_AUDITOR); redEnvelopeRefDO.setRedEnvelopeItemId(redEnvelopeItemDO.getId()); redEnvelopeRefDO.setApplyUserId(applyUserDO.getId()); redEnvelopeRefDO.setUserId(applyUserDO.getUserId()); redEnvelopeRefDAO.save(redEnvelopeRefDO); redEnvelopeItemDO.setStatus(ITEM_STATUS_DONE); redEnvelopeItemDAO.update(redEnvelopeItemDO); }); //设置接口返回的VO中Gain为true,表示抢成功了 grabRedEnvelopeInfoVO.setGain(true); grabRedEnvelopeInfoVO.setType(GAIN.getType()); grabRedEnvelopeInfoVO.setPrice(redEnvelopeItemDO.getPrice()); }catch (NumberFormatException e){ //如果这里捕获到异常,说明从redis的list取空了,设置为抢失败 grabRedEnvelopeInfoVO.setType(NO_GAIN.getType()); //更新红包的完成时间 if(HaoDate.isZeroTime(redEnvelopeDO.getCompleteTime())) { redEnvelopeDO.setCompleteTime(new HaoDate()); redEnvelopeDAO.update(redEnvelopeDO); } //接口返回 return RpcResponse.success(grabRedEnvelopeInfoVO); }finally { //这里释放锁,但时就目前这个代码严谨程度上看,还真不一定能及时释放掉这个锁,后面会讲我是怎么优化的 redisLocker.unlock(lockName); } //接口返回vo return RpcResponse.success(grabRedEnvelopeInfoVO); } 上面第43行的dao查询方法getRedEnvelopeRef,具体实现如下 123456789101112public Long getRedEnvelopeRef(@NotNull Long redEnvelopeId, @NotNull Long userId){ //看这个sql是要连接redenvelopes红包表、redenvelopeitems子红包表、redenveloperefs红包明细表三表连接查询当前用户是否已经抢过这个红包了 //这个sql过于复杂了,而且性能不高,连起码的limit 1都没有加上,后面讲如何优化掉这个多表查询 String sql = "select c.id from redenvelopes a inner join redenvelopeitems b on a.id = b.redenvelopeid inner join " + "redenveloperefs c on b.id = c.redenvelopeitemid where c.userid = :userId and a.id = :redEnvelopeId " + "and b.status = :status and a.sourcetype = :sourceType"; SqlParam sqlParam = SqlParam.create("userId", userId). add("redEnvelopeId", redEnvelopeId). add("status", ITEM_STATUS_DONE). add("sourceType", TYPE_AUDITOR); return jdbcDAO.findField(Long.class, sql, sqlParam); 上面第52行dao查询方法getRedEnvelopeRefList,具体实现如下 12345678910111213public List<RedEnvelopeRefDO> getRedEnvelopeRefList(@NotNull Long userId){ //可以看出来就是查询了所有的这一天时间短内,这个用户抢了这类型的红包记录 //直接返回了明细的DO,这里最起码的优化返回主键id列表或者sql Count一下就可以了。当然还有更好的优化。 String whereSql = "where userid = :userId and type =:type and ctime >= :startTime and ctime <= :endTime"; HaoDate now = new HaoDate(); String startTime = now.dateString(); String endTime = now.offsetDay(1).dateString(); SqlParam sqlParam = SqlParam.create("userId", userId). add("type", TYPE_AUDITOR). add("startTime", startTime). add("endTime", endTime); return jdbcDAO.findList(RedEnvelopeRefDO.class, whereSql, sqlParam); } 通过上面几段最核心的代码,大概能看出来这些问题了。 代码不规范,比如很多地方不关心返回值,理所应当的认为调用成功 逻辑不严谨,像使用分布锁的地方,加锁地方和最终try…finally…的代码短隔了很多中间逻辑,无法保证一定就能进入try代码块,更无法保证一定会释放锁了 性能问题很多,使用缓存的地方,除了公司dao层框架提供的实体缓存和分布式锁,就没有别的地方使用缓存了。大量的查询库表,甚至出现多表连接的复杂SQL。在应对抢红包这样大并发的情况下,显然是不够的。 安全性问题: 锁的力度小(锁用户),不同用户并发进来,没有任何阻碍,无法很好做到限流 使用了tryLock带超时的自旋锁,当发生恶意攻击时,可能一个用户就把所有机器的线程打满了,服务就不能提供给其他人了 接口整体没有限流,不能做到削峰填谷,流量洪峰下,可能打挂服务,也可能打挂DB 框架的dao层查询实体缓存是OK的,但是不能解决缓存穿透的问题。恶意用户一直使用不存在的userId请求的话,会给DB造成很大的压力 还有更多的问题就不一一列举了,在后面的文章中,我将一步步的优化代码、优化前后端交互流程、使用一些限流/熔断/降级、压力测试等手段一步步将这个抢红包项目彻底优化,以能够应付各种高并发下的问题。 四、总结这里篇文章里,我对红包炸弹交接前的设计方案、核心实现等做了一些介绍,在后面的文章中我将正式开始一步步地去优化这个项目。","categories":[{"name":"高并发项目","slug":"高并发项目","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/"},{"name":"系列文章","slug":"高并发项目/系列文章","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/%E7%B3%BB%E5%88%97%E6%96%87%E7%AB%A0/"}],"tags":[{"name":"高并发","slug":"高并发","permalink":"https://sombreknight.gitee.io/tags/%E9%AB%98%E5%B9%B6%E5%8F%91/"},{"name":"性能优化","slug":"性能优化","permalink":"https://sombreknight.gitee.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"},{"name":"红包","slug":"红包","permalink":"https://sombreknight.gitee.io/tags/%E7%BA%A2%E5%8C%85/"}]},{"title":"《高并发红包炸弹项目性能优化》系列一:项目介绍","slug":"MRWloLteKJcf5Idr","date":"2021-01-13T15:06:44.000Z","updated":"2021-01-13T15:07:12.000Z","comments":true,"path":"2021/01/13/MRWloLteKJcf5Idr/","link":"","permalink":"https://sombreknight.gitee.io/2021/01/13/MRWloLteKJcf5Idr/","excerpt":"","text":"一、项目介绍​ 受疫情影响,公司打算在今年搞一次全国医生线上年会。而公司为了或是引流、或是给医生分福利,计划设计一个砸钱抢红包的环节。运气非常不错,虽然整体方案最初的设计不是我做的,但是最后此红包项目的性能优化却落到了我这里。平常的业务项目里,大多接触不到高并发场景,故借此机会,对此项目进行一个好好优化梳理,相信一定会对自己大有裨益的。 首先介绍下抢红包的流程,如下图所示: 首先是年会活动主会场界面,如下图一所示,右下角展示的就是红包炸弹,当有炸弹即将在1分钟后爆开时,就会进入此60秒倒计时。当倒计时完成时界面出现抢红包的云朵图案,如下图二所示。 当点击抢红包云朵图案时,有一下情况: 用户没有报名参加年会,是无法参与抢红包的,会提示还没报名,并引导用户去报名页面,如下图一所示; 用户点击云朵,但是没有抢到红包,提示手慢了,如下图二所示; 用户点击云朵,成功抢到了,点击开红包,查看红包明细,如下图三所示; 用户点击云朵,系统检测出来该用户今天已经成功抢到三次红包了,提示已经3连冠,把机会让给别人。如下图四所示。 用户点击云朵,系统检测出来该用户已经抢成功过了当前红包,直接进入红包明细,如下图五所示。 后台投放红包很简单,管理员可以在后台投放红包炸弹,指定什么时候炸开,投放的红包会分成多少份,总金额是多少。如下图所示: 产品的一些关键特殊的要求,汇总如下: 一个红包炸开了,最多抢15s,这个红包就会被自动置完成,用户不能再抢了 一个用户一天最多抢成功3个红包 一个红包,同一用户最多只能抢成功一次 管理员随时可以在后台取消红包 至此,相信大家对此项目已经有了一定的基本了解。我再附上一张流程图更好的说明整个流程。 二、难点分析这个项目是有一定难度的,具体难在哪儿,我个人的看法如下: 公司的医生用户基数大,年会报名人数预计在30万+,年会当晚会持续多轮红包炸弹轰炸,平均QPS约为在30w/15s = 2000,但是抢红包的这15s不可能每一秒钟都qps都是均衡的,预计峰值QPS可能达到30w/5s = 6000左右,也就是预计在红包炸弹炸开的前5sQPS应该是最高的时候。查阅资料 可知,如微博每天1亿多pv的系统一般也就1500QPS,5000QPS峰值。但是具体多少QPS跟业务强相关,只读接口读缓存,将压力给到缓存单机3000+没问题,写请求1000+也正常,也复杂些可能也就几百+QPS。 如此高的并发下,我们很容易在服务层面想到加机器,但是最应该保护的其实是DB。如果一个红包分成了1000个子红包,这意味着抢完这个红包最多要写库1000次。感觉写库的量并不大,但是要知道的是,这1000次写库非常集中,瞬时写库会给DB造成比较大的压力,可能导致并发下其他查询事务受到影响,甚至影响到其他业务。故我们需要对必要的接口进行流量控制以保证接口的吞吐量维持在一个较高的位置,但是又不会直接影响到服务性能。 并发下,如何保证不“超抢”。例如其实只发了1万元红包,不能因为并发原因,就让用户抢出去2万块钱了。底线应该是,宁可让用户抢不到,也不能抢超了。 怎样避免羊毛党,恶意刷接口等操作造成公司利益受损? 三、总结好了,到这里我们先对此项目有个整体上的感知即可。 抢红包面向医生群体,数量约30w+;抢红包需要先报名;一天最多抢成功三次;同一红包只能抢成功一次。 下一篇文章具体介绍下这个项目的数据库表设计,以及他们的接口代码实现。","categories":[{"name":"高并发项目","slug":"高并发项目","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/"},{"name":"系列文章","slug":"高并发项目/系列文章","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/%E7%B3%BB%E5%88%97%E6%96%87%E7%AB%A0/"}],"tags":[{"name":"高并发","slug":"高并发","permalink":"https://sombreknight.gitee.io/tags/%E9%AB%98%E5%B9%B6%E5%8F%91/"},{"name":"性能优化","slug":"性能优化","permalink":"https://sombreknight.gitee.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"},{"name":"红包","slug":"红包","permalink":"https://sombreknight.gitee.io/tags/%E7%BA%A2%E5%8C%85/"}]}],"categories":[{"name":"Java","slug":"Java","permalink":"https://sombreknight.gitee.io/categories/Java/"},{"name":"Spring","slug":"Spring","permalink":"https://sombreknight.gitee.io/categories/Spring/"},{"name":"Java","slug":"Spring/Java","permalink":"https://sombreknight.gitee.io/categories/Spring/Java/"},{"name":"中间件","slug":"中间件","permalink":"https://sombreknight.gitee.io/categories/%E4%B8%AD%E9%97%B4%E4%BB%B6/"},{"name":"虚拟机","slug":"Java/虚拟机","permalink":"https://sombreknight.gitee.io/categories/Java/%E8%99%9A%E6%8B%9F%E6%9C%BA/"},{"name":"高并发项目","slug":"高并发项目","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/"},{"name":"系列文章","slug":"高并发项目/系列文章","permalink":"https://sombreknight.gitee.io/categories/%E9%AB%98%E5%B9%B6%E5%8F%91%E9%A1%B9%E7%9B%AE/%E7%B3%BB%E5%88%97%E6%96%87%E7%AB%A0/"}],"tags":[{"name":"高并发","slug":"高并发","permalink":"https://sombreknight.gitee.io/tags/%E9%AB%98%E5%B9%B6%E5%8F%91/"},{"name":"性能优化","slug":"性能优化","permalink":"https://sombreknight.gitee.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"},{"name":"Java","slug":"Java","permalink":"https://sombreknight.gitee.io/tags/Java/"},{"name":"Spring","slug":"Spring","permalink":"https://sombreknight.gitee.io/tags/Spring/"},{"name":"源码分析","slug":"源码分析","permalink":"https://sombreknight.gitee.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"},{"name":"框架","slug":"框架","permalink":"https://sombreknight.gitee.io/tags/%E6%A1%86%E6%9E%B6/"},{"name":"中间件","slug":"中间件","permalink":"https://sombreknight.gitee.io/tags/%E4%B8%AD%E9%97%B4%E4%BB%B6/"},{"name":"RabbitMQ","slug":"RabbitMQ","permalink":"https://sombreknight.gitee.io/tags/RabbitMQ/"},{"name":"消息队列","slug":"消息队列","permalink":"https://sombreknight.gitee.io/tags/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/"},{"name":"JVM","slug":"JVM","permalink":"https://sombreknight.gitee.io/tags/JVM/"},{"name":"红包","slug":"红包","permalink":"https://sombreknight.gitee.io/tags/%E7%BA%A2%E5%8C%85/"}]}
1
https://gitee.com/SombreKnight/sombreknight.git
git@gitee.com:SombreKnight/sombreknight.git
SombreKnight
sombreknight
SombreKnight
main

搜索帮助