2024年4月

本次监控将采用Prometheus、Grafana可视化工具以及postgres_exporter对OpenTenBase进行全面监控和优化。

安装监控

Docker安装

1、Docker要求 CentOs 系统的内核版本高于 3.10

通过 uname-r命令查看你当前的内核版本

uname -r

2、使用 root 权限登录 Centos。确保 yum 包更新到最新。

yum -y update

3、卸载旧版本(如果安装过旧版本的话)

sudo yum remove -y docker*

4、安装需要的软件包,yum-utl 提供yum-config-manager功能,另外两个是devicemapper驱动依赖的

yum install -y yum-utils

5、设置yum源,并更新yum 的包索引

yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
yum makecache

6、安装docker

yum install -y docker-ce

8、启动并加入开机启动

systemctl start docker && systemctl enable docker

9、验证安装是否成功(有client和service两部分表示docker安装启动都成功了)

docker version

10、配置docker镜像

cd /etc/docker
然后编辑
vim daemon.json

{
  "registry-mirrors": ["https://jbw52uwf.mirror.aliyuncs.com"]
}

保存退出。

重启docker服务

systemctl daemon-reload
systemctl restart docker

下载Prometheus

在进行监控优化时,您可以从Prometheus官方网站下载最新版:
https://prometheus.io/download/

您可以选择下载源代码并解压使用,也可以通过Docker直接启动。本教程将重点介绍使用Docker进行快速部署。

执行命令:

docker run -d -p 9090:9090 -v /etc/prometheus:/etc/prometheus prom/prometheus

完成挂载后,请对配置文件进行必要的修改以确保系统正常监控。

vim prometheus.yml

# my global config
global:
  scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:
          # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: "prometheus"

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
      - targets: ["192.168.56.10:9090"]
  # 主要修改这里,添加文件形式的扫描
  - job_name: "node"
    file_sd_configs:
    - refresh_interval: 10s
      files:
      - "/etc/prometheus/conf/node*.yaml"

当前Prometheus的配置采用文件形式进行服务发现。在修改配置时,无需重新启动,系统将自动更新并生效,更新间隔为10秒。

