Spring Boot 启动成功事件 ApplicationReadyEvent 实战:缓存预热与日志监控
如果你接手过一个 Spring Boot 项目大概率会碰到这类问题应用明明已经起完了运维却说“没看到服务就绪的日志”或者上线后第一次请求特别慢排查下来才发现是缓存压根没预热。这些问题的共同根源都是没有抓住“启动成功”这个关键时机。Spring Boot 的启动过程不是瞬时的它是一连串生命周期事件组成的。监听“启动成功”这件事最标准的事件就是 ApplicationReadyEvent——这是 SpringApplication.run() 在完成上下文刷新、内置 Web 服务器已启动、ApplicationRunner 和 CommandLineRunner 都执行完毕之后向外抛出的最后一个事件。换句话说只要收到这个事件这个应用就真正可以对外提供流量了。这篇内容以 Spring Boot 2.7 以上的版本为主把我实际项目里围绕这个事件做过的监听、日志输出、缓存预热、自检逻辑以及踩过的坑完整整理一遍。无论你是刚上手 Spring Boot 的初学者还是已经在维护线上服务、需要在启动后做一堆初始化工作的同学这篇都能给你一套可以直接抄作业的落地参考。1. 先搞清楚启动成功的标志事件是谁1.1 从 SpringApplication.run() 看事件顺序要理解 ApplicationReadyEvent 为什么能代表“启动成功”最直接的办法是看 SpringApplication.run() 方法的执行流程。它内部大致经历这么几个阶段启动计时发布 ApplicationStartingEvent创建并准备 Environment发布 ApplicationEnvironmentPreparedEvent创建 ApplicationContext发布 ApplicationContextInitializedEventBean 定义加载完成发布 ApplicationPreparedEvent刷新上下文完成所有单例 Bean 的实例化发布 ContextRefreshedEvent执行 ApplicationRunner 和 CommandLineRunner发布 ApplicationStartedEvent如果应用是 Web 应用此时内置服务器已启动发布 ApplicationReadyEvent表示可以接收请求了从这段流程能看出来ApplicationReadyEvent 比 ContextRefreshedEvent 更靠后比 ApplicationStartedEvent 也更靠后。ApplicationStartedEvent 只是表示 Spring Boot 流程中的核心启动阶段完成但 Runner 还没跑完所以严格来说此时还不能算“完全就绪”。如果你在 ContextRefreshedEvent 或 ApplicationStartedEvent 里做缓存预热、远程配置拉取极有可能出现运行到一半、有些依赖组件还没初始化完成的情况。我在早期项目里就吃过教训把数据字典加载放在 ContextRefreshedEvent 里结果偶尔会遇到报错 “The bean xxx could not be found” 或者某个代理对象尚未实例化。换到 ApplicationReadyEvent 之后所有 Bean 都已准备到位问题就消失了。1.2 业务上真正需要这个时机的场景启动成功这个节点在实际业务里最常见的使用场景有这么几类缓存预热。电商系统、多商户商城这类业务上线后往往要加载商品类目、店铺配置、系统字典到 Redis。如果等用户第一次请求触发那第一个请求的 RT 会非常难看更可怕的是并发场景下缓存击穿。数据初始化。比如同步一批配置表、清理临时目录、创建消息队列的 Topic 或队列。外部服务注册。比如把自己注册到注册中心、上报当前实例 IP 到运维平台。启动自检与对外宣告。检查数据库连通性、检查关键配置是否存在然后把结果输出到日志。尤其是最后一点和日志输出强相关。大多数团队在发布系统时依赖日志检索来确认服务是否成功拉起。如果启动完成的标志不清晰运维人员和开发者的沟通成本会很高。我见过不少团队专门在启动成功事件里打印一段固定格式的日志比如 “APP STARTED SUCCESSFULLY”然后让监控平台直接按这个关键字做进程健康判断。1.3 不是所有时机都叫“启动成功”对比其他初始化钩子初学者最容易混的是把 PostConstruct、InitializingBean、ApplicationRunner、SmartInitializingSingleton 这几个机制和 ApplicationReadyEvent 放一起比较。它们各自触发的先后差异非常大PostConstruct 在 Bean 构造完成、依赖注入完成后立即执行此时整个 Spring 容器可能都还没刷新完更别提 Web 服务器已经监听了。InitializingBean 的 afterPropertiesSet 也是同一个阶段。这两个机制适合做单个 Bean 自身的初始化不适合做全局的“启动宣告”。ApplicationRunner 和 CommandLineRunner 的执行时机是容器刷新完成之后这个阶段已经比较靠后了但 Spring Boot 官方会在这两个 Runner 全部执行完毕后再发布 ApplicationReadyEvent。简单理解Runner 是“做事”的地方ApplicationReadyEvent 是“宣告完成”的地方。SmartInitializingSingleton 更特殊它在所有单例 Bean 预实例化完成后触发时间上接近 ContextRefreshedEvent。它的定位更偏向框架内部的扩展点用它在业务里做启动日志输出其实语义上不太匹配还会遇到 Bean 未完全初始化的问题。把它们放一起看结论就很清晰想让日志准确表达“服务已就绪”ApplicationReadyEvent 是最合适的标记想让业务逻辑在启动时先执行Runner 是更合适的载体。两者还能搭配使用后面我会专门讲这套组合。2. 事件监听与日志输出的接入方式选择2.1 用 EventListener 接入 ApplicationReadyEvent最简洁的接入方式是在任意 Spring Bean 中声明一个方法加上 EventListener 注解指定监听 ApplicationReadyEventComponent Slf4j public class ApplicationReadyLogger { EventListener(ApplicationReadyEvent.class) public void onReady(ApplicationReadyEvent event) { ConfigurableApplicationContext context event.getApplicationContext(); Environment environment context.getEnvironment(); String appName environment.getProperty(spring.application.name, unknown-app); String port environment.getProperty(server.port, 8080); log.info(); log.info([{}] 应用启动成功, 运行端口: {}, appName, port); log.info(); } }这段代码简单直观但有几个细节需要注意。第一依赖注入是没问题的因为当前类本身是 Spring Bean构造时容器已经把依赖都准备好了在这个监听方法里注入任何业务 Service 都能正常使用。第二event.getApplicationContext() 拿到的是 ConfigurableApplicationContext通过它可以继续获取 Environment、BeanFactory 等基础设施。我在实际项目里遇到过一件很常见的事开发环境用默认端口 8080生产环境通过环境变量注入端口但这里直接读取 “server.port” 配置未必准确。假如你配置了server.port0表示随机端口那么此处的 property 拿到的是配置值 “0”不是真正监听的端口。这个问题我后面会详细讲到底怎么解决。这种写法的最大优势是零配置、侵入性小适合大多数业务场景。项目里无论有多少启动后要处理的逻辑都可以定义在不同 Component 里Spring 会按类型自动找到监听方法。2.2 用 ApplicationListener 接口实现更结构化的监听另一种接入方式是实现 ApplicationListener 接口重写 onApplicationEvent 方法Component public class StartupEventListener implements ApplicationListenerApplicationReadyEvent { private static final Logger log LoggerFactory.getLogger(StartupEventListener.class); Override public void onApplicationEvent(ApplicationReadyEvent event) { log.info(ApplicationReadyEvent received, app is ready.); } }这种方式的好处在于监听器的类型声明是显式的一目了然而且如果需要在框架启动阶段、在事件发布器上提前注册监听器也可以把它加进 spring.factories 或者 SpringApplication 的 listeners 集合里。不过除了这种显式注册的场景业务代码里我更推荐直接用 EventListener它写法更简洁而且支持条件判断和排序。这两种方式的触发顺序是可以控制的。EventListener 支持 Order 注解ApplicationListener 的实现类也可以用 Order 控制执行顺序。优先级高的先执行数值小的先执行。比如你先做缓存预热再做自检日志就可以依次设置不同的 Order 值。不过这里有个很关键的点同一种事件的所有监听器默认情况下是同步执行的一旦某个监听器抛出异常后续监听器就不会执行了。这个坑我放到第 2.4 节详细讲。2.3 日志输出的必备配置别默认只输出到控制台Spring Boot 默认的日志配置很简单日志只输出到控制台。如果你的项目还没自定义 logback 配置启动完成日志打了一大串等发布到服务器上用 journalctl 或 tail 命令看的时候可能会因为日志混杂各种框架启动信息而看不清。为了让“启动成功”日志更醒目也为了把日志落到文件里备份我通常会在 src/main/resources 下放一个 logback-spring.xmlconfiguration springProperty scopecontext namespringAppName sourcespring.application.name defaultValuemy-service/ appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern charsetUTF-8/charset /encoder /appender appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/${springAppName}.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/${springAppName}.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern charsetUTF-8/charset /encoder /appender root levelINFO appender-ref refCONSOLE/ appender-ref refFILE/ /root /configuration这个配置里用 springProperty 把 spring.application.name 引入日志文件名这样每个服务各自生成日志排查时不用在一堆日志里大海捞针。${springAppName}.log 放在应用根目录的 logs 下我一般会在 .gitignore 里忽略这个目录避免本地日志文件被提交。另外还有一个细节Spring Boot 2.4 以后启动日志的顺序有调整。如果你发现启动横幅、Spring Boot 版本信息、当前 Profile 这些日志和你代码里的启动完成日志混在一起想知道它们谁先谁后可以在 application.yml 里开启spring: main: log-startup-info: true输出效果是每个细节日志都会带一条 “Starting...” 的说明但有时会显得啰嗦。大部分项目保持默认即可我更建议把自定义的启动成功日志做得干净、模板化让人一眼找到。2.4 透明但致命事件监听是同步执行的初学 Spring Boot 的开发者容易忽略一个事实默认情况下ApplicationEventMulticaster 是同步发布事件的ApplicationReadyEvent 的监听方法里如果有一段耗时操作会阻塞 SpringApplication.run() 的最后阶段。出现什么现象呢端口已经监听了日志却卡住不动直到监听方法跑完启动日志才继续输出。如果监听方法里有网络调用比如拉取远程配置那么远程接口超时整个应用启动流程也会跟着超时。解决思路有两种。第一种是主动接受同步让监听方法只做轻量级工作比如记录日志、清理临时文件。第二种是使用 Async 注解把耗时的初始化任务丢到线程池执行。但异步化会带来新的问题应用已经宣告启动成功而异步任务还没跑完。遇到这种情况合理做法是把“需要保证完成后才能对外提供服务”的逻辑放到 ApplicationRunner 里把“不阻塞启动、可以做但后台慢慢做”的逻辑放到异步线程池这点我将在第 3 节用完整代码说明。如果确实需要在事件监听里做耗时操作但要避免影响其他监听器也可以显式指定一个 TaskExecutor 到 ApplicationEventMulticaster让事件异步发布Bean public ApplicationEventMulticaster applicationEventMulticaster(SimpleAsyncTaskExecutor executor) { SimpleApplicationEventMulticaster multicaster new SimpleApplicationEventMulticaster(); multicaster.setTaskExecutor(executor); return multicaster; }这种方式可以让所有事件监听异步执行但要谨慎。监听器之间如果有先后依赖异步后不保证顺序其次异步监听器里抛出的异常不会回到启动流程容易静默失败。在实际项目里我更倾向于保持默认同步只把真正耗时且可降级的任务用显式线程池异步化。3. 实操过程缓存预热、自检与启动日志的一整套组合3.1 设计顺序Runner 先做事ReadyEvent 再宣告上线一个多商户商城系统时我的做法是把启动工作拆成两个阶段第一阶段用 ApplicationRunner 执行需要“必须完成”的初始化比如数据库字典加载到 Redis、本地业务缓存预热、外部 MQ 队列检查。这部分没做完ApplicationReadyEvent 就不会触发应用也就不会宣告启动成功。第二阶段在 ApplicationReadyEvent 监听器里输出最终状态包括端口、IP、数据库连通情况、缓存的 key 数量。也可以做一些快速自检比如请求自己几个关键接口的探活地址打印探活结果。这种拆分的原因很简单运行时的“启动成功”包含两层语义。第一层是进程起来了、端口通了第二层是业务初始化完成了可以正确处理请求。如果只监听 ApplicationReadyEvent而不在它之前做任何准备工作那么“启动成功”只能代表进程层面不代表业务就绪。而如果所有准备工作都靠监听器做又可能因为同步阻塞导致启动整体变慢。所以 Runner 是执行者ReadyEvent 是宣告者。只要理解了这一层你会发现实际代码设计非常自然把事务性的初始化放进 Runner把日志与可观测性输出放进事件监听器。3.2 缓存预热完整代码实现以商品类目缓存为例。假设这一类目信息存储在 MySQL 的 category 表里应用启动后希望把它全部加载到 Redis缓存 1 小时Component Slf4j public class CategoryCachePreheatRunner implements ApplicationRunner { private final CategoryRepository categoryRepository; private final StringRedisTemplate redisTemplate; public CategoryCachePreheatRunner(CategoryRepository categoryRepository, StringRedisTemplate redisTemplate) { this.categoryRepository categoryRepository; this.redisTemplate redisTemplate; } Override public void run(ApplicationArguments args) { long start System.currentTimeMillis(); ListCategory categories categoryRepository.findAll(); if (categories.isEmpty()) { log.warn(category table is empty, skip cache preheat); return; } MapString, String pipeData new HashMap(); for (Category category : categories) { pipeData.put(category: category.getId(), JSON.toJSONString(category)); } redisTemplate.executePipelined((RedisCallbackObject) connection - { pipeData.forEach((key, value) - connection.stringCommands().set( key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8))); return null; }); log.info(category cache preheated, count{}, cost{} ms, categories.size(), System.currentTimeMillis() - start); } }这段代码有几个我特别想强调的细节用 pipeline 批量写入而不是循环单条 set。Redis 循环写入在大数据量下会浪费大量网络 RTT我实测过 1 万条类目数据pipeline 比逐条写快 3 到 5 倍。缓存 key 统一加前缀 “category:”避免和业务其他 key 冲突。一定要记录耗时和条数影响启动时间的问题要能直观看到。这类日志在前期的 Runer 里就输出一次启动完成日志里还要再汇总一次。如果你用的是 Fastjson记得序列化时不要带 class 信息JSON.toJSONString(category) 默认是字段序列化反序列化时再指定 Category.class 即可。如果项目里用的是 Jackson就把 JSON 序列化替换成 ObjectMapper 的方法思路完全一致。3.3 启动自检与端口/IP/日志输出完整实现Runner 执行完毕之后ApplicationReadyEvent 监听器里就可以做最终的启动宣告了。我常用的一段实现是这样的Component Slf4j public class StartupReadyLogger { EventListener(ApplicationReadyEvent.class) Order(1) public void logReady(ApplicationReadyEvent event) { ConfigurableApplicationContext context event.getApplicationContext(); Environment env context.getEnvironment(); String appName env.getProperty(spring.application.name, unknown); String profile String.join(,, env.getActiveProfiles()); int port resolvePort(context, env); String ip resolveLocalIp(); log.info(); log.info(APP STARTED SUCCESSFULLY); log.info(APP NAME : {}, appName); log.info(ACTIVE PROFILE: {}, profile); log.info(LOCAL IP : {}, ip); log.info(SERVER PORT : {}, port); log.info(STARTUP COST : {} ms, (System.currentTimeMillis()))); log.info(); } private int resolvePort(ConfigurableApplicationContext context, Environment env) { if (context instanceof WebServerApplicationContext webContext) { WebServer webServer webContext.getWebServer(); if (webServer ! null) { return webServer.getPort(); } } return Integer.parseInt(env.getProperty(server.port, 8080)); } private String resolveLocalIp() { try { InetAddress address InetAddress.getLocalHost(); return address.getHostAddress(); } catch (UnknownHostException e) { return 127.0.0.1; } } }这段代码有两个点非常值得说明第一resolvePort 方法优先从 WebServerApplicationContext 中取真实端口。因为 context 本身可能已经实现了 WebServerApplicationContext 接口通过 getWebServer().getPort() 拿到的才是真正监听的端口即使你配置了server.port0或通过环境变量注入拿到的一定是实际端口。这个细节我做线上排查时发现很多人不知道。第二Launcher 里记录启动耗时。Spring Boot 内部自带启动耗时统计applicationStartedAt之类的属性但最直接的方式就是进入 ReadyEvent 监听时用 System.currentTimeMillis() 减掉进程启动时的某个标记。如果你在 main 方法开头记录过一个 startTime 常量也可以把这两个时间对比着看。实际发布时启动耗时日志非常有用可以判断缓存预热、数据库连接等环节是不是拖慢了整体启动。启动宣告里的 “APP STARTED SUCCESSFULLY” 关键字不是随便写的。很多监控平台会检索日志关键字来判断服务是否存活把它固定下来比让运维用人眼找日志干净得多。要输出这种固定格式日志前提是监听器一定执行到了也就是说如果某个初始化步骤抛异常整个应用启动会失败自然也就不会输出这个关键字。这样反而是好事因为监控团队拿到的信号与服务真实状态是一致的。3.4 多模块项目中的日志输出模板经验如果你的项目是微服务架构多模块项目里有几十个服务每个服务都在启动完成事件里输出自定义日志但格式各不相同这时候再做监控检索就会很痛苦。我建议在项目里抽一个公共模块专门定义启动日志的输出模板让各服务继承或引入这个公共监听器配置。把第 3.3 节的 StartupReadyLogger 抽到 common-startup 模块里其他服务只需引入依赖触发后就会统一输出 LOGO、服务名、端口、Profile、IP 等字段。这里要注意的是公共模块里用 EventListener 定义全局监听器没问题但如果某些服务不想要这个监听器可以通过条件注解排除Component ConditionalOnProperty(name startup.log.enabled, havingValue true, matchIfMissing true) public class StartupReadyLogger { // ... }matchIfMissing 设置为 true 表示默认开启。某个服务如果因为特殊原因想关掉就在配置里写startup.log.enabledfalse。这种设计可能比在每个服务里重复写监听器更优雅也更好维护。4. 我踩过的坑从重复事件到随机端口4.1 启动日志打印了两次父子容器和事件重放我第一次写启动成功日志的时候明明只加了一个监听器日志却打印了两遍。排查到最后发现是因为类被扫描了两次项目里同时用了 ComponentScan 指定了多个包路径其中一个包路径把公共模块里的监听器扫了两次。这种情况在 Spring Boot 里确实存在最常见的触发原因有三个类路径下有同一个监听器类的多份 Jar或者一个项目里同时包含了多个模块的相同类。父子容器场景比如 DispatcherServlet 的子容器和 Spring 根容器都注册了监听器事件发布时会同时收到。通过 spring.factories 自动注册的监听器同时又被 Component 扫描到也可能重复注册。排查这类问题我习惯在监听器里加一行日志打印当前上下文 IDlog.info(listener fired, contextId{}, event.getApplicationContext().getId());如果两次日志的 contextId 相同说明是同一容器里注册了两份优先检查扫描范围如果 contextId 不同那就是父子容器要根据实际架构决定只在哪个容器中启用监听器。对于常规的单体 Spring Boot 服务最稳妥的办法是严格管理组件扫描路径避免一个类被多个扫描规则重复捞到。4.2 拿到错误端口只读了配置没有读真实端口这个问题在随机端口和容器环境中特别明显。你在 application.yml 里写server.port: 8080本机启动没问题一旦部署到平台平台通过环境变量 SERVER_PORT 注入端口或者使用随机端口那么监听器里直接读server.port属性拿到的很可能是配置值而不是真实监听值。上面第 3.3 节已经给出了解决方案通过 WebServerApplicationContext 读取。我再补充一个更直接的场景如果你在监听器里需要拿到 Servlet 容器的实际端口也可以通过注入 WebServerApplicationContext 的方式EventListener(ApplicationReadyEvent.class) public void showPort(ApplicationReadyEvent event) { if (event.getApplicationContext() instanceof WebServerApplicationContext webContext) { int port webContext.getWebServer().getPort(); log.info(real port {}, port); } }这也是为什么不要在 ApplicationReadyEvent 里直接信 Environment 的原因。实际开发中有的同学甚至通过注入Value(${server.port})来显示端口在随机端口场景下必然踩坑。所以要牢记严格讲配置文件和运行时实际值不是一回事任何启动后的可观测信息都应该读运行时数据。4.3 异步初始化导致“启动完但服务不可用”某一次项目里我在 ApplicationReadyEvent 监听器里启了一个线程池把所有缓存预热任务丢进去随后主线程继续走完打印出了启动成功日志。结果上线后立刻有用户反馈首页加载异常很多依赖缓存的接口都在报空数据。复盘原因很简单缓存预热是业务强依赖线上服务结束启动流程时预热任务可能刚起步半数缓存还是空的请求打进来自然出问题。之后我定下了一个规则凡是对请求正确性有硬性影响的初始化逻辑一律放在 ApplicationRunner 里同步执行不允许异步只有可降级的逻辑比如清除临时目录、上报实例启动指标、发送启动通知才能放进异步线程池。举个例子数据字典预热是强依赖放在 Runner发送企业微信通知“服务已上线”这个是弱依赖放在 ReadyEvent 里用 Async 跑EventListener(ApplicationReadyEvent.class) Async(startupNotifyExecutor) public void notifyStartup(ApplicationReadyEvent event) { notifier.send(服务已启动...); }Async 方法执行时Spring 会找一个 TaskExecutor。项目的线程池定义可以做成这样Bean(startupNotifyExecutor) public Executor startupNotifyExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(1); executor.setMaxPoolSize(2); executor.setQueueCapacity(10); executor.setThreadNamePrefix(startup-notify-); return executor; }这样配置后线程池很小不占用太多资源任务失败也只影响通知不会反过来阻塞主线程。但要注意应用在关闭过程中这个异步线程池如果还有任务在执行可能会出现任务丢的问题规范的工程里最好给线程池配置好的 shutdown 行为或者等待任务结束。4.4 监听器抛异常启动失败或静默失败默认同步事件下监听器里的异常会传播到 SpringApplication.run() 的调用方。如果监听方法里写了一个空指针你会看到应用启动过程最终抛异常进程起不来但日志却已经输出了“容器刷新完成”之类的信息看起来像是成功了一样这对运维来说是个巨大的困惑。我的处理方式是在监听方法内部做好异常边界原则上不让异常外泄。比如启动自检发现数据库连接失败应当明确记录 ERROR并决定是抛出异常让启动失败还是打印告警后继续运行。这里没有绝对的对错关键是要让行为符合团队预期。有一种情况能让你同时保住“应用可启动”和“异常可见”在监听器内捕获异常打印错误日志但不要影响进程EventListener(ApplicationReadyEvent.class) public void onReady(ApplicationReadyEvent event) { try { doStartupCheck(); } catch (Exception e) { log.error(startup check failed, e); } }如果你决定某些检查失败时要阻断启动那就在 Runner 里做因为 Runner 抛出的异常会让 SpringApplication.run() 直接失败应用不会进入 Ready 状态。这两种方式根据业务诉求选一种千万别混着来。线上排查时我看到过有人在 ReadyEvent 里抛异常应用日志里报了 ERROR但端口其实是通的这就造成了监控数据的矛盾。另外还要提防另一种静默失败如果把监听器加了 Async但忘了配置线程池Spring 会用默认的 SimpleAsyncTaskExecutor它每次执行都会新建线程不推荐用在生产环境资源消耗不可控。所以用异步监听前一定要显式声明一个线程池别图省事直接用默认值。5. 多实例部署时的事件只执行一次问题微服务部署都是多实例每个实例启动都会触发 ApplicationReadyEvent。如果启动逻辑里有“发送启动通知”“初始化定时任务”这类全局唯一性的操作会导致所有实例重复执行轻则产生重复数据重则定时任务被多个实例同时拉起任务直接重复消费。这个问题在单机日常开发时不明显K8s 多副本发布后立刻暴露。我常用的方案有两种。第一种是加 Redis 分布式锁。在 ApplicationReadyEvent 监听器里尝试获取一个带过期时间的锁拿到锁的实例才执行全局初始化逻辑拿不到锁的直接跳过EventListener(ApplicationReadyEvent.class) public void onReady(ApplicationReadyEvent event) { String lockKey startup:global:init:lock; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofMinutes(10)); if (!locked) { log.info(another instance is doing global init, skip); return; } doGlobalInit(); }这种方式一定要注意锁的过期时间。如果全局初始化非常耗时锁过期了但任务还没执行完另一个实例就会再进来。更稳妥的方案是初始化完成后主动删除 key而不要在全局初始化任务里放着不管。如果项目没有 Redis可以用数据库的分布式锁表思路一样。第二种方案是区分初始化类型本地缓存预热每个实例都需要做完全不需要加锁只有全局性任务比如“定期清理某张业务表”“初始化定时任务调度”才用分布式锁。实际应用中我强烈建议把这两类初始化逻辑拆到不同的监听器或 Runner 里各自标注清楚这样既不会重复执行无意义的任务也不会造成不必要的锁竞争。6. 几个容易忽略的配置与扩展点近几年用 Kotlin 或高版本 Java 的朋友越来越多。如果你的监听器需要拿到当前应用实际启动耗时Spring Boot 已内置了相关指标。通过ApplicationReadyEvent里的事件时间戳与应用上下文刷新完成时间做差也能大概算出耗时long nanoTime event.getTimestamp();这一字段是 Spring 在内部写死的事件发布时间戳把它和 SpringApplication 启动开始的时间相减可以得到一个近似的启动总耗时。不过坦白说通过监听器方法进入的时间和 run() 返回前的时间差才是业务上更关心的那个“启动完成时间”。另外如果你的启动后逻辑还需要订阅更多事件只需要在同一个监听类里添加多个 EventListener 方法即可。事件机制是 Spring 框架的通用能力除了 ApplicationReadyEvent还可以监听 ContextClosedEvent 做优雅停机前的通知。把这些事件串联起来整个应用的生命周期在日志上会显得非常完整。最后提一个偏工程的习惯如果项目里同时存在多种启动初始化手段我建议在项目根目录的 README 或 CI/CD 的部署文档里把启动事件监听器、Runner、Spring 生命周期回调明确列出来标注它们各自的作用和执行顺序。初期看起来有点多余但项目变大到几十个服务时这种文档能让后来的同事快速理清“启动过程到底干了什么”节省大量排查时间。个人在实际项目中的体会是启动事件监听这件事看上去只是加一个注解的问题但深挖下去它会牵涉到 Spring Boot 生命周期理解、日志规范、初始化任务编排、分布式环境的一致性等等。先把这一套整理明白后续不管打包多少服务启动阶段都不会再让人提心吊胆。

相关新闻

华为交换机DHCP多网段配置与排错实战

华为交换机DHCP多网段配置与排错实战

公司要搭一套带多网段的办公模拟环境,我顺手把 DHCP 实验整个过了一遍。结果发现一个挺有意思的现象:不少人以为 DHCP 就是个"自动发 IP 的小功能",真要动手配置时,卡住的点五花八门——有搞不清"一个 DHCP 服务器…

2026/10/9 6:08:04 阅读更多 →
1、CPU - 计算机品牌排行榜

1、CPU - 计算机品牌排行榜

基于2026年第二季度全球x86处理器市场份额、用户口碑及综合表现,当前电脑CPU品牌排行榜如下,‌英特尔以69.3%的市场份额位居第一‌,AMD紧随其后占据30.7%的份额,其余品牌在细分领域各有优势。 1. 英特尔(Intel) ‌市场表现‌&…

2026/10/9 6:08:03 阅读更多 →
MATLAB结构体完全指南:从S.name到S(2).age的字段访问与批量操作

MATLAB结构体完全指南:从S.name到S(2).age的字段访问与批量操作

1. 为什么结构体值得认真学:从S.name说起如果你只是用MATLAB处理过矩阵和脚本,第一次看到S.name这种写法可能会愣一下。它不是数组下标,不是函数调用,而是结构体的点号字段访问。这东西在MATLAB里几乎无处不在:读取数据…

2026/10/9 6:08:03 阅读更多 →

最新新闻

生产级Coding Agent调优实战:Harness工程化决定落地下限

生产级Coding Agent调优实战:Harness工程化决定落地下限

1. 从"能跑"到"好用":生产级 Coding Agent 的最后一公里到底卡在哪Vibe Coding 这个词这两年被聊得很多,大意是开发者用自然语言描述意图,让 Coding Agent 去生成、修改、验证代码,人只负责把握方向和验收。听…

2026/10/9 6:35:27 阅读更多 →
Java Swing人事管理系统:JDBC+MySQL课程设计实战与避坑指南

Java Swing人事管理系统:JDBC+MySQL课程设计实战与避坑指南

简介:这份资源是一套基于 Java Swing、JDBC 与 MySQL 实现的人事管理系统课程设计项目,面向正在完成数据库课程设计、需要可运行参考案例的计算机相关专业学生。项目包含可视化软件界面,覆盖人员信息维护、数据库连接与增删改查等典型业务场景…

2026/10/9 6:35:27 阅读更多 →
MySQL校对规则:utf8mb4_general_ci与utf8mb4_bin的差异及选型

MySQL校对规则:utf8mb4_general_ci与utf8mb4_bin的差异及选型

1. 这两个校对规则到底在吵什么看你一脸问号地点进来,我猜你多半是遇到过这种情况:建表的时候复制了一段别人的SQL,里面有CHARSETutf8mb4 COLLATEutf8mb4_general_ci,或者是utf8mb4_bin,当时也没多想,能用就…

2026/10/9 6:35:27 阅读更多 →
PS5底层开发合规边界与技术可行性分析

PS5底层开发合规边界与技术可行性分析

我无法根据当前输入生成符合要求的博文。原因如下:项目标题 "AnyPS5" 缺乏明确指向性:该词在公开技术语境中无公认定义,既非官方产品名(索尼未发布/命名过 AnyPS5)、非开源项目(GitHub、GitLab、…

2026/10/9 6:35:27 阅读更多 →
claude-mem:为Claude Code打造跨会话长期记忆的实战指南

claude-mem:为Claude Code打造跨会话长期记忆的实战指南

用过 Claude Code 写真实项目的人,基本都遇到过这个场景:昨天刚跟 AI 讨论清楚的一个架构方案,今天新开一个会话,它完全不记得了。你在同一个仓库里翻历史对话记录,发现上一个会话已经把项目的来龙去脉都喂给了它&…

2026/10/9 6:35:27 阅读更多 →
Agent-Reach:LLM API智能路由与成本可控调度中枢

Agent-Reach:LLM API智能路由与成本可控调度中枢

1. 项目概述:Agent-Reach 是什么?它解决的不是“能不能用”,而是“怎么用得稳、用得准、用得省”Agent-Reach 这个名字乍看像某个开源模型或工具库,但结合 CLI、API、YouTube、Reddit 这些高频热词,再叠加上“zcode cl…

2026/10/9 6:34:27 阅读更多 →

日新闻

Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南

Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南

Java时间API这个话题,隔三差五就会在群里被翻出来讨论一次。上周还有个同事线上处理一个订单超时问题,排查到最后发现是ZonedDateTime序列化后时区丢了,用户在下单当天晚上看到的时间整整差了8个小时。这类问题几乎每个做Java开发的人都遇到过…

2026/10/9 0:00:49 阅读更多 →
EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错

EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错

前几个月我手头有好几台机器需要互相访问:办公室台式机、家里 NAS、还有一台云主机。如果只是偶尔传个文件倒还好,问题是工作场景经常要在几处环境之间来回切换,每次都先登录跳板机再层层代理,实在折腾。我先后试过端口映射、自建…

2026/10/9 0:00:49 阅读更多 →
AI Agent工程实战:从七要素到七个决策点的系统设计指南

AI Agent工程实战:从七要素到七个决策点的系统设计指南

AI Agent 这个词在过去一年里被反复提及,但真正动手搭过一套能跑起来的 Agent 系统的人都知道,从"知道它是什么"到"让它稳定干活"之间隔着一整套工程决策。我前后参与过几个 Agent 项目的落地,从最初用现成框架拼装&…

2026/10/9 0:01:50 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:32 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:40 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 10:10:36 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 21:13:17 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 6:17:20 阅读更多 →