为了修改相关配置文件,首先创建一个名为conf的目录(
mkdir conf
)然后通过cd命令进入目录(
cd /etc/prometheus/conf
)接着使用vim编辑器来修改文件(
vim node-ms.yaml

- targets:
  - "ip:port"
  labels:
    hostname: pg

为了自定义配置信息,请将相应的IP地址和主机名修改为您自己的信息。完成修改后,启动Prometheus服务,然后您可以通过访问http://您的IP地址:9090/ 来查看Prometheus的监控数据。

下载Grafana

为了确保配置的持久性,我们可以通过Docker容器以持久化形式启动Grafana。您可以使用以下命令来启动Grafana容器,并在容器重启后保留配置信息:

docker run -d -p 3000:3000 --name=grafana --volume grafana-storage:/var/lib/grafana grafana/grafana-enterprise

启动后,您可以在浏览器中输入http://您的IP地址:3000/

使用默认的用户名和密码admin/admin登录,以查看Grafana监控界面。

配置数据源

image

在这里,您只需填写URL(
http://ip:9090/
)即可保存配置。这个URL指向Prometheus的地址,Grafana将通过该地址与Prometheus建立连接,从而获取数据用于展示监控面板。

image

下载Exporter

Prometheus官方提供了丰富的Exporter,您可以在https://prometheus.io/docs/instrumenting/exporters/ 找到相关信息。

我们可以安装postgres_exporter来监控数据库,官方地址为https://github.com/prometheus-community/postgres_exporter。

同样可以以Docker启动:

docker run --net=host -e DATA_SOURCE_NAME="postgresql://opentenbase:@ip:port/postgres?sslmode=disable" quay.io/prometheuscommunity/postgres-exporter

ip和host修改为自己的信息即可,官方示例中对opentenbase用户并没有设置登录密码,我们也不设置密码进行登录。

启动后,我们首先登录到数据库中,然后进行数据库用户的相关设置。

CREATE OR REPLACE FUNCTION __tmp_create_user() returns void as $$
BEGIN
  IF NOT EXISTS (
          SELECT                       -- SELECT list can stay empty for this
          FROM   pg_catalog.pg_user
          WHERE  usename = 'postgres_exporter') THEN
    CREATE USER postgres_exporter;
  END IF;
END;
$$ language plpgsql;
SELECT __tmp_create_user();

DROP FUNCTION __tmp_create_user();

ALTER USER postgres_exporter WITH PASSWORD 'password';

ALTER USER postgres_exporter SET SEARCH_PATH TO postgres_exporter,pg_catalog;

GRANT CONNECT ON DATABASE postgres TO postgres_exporter;

-- OpenTenBase中集成的PostgreSQL版本是10,所以可以执行以下语句,历史版本可前往开源地址进行查看。
GRANT pg_monitor to postgres_exporter;

postgres_exporter启动报错修复

panic: Error converting setting "session_memory_size" value "3M" to float: strconv.ParseFloat: parsing "3M": invalid syntax

goroutine 42 [running]:
main.(*pgSetting).metric(0xc000081720, 0xc0000d5c50?)
        /app/cmd/postgres_exporter/pg_setting.go:87 +0x325
main.querySettings(0x0?, 0xc00010d290)
        /app/cmd/postgres_exporter/pg_setting.go:56 +0x287
main.(*Server).Scrape(0xc00010d290, 0xc000028011?, 0x90?)
        /app/cmd/postgres_exporter/server.go:121 +0xcb
main.(*Exporter).scrapeDSN(0xc0000000c0, 0x44d406?, {0xc000028011, 0x46})
        /app/cmd/postgres_exporter/datasource.go:115 +0x1c5
main.(*Exporter).scrape(0xc0000000c0, 0x0?)
        /app/cmd/postgres_exporter/postgres_exporter.go:679 +0x16c
main.(*Exporter).Collect(0xc0000000c0, 0xc00003ff60?)
        /app/cmd/postgres_exporter/postgres_exporter.go:568 +0x25
github.com/prometheus/client_golang/prometheus.(*Registry).Gather.func1()
        /go/pkg/mod/github.com/prometheus/client_golang@v1.17.0/prometheus/registry.go:457 +0xe7
created by github.com/prometheus/client_golang/prometheus.(*Registry).Gather in goroutine 18
        /go/pkg/mod/github.com/prometheus/client_golang@v1.17.0/prometheus/registry.go:547 +0xbab

查看postgres_exporter其源码发现端倪:

SELECT name, setting, COALESCE(unit, ''), short_desc, vartype FROM pg_settings WHERE vartype IN ('bool', 'integer', 'real') AND name != 'sync_commit_cancel_wait';

image

确实是因为session_memory_size的显示问题,不过我已经提交了PR修复,官方修复后即可成功。

image

配置监控面板

一旦所有组件都成功启动,接下来我们需要前往市场寻找我们想要的监控面板。你可以访问Grafana的官方仪表板市场:
https://grafana.com/grafana/dashboards/?search=postgresql

image

一旦找到您喜欢的面板,请点击此处进行导入。以下以ID:9628为示例进行导入操作。

这里选择我们的数据源。

image

让我们来看一下效果如何:

image

总结

本次监控将采用Prometheus、Grafana可视化工具以及postgres_exporter对OpenTenBase进行全面监控和优化。首先,通过Docker安装了Prometheus,配置了必要的文件形式进行服务发现,实现了系统正常监控。接着,使用Docker启动Grafana,并配置数据源连接到Prometheus,展示监控面板。最后,安装了postgres_exporter以监控数据库,并解决了启动报错问题。在配置监控面板时,通过Grafana的仪表板市场找到了适合的监控面板,并成功导入使用。

使用场景

RequiredActionProvider,它是在认证过程中,需要当前登录的用户执行个性化的动作;当用户符合条件,就被执行RequiredActionProvider对作,当RequiredActionProvider没有正常提交(
context.success()
)之前,当前用户仍然是
未登录
状态,这在keycloak框架中,也有一些默认的个性化动作,它与整个登录流程是解耦的,事实上,keycloak的设计理念也是微架构设计,插件化设计。

keycloak默认提供的RequiredActionProvider

  • VERIFY_EMAIL 验证邮箱
  • UPDATE_PROFILE 更新用户信息
  • CONFIGURE_TOTP 配置totp多因子认证
  • UPDATE_PASSWORD 强制更新密码,用在临时建立的密码场景(CredentialRepresentation中的isTemporary为true时执行)
  • TERMS_AND_CONDITIONS 用户在首次登录时会被要求查看并接受特定的服务条款和条件
  • VERIFY_PROFILE 验证个人信息

keycloak后台配置RequiredActionProvider

在侧-验证菜单,选择Required Action标签,可以管理它们,开启或者设置成默认,同时也可以添加自定义的RequiredActionProvider

1 配置列表

2 添加新的Required Action

自定义的RequiredActionProvider

下面我们添加一个自定义的RequiredActionProvider,业务场景是,当登录用户名前缀是test时,就让这个用户去验证手机号

1 添加一个
UpdatePhoneNumberRequiredAction
文件,让它实现RequiredActionProvider接口

public class UpdatePhoneNumberRequiredAction implements RequiredActionProvider {

    public static final String PROVIDER_ID = "UPDATE_PHONE_NUMBER";

    @Override
    public void evaluateTriggers(RequiredActionContext context) {
    }

    @Override
    public void requiredActionChallenge(RequiredActionContext context) {
        Response challenge = context.form()
                .createForm("login-update-phone-number.ftl");
        context.challenge(challenge);
    }

    @Override
    public void processAction(RequiredActionContext context) {
        TokenCodeServiceProvider tokenCodeServiceProvider = context.getSession().getProvider(TokenCodeServiceProvider.class);
        String phoneNumber = context.getHttpRequest().getDecodedFormParameters().getFirst("phoneNumber");
        String code = context.getHttpRequest().getDecodedFormParameters().getFirst("code");
        try {
            tokenCodeServiceProvider.validateCode(context.getUser(), phoneNumber, code);
            context.success();
        } catch (BadRequestException e) {

            Response challenge = context.form()
                    .setError("noOngoingVerificationProcess")
                    .createForm("login-update-phone-number.ftl");
            context.challenge(challenge);

        } catch (ForbiddenException e) {

            Response challenge = context.form()
                    .setAttribute("phoneNumber", phoneNumber)
                    .setError("verificationCodeDoesNotMatch")
                    .createForm("login-update-phone-number.ftl");
            context.challenge(challenge);
        }
    }

    @Override
    public void close() {
    }
}

2 添加UpdatePhoneNumberRequiredActionFactory文件,让它去构建上面的UpdatePhoneNumberRequiredAction实例

public class UpdatePhoneNumberRequiredActionFactory implements RequiredActionFactory {

    private static final UpdatePhoneNumberRequiredAction instance = new UpdatePhoneNumberRequiredAction();

    @Override
    public String getDisplayText() {
        return "";
    }

    @Override
    public RequiredActionProvider create(KeycloakSession session) {
        return instance;
    }

    @Override
    public void init(Scope scope) {
    }

    @Override
    public void postInit(KeycloakSessionFactory sessionFactory) {
    }

    @Override
    public void close() {
    }

    @Override
    public String getId() {
        return UpdatePhoneNumberRequiredAction.PROVIDER_ID;
    }
}

3 在resources/META-INF/services/文件夹下,添加org.keycloak.authentication.RequiredActionFactory文件,通过SPI的方式,注册咱们的UpdatePhoneNumberRequiredActionFactory工厂

org.keycloak.phone.authentication.requiredactions.UpdatePhoneNumberRequiredActionFactory

4 添加咱们这个UpdatePhoneNumberRequiredActionFactory,它在keycloak后台RequiredActionProvider中,显示的名称是“Update Phone Number”,我们去添加并开启它

5 在brower的认证流程中,你需要在context.success()之前去判断用户名的前缀,并为它指定RequiredAction,如果是对所有用户有效的,那不需要添加以下代码,可以把它在keycloak后台,设置为“默认”行为即可。

  • 要想使RequiredAction生效,需要先在keycloak后台启用它
  • 要想对新建用户启用它,需要先在keycloak后台启用它,并开启“默认”选项【默认是对新用户来说的,老用户不受这个值的控制,就是说你新建一个requiredAction,没有任何规则,如果你开启+默认,那它只对所有新建用户有效】
  • 要想对某些规则的用户启用它,需要在form表单认证时,添加对应的业务逻辑,可以添加自定义的"Authenticator"来实现这个逻辑,尽量不修改之前的核心代码。
  • 当前用户执行的RequiredAction步骤,会在数据表
    user_required_action
    中存储,用户每次登录都会检查这个表的状态,如果用户存在这表里,你的对应的RequiredAction关闭了,事实上,当前这个老用户在登录时依然会走这个RequiredAction逻辑。
  • 注意,这个
    user_required_action
    表产生的数据会有缓存,只删除数据表记录是不起作用的,需要重启keycloak
  if(context.getUser().getUsername().startsWith("test")){
    context.getUser().addRequiredAction(UpdatePhoneNumberRequiredAction.PROVIDER_ID);
  }
  context.success();

好了,到目前来说,咱们用户名登录时,前缀为test的用户,都会走这个手机验证的界面了,在验证成功前,用户是不能直接登录的。

引言

Spring Boot 提供了许多便捷的功能和特性,使得开发者可以更加轻松地构建强大、高效的应用程序。然而,在应用程序启动时执行一些初始化操作是至关重要的,它可以确保应用程序在启动后处于预期的状态,从而提供更好的用户体验和稳定性。

在应用程序启动时执行初始化操作有许多好处。首先,它可以确保应用程序在启动后的初始状态是正确的,避免了在应用程序运行时出现意外情况。其次,它可以在应用程序准备好接受请求之前完成一些必要的设置,例如加载配置、建立数据库连接、缓存预热等。总的来说,执行初始化操作可以确保应用程序以正确的方式启动,并为后续操作提供一个稳定的基础。

image.png

监听 ApplicationContext事件

Spring Boot应用程序启动时执行初始化操作的方法是通过监听
ApplicationContext
事件。
ContextRefreshedEvent
事件表示
ApplicationContext
被初始化或刷新时触发的事件。通过监听这个事件,开发者可以在应用程序启动后执行一些必要的初始化操作。

image.png

示例:

@Component
public class MyContextRefreshedListener implements ApplicationListener<ContextRefreshedEvent> {

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        System.out.println("监听到ContextRefreshedEvent事件,开始初始化操作。。。。。。。");
    }
}

这种方式适合以下场景:

  1. 执行一次性初始化操作:
    当应用程序启动时,可能需要执行一些只需在应用程序初始化阶段执行一次的操作,例如加载基础数据、建立连接等。通过监听
    ContextRefreshedEvent
    事件,可以确保这些初始化操作在应用程序启动后立即执行。

  2. 初始化缓存或缓存刷新:
    如果应用程序使用了缓存,可能需要在应用程序启动时初始化缓存或定期刷新缓存。通过监听
    ContextRefreshedEvent
    事件,可以在应用程序启动后立即执行缓存初始化或刷新操作,确保缓存数据是最新的。

  3. 执行与外部系统的交互:
    在应用程序启动时,可能需要与外部系统进行交互,例如检查外部系统的可用性、加载配置信息等。通过监听
    ContextRefreshedEvent
    事件,可以在应用程序启动后立即执行与外部系统的交互操作,确保应用程序在启动后处于正常工作状态。

  4. 执行与 Spring Bean 相关的初始化操作:
    在应用程序启动时,可能需要执行一些与 Spring Bean 相关的初始化操作,例如在数据库连接池初始化后执行数据库迁移、在消息队列连接初始化后执行订阅操作等。通过监听
    ContextRefreshedEvent
    事件,可以确保这些初始化操作在 Spring Bean 初始化完成后立即执行

这种方式能够确保在 ApplicationContext 被完全初始化或刷新后执行初始化操作,可以在这个时机执行一些需要
ApplicationContext
完全准备好的操作。但是需要注意的是,ContextRefreshedEvent 事件可能会在应用程序的刷新周期内多次触发,因此在处理这个事件时需要谨慎处理,避免重复执行初始化逻辑。

实现CommandLineRunner接口

CommandLineRunner
是Spring Boot提供的一个接口,它有一个
run
方法,当Spring Boot应用上下文初始化完成后,会自动查找并执行所有实现了
CommandLineRunner
接口的Bean的
run
方法。
CommandLineRunner
接口实际上是Spring Boot对Spring框架生命周期管理的一个扩展,通过对接口的实现,我们可以在Spring Boot应用启动后的特定阶段执行自定义的初始化逻辑。

image.png

示例:

@Component
public class MyCommandLineRunner implements CommandLineRunner {

    @Override
    public void run(String... args) throws Exception {
        System.out.println("MyCommandLineRunner.run()方法执行了");
    }
}

使用场景:

  1. 命令行参数处理

    CommandLineRunner
    接口常用于处理从命令行传入的参数,例如运行不同模式下的任务(如dev模式、prod模式)、读取配置项等。
  2. 应用启动后的一次性操作
    :在应用启动后,可能需要进行一些一次性执行的任务,如数据库表结构检查、初始化缓存、发送通知邮件等。

使用
CommandLineRunner
接口这种方式是,我们只需要实现接口,无需关注容器的生命周期事件或手动注册监听器。但是如果是多个
CommandLineRunner
之间的执行顺序无法保证,可能会带来不确定性(如果是不关心顺序,那就不是缺点了)。另外,我们不应该在``
run
方法中实现过多或较为复杂的任务。

实现ApplicationRunner接口

ApplicationRunner
是Spring Boot提供的另一个接口,它也有一个
run
方法,与
CommandLineRunner
接口非常相似。当Spring Boot应用启动并且ApplicationContext初始化完成后,Spring Boot会查找并执行所有实现了
ApplicationRunner
接口的Bean的
run
方法。

image.png

ApplicationRunner
的主要特点是其
run
方法接收一个
ApplicationArguments
参数,它可以更好地解析和处理命令行参数,包括选项参数(键值对)和非选项参数。

示例:

@Component
public class ApplicationArgumentProcessor implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        System.out.println("ApplicationArgumentProcessor.run()方法执行了");
    }
}

使用场景:

  1. 命令行参数解析
    :由于
    ApplicationArguments
    提供了丰富的参数解析能力,因此更适合处理带有键值对形式的命令行参数,如
    --server-port=8080
    ,然后根据这些参数执行不同的初始化操作。
@Component
public class ApplicationArgumentProcessor implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        Optional<Integer> port = args.getOptionValues("server-port").stream()
                .map(Integer::parseInt)
                .findFirst();
        if (port.isPresent()) {
            // 根据端口号进行特定的初始化操作
        }
    }
}
  1. 启动时初始化
    :同
    CommandLineRunner
    ,也可用于执行启动后的一次性操作,例如读取配置、初始化缓存、检查系统资源等,同时可以根据解析的命令行参数决定初始化的具体内容。

相比较于
CommandLineRunner

ApplicationRunner
提供了更强大的命令行参数解析功能,可以轻松处理各种类型的参数。可以根据命令行参数灵活调整启动时的初始化逻辑。但是其缺点同
CommandLineRunner

ApplicationRunner

CommandLineRunner
都可以用来在Spring Boot启动时执行特定代码,两者在应用场景上略有差异,具体选择哪种取决于项目的实际需求和命令行参数的复杂程度。

使用@PostConstruct注解

@PostConstruct
注解是JSR-250规范的一部分,Spring框架对此提供了支持。当Spring容器管理的Bean完成依赖注入后,会自动调用标注有
@PostConstruct
的方法。这个注解应用于无参或void返回值的方法上,表明该方法应在依赖注入完成后,但在Bean实例正式投入使用之前调用。

在Spring Boot启动时,当Spring容器初始化并创建Bean时,如果发现某个Bean上有
@PostConstruct
注解的方法,则会在Bean的生命周期的初始化阶段调用这个方法。

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;
    
    @PostConstruct
    public void init() {
        // 在依赖注入完成后,执行初始化操作
        System.out.println("UserService初始化...");
        // 初始化数据库连接、缓存或者其他内部状态
    }
}

使用场景:

  1. 单个Bean初始化
    :对于某个特定的Bean,在其所有依赖项注入完成后,需要执行一些特定的初始化操作,例如数据库连接初始化、缓存预热、初始化内部状态等。
  2. 资源初始化
    :对于一些公共资源,如线程池、数据库连接池等,可以在对应的配置类或服务类中使用
    @PostConstruct
    来完成初始化设置。

@PostConstruct
注解只需要在需要执行初始化操作的方法上加上即可,无需额外实现接口或关注Spring容器的生命周期事件。并且针对性强,仅针对单个Bean进行初始化操作,有助于提高代码的模块化和复用性。

但是如果有多个具有
@PostConstruct
注解的方法,它们之间没有明确的执行顺序,除非通过Bean间的依赖关系隐式确定顺序。并且针对单个Bean进行初始化操作,所以他并不适合做全局性初始化操作。

@Bean注解中指定初始化方法

@Bean
注解在Spring框架中用于定义一个Bean的实例化逻辑,通常在配置类中使用。通过在
@Bean
注解中指定
initMethod
属性,可以设置一个在Bean实例化并完成依赖注入后执行的方法。当Spring容器创建并注入完所有依赖关系后,会自动调用该Bean上指定的初始化方法。

@Configuration
public class PrePostConfig {
    /**
     * 指定初始化init
     * @return
     */
    @Bean(initMethod = "init")
    BeanWayService beanWayService(){
        return new BeanWayService();
    }
}

public class BeanWayService {

    public void init() {
        System.out.println("@Bean-init-method");
    }
    
    public BeanWayService(){
        super();
        System.out.println("初始化构造函数-BeanWayService");
    }
}

适用场景:

  1. 资源初始化
    :例如,初始化数据库连接、网络连接、线程池等资源。
  2. Bean状态设置
    :在Bean实例化后,对其进行额外的状态设定或配置。
  3. 缓存预热
    :在服务启动时预先加载部分数据至缓存中。

Bean实例上定义初始化方法,与Bean紧密关联,可以精确地控制Bean在何时执行初始化操作,与Spring容器的生命周期绑定,尤其适用于那些需要在Bean实例化后立即执行的操作。。但是如果多个Bean都有初始化方法,它们之间的执行顺序难以控制,除非依赖于Spring容器中Bean的依赖注入顺序。

实现InitializingBean接口

InitializingBean
是Spring框架中的一个接口,它包含一个方法
afterPropertiesSet()
。当Spring容器完成了对一个Bean的所有必要属性的依赖注入后,如果该Bean实现了
InitializingBean
接口,Spring会自动调用其
afterPropertiesSet()
方法。

@Component
public class MyService implements InitializingBean {

    @Autowired
    private Dependency dependency;

    @Override
    public void afterPropertiesSet() throws Exception {
        // 在所有依赖注入完成后执行的初始化逻辑
        System.out.println("MyService初始化...");
        // 初始化资源、设置状态或执行其他操作
    }

    // 其他业务方法...
}

适用场景:

  1. 资源初始化
    :如初始化数据库连接、网络连接、线程池等资源。
  2. Bean状态设置
    :在依赖注入完成后,设置Bean的初始状态或执行特定的配置操作。

afterPropertiesSet()
方法会在所有属性注入完成后执行,确保Bean在使用前完成初始化。不需要额外的注解,只需实现接口就可以定义初始化逻辑。但是其要求Bean实现特定接口,增加了类的耦合度,同时也不符合Spring倡导的基于注解的编程风格。并且需要显式抛出异常。

相比较于
@PostConstruct

@PostConstruct
注解更具语义化且不强制类实现接口,降低了耦合度。推荐优先考虑使用
@PostConstruct
注解进行初始化逻辑的编写。

@EventListener注解

@EventListener
注解在Spring应用程序中定义事件监听器。通过监听
ApplicationReadyEvent
事件,我们可以确保在应用程序完全启动并准备好接受请求时执行初始化逻辑。通过在监听器方法上添加
@EventListener
注解,并指定要监听的事件类型,可以在事件发生时执行相应的初始化操作。

@Component
public class StartupEventListener {

    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReadyEvent(ApplicationReadyEvent event) {
        System.out.println("Spring Boot应用已启动并准备就绪,开始执行初始化操作...");
        // 在这里执行需要在应用启动后进行的初始化代码
    }
}

适用场景:

  1. 应用启动后执行一次性操作
    :如数据初始化、缓存预热、统计信息收集等。
  2. 等待所有Bean初始化后再执行
    :当需要确保所有Bean都已经初始化完毕再执行某些操作时。

通过事件驱动的方式,将初始化逻辑与Bean的创建逻辑解耦开来,并且可以监听多种事件类型(例如:
ContextRefreshedEvent
),不仅仅是应用启动事件,还可用于其他业务场景。相比于
@PostConstruct

CommandLineRunner

ApplicationRunner
等机制,
@EventListener
监听的
ApplicationReadyEvent
在Spring Boot启动流程中的执行时机较晚,所有Bean都已经初始化并准备就绪后才会触发。

总结

本文全面探讨了Spring Boot启动阶段执行初始化操作的几种常见方法,包括监听事件、实现接口以及使用注解等多种策略,具体如下:

  1. 监听ApplicationContext事件
    :通过实现
    ApplicationListener<ContextRefreshedEvent>
    接口,监听
    ContextRefreshedEvent
    事件,可在Spring容器初始化完成后执行初始化逻辑。这种方式适用于需要在所有Bean加载完毕后进行全局性初始化操作的场景。

  2. 实现CommandLineRunner接口
    :Spring Boot启动后,会自动调用实现了
    CommandLineRunner
    接口的Bean的
    run
    方法,该方法可以处理命令行参数并执行启动时的特定操作。适用于需要根据命令行参数执行初始化逻辑或进行启动后一次性任务的情况。

  3. 实现ApplicationRunner接口
    :与
    CommandLineRunner
    类似,
    ApplicationRunner
    也在Spring Boot启动后执行其
    run
    方法,但其参数为
    ApplicationArguments
    ,提供了更强大的命令行参数解析功能。适合处理键值对形式的命令行参数并据此执行初始化任务。

  4. 使用@PostConstruct注解
    :在Bean的方法上添加
    @PostConstruct
    注解,Spring会在该Bean的所有依赖注入完成后调用该方法进行初始化。这种方法用于单个Bean初始化完成后的特定逻辑,增强了代码的模块化和可维护性。

  5. @Bean注解中指定初始化方法
    :通过
    @Bean
    注解中的
    initMethod
    属性指定Bean的初始化方法,该方法在Bean实例化并完成注入后由Spring容器调用。这种方法适用于需要对特定Bean进行精细化初始化管理的场景。

  6. 实现InitializingBean接口
    :Bean实现
    InitializingBean
    接口并重写
    afterPropertiesSet
    方法,也能实现在依赖注入完成后执行初始化逻辑。虽然传统但不如使用
    @PostConstruct
    注解优雅,且增加了类的耦合度。

  7. 使用@EventListener注解
    :通过监听
    ApplicationReadyEvent
    等事件,可以在Spring Boot应用启动并准备就绪后执行初始化任务。这种方式延迟执行,适用于在所有Bean初始化完毕且应用已经完全启动后才需要进行的操作。

每种方法均有其适用场景和优缺点,我们应根据项目需求和具体情况选择最适合的初始化方式。通过熟练掌握和灵活运用这些方法,能够有效地管理和优化Spring Boot应用的启动流程,确保应用程序在启动之初即进入正常运作状态。

本文已收录于我的个人博客:
码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等


前言

在讨论ThreadLocal存在内存泄漏问题之前,需要先了解下面几个知识点:

  • 什么是内存泄漏?
  • 什么是ThreadLocal?
  • 为什么需要ThreadLocal?
    • 数据一致性问题
    • 如何解决数据一致性问题?

当我们了解了上面的知识点以后,会带大家一起去了解真相。包括下面几个知识点:

  • 为什么会产生内存泄漏?
  • 实战复现问题
  • 如何解决内存泄漏?
  • 为什么是弱引用?

只有了解上面的知识点,才能更好的理解以及如何解决ThreadLocal内存泄漏问题。下面我们就开始带大家一步一步的去了解。


什么是内存泄漏?

在讨论ThreadLocal存在内存泄漏问题之前,我觉得有必要先了解一下什么是内存泄漏?我们为什么要解决内存泄漏的问题?这里引用一段百度百科对内存泄漏的解释。

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

从Java的内存管理来说,就是ThreadLocal存在无法被GC回收的内存。这些无法被回收的内存,如果随着时间的推移,从而导致超出内存容量「内存溢出」,最终导致程序崩溃「OutOfMemoryError」。所以为了避免我们的Java程序崩溃,我们必须要避免出现内存泄漏的问题。


ThreadLocal

前面讲了什么是内存泄漏,为什么要解决内存泄漏的问题。现在我们来讲讲什么是ThreadLocal?

简单来说,ThreadLocal是一个本地线程副本变量工具类。ThreadLocal让每个线程有自己”独立“的变量,线程之间互不影响。ThreadLocal为每个线程都创建一个副本,每个线程可以访问自己内部的副本变量。


为什么需要ThreadLocal?

现在我们知道了什么是ThreadLocal,接下来我们讲讲为什么需要ThreadLocal在讲为什么需要ThreadLocal之前,我们需要了解一个问题。那就是数据一致性问题。因为ThreadLocal就是解决数据一致性问题的一种方案,只要当我们了解什么是数据一致性问题后,自然就知道为什么需要ThreadLocal了。



什么是一致性问题?

多线程充分利用了多核CPU的能力,为我们程序提供了很高的性能。但是有时候,我们需要多个线程互相协作,这里可能就会涉及到数据一致性的问题。 数据一致性问题指的是:发生在多个主体对同一份数据无法达成共识。



如何解决一致性问题?


  • 排队
    :如果两个人对一个问题的看法不一致,那就排成一队,一个人一个人去修改它,这样后面一个人总是能够得到前面一个人修改后的值,数据也就总是一致的了。Java中的互斥锁等概念,就是利用了排队的思想。排队虽然能够很好的确保数据一致性,但性能非常低。

  • 投票
    :,投票的话,多个人可以同时去做一件决策,或者同时去修改数据,但最终谁修改成功,是用投票来决定的。这个方式很高效,但它也会产生很多问题,比如网络中断、欺诈等等。想要通过投票达到一致性非常复杂,往往需要严格的数学理论来证明,还需要中间有一些“信使”不断来来回回传递消息,这中间也会有一些性能的开销。我们在分布式系统中常见的Paxos和Raft算法,就是使用投票来解决一致性问题的。

  • 避免
    :既然保证数据一致性很难,那我能不能通 过一些手段,去避免多个线程之间产生一致性问题呢?我们熟悉的Git就是这个实现,大家在本地分布式修改同一个文件,通过版本控制和解决冲突去解决这个问题。而ThreadLocal也是使用的这种方式。


为什么会产生内存泄漏?

上面讲清楚了ThreadLocal的基本含义,接下来我们一起看看ThreadLocal常用函数的源码,只有了解ThreadLocal的具体实现才能更好的帮助我们理解它为什么会产生内存泄漏的问题。



set()方法

public void set(T value) {
    Thread t = Thread.currentThread(); 
    ThreadLocalMap map = getMap(t); 
    if (map != null)
        map.set(this, value); 
    else
        createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

从上面的源码可以看出,当我们调用ThreadLocal对象的set()方法时,其实就是将ThreadLocal对象存入当前线程的ThreadLocalMap集合中,map集合的key为当前ThreadLocal对象,value为set()方法的参数。

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {

        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }

 private Entry[] table;
}

这是ThreadLocalMap的源码(由于篇幅原因这里我只取了重要的代码),可以看到ThreadLocalMap中使用一个Entry对象来存储数据,而Entry的key则是一个WeakReference弱引用对象。这里我带大家再复习一下Java对象的几种引用。


  • 强引用
    :java中的引用默认就是强引用,任何一个对象的赋值操作就产生了对这个对象的强引用。如: Object o = new Object() ,只要强引用关系还在,对象就永远不会被回收。

  • 软引用
    :java.lang.ref.SoftReference,JVM会在内存溢出前对其进行回收。

  • 弱引用
    :java.lang.ref.WeakReference,不管内存是否够用,下次GC一定回收。

  • 虚引用
    :java.lang.ref.PhantomReference,也称“幽灵引用”、“幻影引用”。虚作用是跟踪垃圾回收器收集对象的活动,在GC的过程中,如果发现有PhantomReference,GC则会将引用放到ReferenceQueue中,由程序员自己处理,当程序员调用ReferenceQueue.pull()方法,将引用出ReferenceQueue移除之后,Reference对象会变成Inactive状态,意味着被引用的对象可以被回收了,虚引用的唯一的目的是对象被回收时会收到一个系统通知。



实战复现问题

上面我们已经了解了ThreadLocal存储数据的set()方法,现在我们来看一段代码,通过代码来分析ThreadLocal为什么会产生内存泄漏。

public class Test {

    @Override
    protected void finalize() throws Throwable {
        System.err.println("对象被回收了");
    }
}
@Test
void test() throws InterruptedException {
    ThreadLocal<Test> local = new ThreadLocal<>();
    local.set(new Test());
    local = null;
    System.gc();
    Thread.sleep(1000000);
}

我们创建一个测试类,并重写finalize()方法,当对象被回收时会打印消息在控制台方便我们测试观察对象是否被回收。

从代码可以看到,我们创建了一个ThreadLocal对象,然后往对象里面设置了一个new Test对象,然后我们将变量local赋值为null,最后手动触发一下gc。大家可以猜猜,控制台会打印出对象被回收了的消息吗?建议大家动手试试,增加一下理解。

在告诉大家答案之前我们先来分析一下上面的一个引用关系:

Untitled

示例中local = null这行代码会将强引用2断掉,这样new ThreadLocal对象就只有一个弱引用4了,根据弱引用的特点在下次GC的时候new ThreadLocal对象就会被回收。那么new Test对象就成了一个永远无法访问的对象,但是又存在一条强引用链thread→Thread对象→ThreadLocalMap→Entry→new Test,如果这条引用链一直存在就会导致new Test对象永远不会被回收。因为现在大多时候都是使用线程池,而线程池会复用线程,就很容易导致引用链一直存在,从而导致new Test对象无法被回收,一旦这样的情况随着时间的推移而大量存在就容易引发内存泄漏。


如何解决内存泄漏?

我们已经知道了造成内存泄漏的原因,那么要解决问题就很简单了。

上面造成内存泄漏的第一点就是Entry的key也就是new ThreadLocal对象的强引用被断开了,我们就可以想办法让这条强引用无法断开,比如将ThreadLocal对象设置为private static 保证任何时候都能访问new ThreadLocal对象同时避免其他地方将其赋值为null。

还有一种办法就是想办法将new Test对象回收,从根本上解决问题。下面我们一起看看ThreadLocal为我们提供的方法。



remove()方法

public void remove() {
   ThreadLocalMap m = getMap(Thread.currentThread());
   if (m != null)
       m.remove(this);
}
private void remove(ThreadLocal<?> key) {
  Entry[] tab = table;
  int len = tab.length;
  int i = key.threadLocalHashCode & (len-1);
  for (Entry e = tab[i];
       e != null;
       e = tab[i = nextIndex(i, len)]) {
      if (e.get() == key) {
          e.clear();
          expungeStaleEntry(i);
          return;
      }
  }
}
private int expungeStaleEntry(int staleSlot) {
      Entry[] tab = table;
      int len = tab.length;

      // expunge entry at staleSlot
      tab[staleSlot].value = null;
      tab[staleSlot] = null;
      size--;
   // 省略代码...感兴趣可以去看看源码
      return i;
  }

该方法的逻辑是,将entry里value的强引用3和key的弱引用4置为null。这样new Test对象和Entry对象就都能被GC回收。

因此,只要调用了
expungeStaleEntry()
就能将无用 Entry 回收清除掉。

但是该方法为private故无法直接调用,但是ThreadLocalMap中
remove()
方法直接调用了该方法,因此只要当我们使完ThreadLocal对象后调用一下remove()方法就能避免出现内存泄漏了。

综上所述:针对ThreadLocal 内存泄露的原因,我们可以从两方面去考虑:

  1. 删除无用 Entry 对象。即 用完ThreadLocal后手动调用remove()方法。
  2. 可以让ThreadLocal对象的强引用一直存在,保证任何时候都可以访问到 Entry的 value值。即 将ThreadLocal 变量定义为 private static。


为什么是弱引用?

不知道大家有没有想过一个问题,既然是弱引用导致的内存泄漏,那么为什么JDK还要使用弱引用。难道是bug吗?大家再看一下下面这段代码。

@Test
void test() throws InterruptedException {
    ThreadLocal<Test> local = new ThreadLocal<>();
    local.set(new Test());
    local = null;
    System.gc();
    Thread.sleep(1000000);
}

我们假设Entrykey使用强引用,那么引用图就是如下

Untitled

当代码local = null断掉强引用2的时候,new ThreadLocal对象就是只存在一条强引用4,那么由于强引用的关系GC无法回收new ThreadLocal对象。所以就造成了Entry的key和value都无法访问无法回收了,内存泄漏就加倍了。

同理也不能将Entry的value设置为弱引用,因为Entry对象的value即new Test对象只有一个引用,如果使用弱引用,在GC的时候会导致new Test对象被回收,导致数据丢失。

将Entry的key设置为弱引用还有一个好处就是,当强引用2断掉且弱引用4被GC回收后,ThreadLocal会通过key.get() == null识别出无用Entry从而将Entry的key和value置为null以便被GC回收。具体代码如下

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

所以,Entry key使用弱引用并不是一个bug,而是ThreadLocal的开发人员在尽力的帮助我们避免造成内存泄漏。


彩蛋

@Test
void test2() throws InterruptedException {
    ThreadLocal<Test> local = new ThreadLocal<>();
  local.set(new Test());
    local = null;
    System.gc();
   for (int i = 0; i < 9; i++) {
        new ThreadLocal<>().get();
    }
    System.gc();
    Thread.sleep(1000000);
}

感兴趣的同学可以尝试运行上面的代码,你会发现惊喜的!至于结果大家自己动手去获取吧!。下面我们再来看一个ThreadLocal常用的方法。



get()方法

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  Entry[] tab = table;
  int len = tab.length;

  while (e != null) {
      ThreadLocal<?> k = e.get();
      if (k == key)
          return e;
      if (k == null)
          expungeStaleEntry(i);
      else
      i = nextIndex(i, len);
      e = tab[i];
  }
  return null;
}

从上面的代码你会惊奇的发现,get方法也会调用expungeStaleEntry()方法,当然不是每次get都会调用。逻辑大家可以去看源码慢慢理。这里再提一下,可以顺便看看完整的set方法,你还会发现秘密。

本文使用
markdown.com.cn
排版

背景

时间过得很快啊,一转眼已经到了 2024 年,还记得 15 年刚工作那会掌握个
SSM/H(Spring/Struts2/Mybatis/Hibernate)
框架就能应付大部分面试了。



现在 CS 专业的新同学估计都没听说过 SSM