rpc节点: synchronized (this) + 双检锁,在 race condition 的情况下分析

结合rpc节点刷新业务,讲解 Java 中 synchronized (this) 的作用、原理和在代码里的具体意义。

这段代码的核心逻辑回顾

java 复制代码
public SolanaRpcClient client() {
    SolanaRpcClient client = this.healthyClient;

    // 定期检查健康状态
    if (client == null || System.currentTimeMillis() - lastHealthCheck.get() > HEALTH_CHECK_INTERVAL_MS) {
        synchronized (this) {   // ← 这里
            client = this.healthyClient;
            if (client == null || System.currentTimeMillis() - lastHealthCheck.get() > HEALTH_CHECK_INTERVAL_MS) {
                refreshHealthyClient();
                client = this.healthyClient;
            }
        }
    }
    return client;
}

这段代码的目的是:在多线程环境下安全地获取一个健康的 SolanaRpcClient,并且每隔 30 秒(HEALTH_CHECK_INTERVAL_MS)检查一次健康状态,如果过期或为空,就刷新一次。

synchronized (this) 到底在干什么?

synchronized (this) 是一种对象监视器锁(monitor lock),它的作用是:

  • 保证同一时刻只有一个线程能进入这个 synchronized 块
  • 其他线程如果也想进入同一个 synchronized (this) 块,就必须等待锁被释放。

在这里,this 指的是当前对象实例(即 SolanaRpcClientService 这个 Spring @Service bean 的实例)。

所以这段代码的锁范围是:

  • 所有调用 client() 方法的线程,如果同时判断需要刷新,就会竞争同一个对象(this)的锁。
  • 只有一个线程能成功进入 synchronized 块,去执行 refreshHealthyClient()

为什么需要加 synchronized (this)?

因为这段代码运行在多线程环境 (Spring Boot 应用默认是多线程的,Web 请求、定时任务、消息队列消费者等都可能并发调用 client())。

如果不加锁,会出现以下经典的竞态条件(race condition):

场景模拟(不加锁的情况)

假设 HEALTH_CHECK_INTERVAL_MS 刚好过期,healthyClient 还是旧的(或 null)。

线程 A 和线程 B 几乎同时调用 client()

  1. 线程 A:看到 client == null 或过期 → 进入 if
  2. 线程 B:也看到 client == null 或过期 → 进入 if
  3. 线程 A:调用 refreshHealthyClient() → 刷新成功,healthyClient 被赋值为新客户端
  4. 线程 B:也调用 refreshHealthyClient() → 又刷新一次(重复工作,浪费资源)
  5. 更糟的情况:如果 refreshHealthyClient() 内部有非线程安全的操作(比如修改共享状态),可能导致数据不一致或异常。

即使 refreshHealthyClient() 本身是线程安全的,重复调用也会浪费网络请求(多次 getSlot 检查),增加延迟。

加了 synchronized (this) 后:

  • 线程 A 先抢到锁 → 进入 synchronized 块 → 刷新 healthyClient
  • 线程 B 在 synchronized 块门口等待
  • 线程 A 刷新完退出锁 → 线程 B 进入 → 发现 healthyClient 已经是最新的(或刚刷新过),时间戳也更新了 → 直接跳过 if,不再重复刷新

synchronized (this) 的具体保护范围

java 复制代码
synchronized (this) {
    client = this.healthyClient;                          // 重新读取(防止指令重排序)
    if (client == null || 过期) {                         // 双重检查(Double-Checked Locking 模式)
        refreshHealthyClient();                           // 只有第一个线程会真正刷新
        client = this.healthyClient;                      // 刷新后重新读取
    }
}
  • 双重检查锁定 (Double-Checked Locking):这是经典的懒加载/缓存刷新模式。
    • 第一次检查(if)在锁外 → 减少不必要的加锁
    • 第二次检查(if)在锁内 → 保证线程安全
  • volatile 修饰 healthyClient 确保可见性(其他线程能看到最新的值)
  • AtomicLong 的 lastHealthCheck 也保证原子更新时间戳

为什么用 synchronized (this) 而不是其他锁?

方式 优点 缺点 是否适合这里
synchronized (this) 简单、直观、代码量少 锁粒度是整个对象(如果类有其他 synchronized 方法,会竞争) 适合(当前场景简单)
private final Object lock = new Object(); synchronized (lock) 锁粒度更细,不会影响其他方法 多写一行代码 更好(推荐)
ReentrantLock 可中断、可公平、可条件等待 代码复杂 没必要
synchronized 方法 更简洁 锁粒度更大(整个方法) 不如块好

推荐改进:为了降低锁竞争风险,建议改成专用锁对象:

java 复制代码
private final Object healthLock = new Object();

public SolanaRpcClient client() {
    SolanaRpcClient client = this.healthyClient;

    if (client == null || System.currentTimeMillis() - lastHealthCheck.get() > HEALTH_CHECK_INTERVAL_MS) {
        synchronized (healthLock) {   // ← 改成专用锁
            client = this.healthyClient;
            if (client == null || System.currentTimeMillis() - lastHealthCheck.get() > HEALTH_CHECK_INTERVAL_MS) {
                refreshHealthyClient();
                client = this.healthyClient;
            }
        }
    }
    return client;
}

这样即使类里以后加了其他 synchronized 方法,也不会和健康检查锁冲突。

总结一句话

synchronized (this) 在这里的作用是:

  • 防止多个线程同时重复调用 refreshHealthyClient(),避免浪费资源和潜在的并发问题。
  • 配合双重检查锁定,确保高并发下健康客户端的刷新是线程安全的,而且只刷新一次。

这是 Java 多线程编程中非常经典的"缓存刷新 + 懒加载"模式,在 RPC 客户端、配置中心、连接池等高可用组件中几乎都会用到。

下边进行定时刷新的逻辑

好的,使用 ScheduledExecutorService定时刷新健康 RPC 节点,而不是原来的"按需刷新"(即调用 client() 时才检查),是一个更推荐的生产级方案。

为什么定时刷新更好?

  • 按需刷新的缺点:

    • 第一次调用或过期时,所有并发请求都会竞争锁 → 可能导致"惊群效应"(thundering herd)
    • 高并发场景下,refresh 操作会被频繁触发,浪费资源
    • 延迟敏感:如果正好在过期瞬间调用,可能会短暂阻塞
  • 定时刷新的优点:

    • 刷新是后台异步的,不阻塞业务线程
    • 业务调用 client() 时几乎总是拿到最新的健康节点(无锁、无等待)
    • 资源消耗可控(固定周期,比如每 30 秒刷新一次)
    • 更容易监控和添加告警(刷新失败可以发 metrics 或日志)

下面是基于原有代码的完整改造版本,使用 ScheduledExecutorService 定时刷新。

java 复制代码
package com.forms.sdk.client.rpc;

import com.forms.sdk.config.SolanaRpcConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Service;
import software.sava.rpc.json.http.client.SolanaRpcClient;

import javax.annotation.PostConstruct;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

@Service
public class SolanaRpcClientService implements DisposableBean {

    private static final Logger log = LoggerFactory.getLogger(SolanaRpcClientService.class);

    private final List<String> rpcUrls;
    private final Duration connectTimeout;
    private final Duration requestTimeout;

    // 当前健康的客户端列表(AtomicReference 保证可见性)
    private final AtomicReference<List<SolanaRpcClient>> healthyClientsRef = 
            new AtomicReference<>(Collections.emptyList());

    // 当前首选客户端(轮询或随机用)
    private final AtomicReference<SolanaRpcClient> primaryClientRef = new AtomicReference<>();

    // 定时刷新器
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
        Thread t = new Thread(r, "Solana-RPC-HealthChecker");
        t.setDaemon(true);
        return t;
    });

    public SolanaRpcClientService(SolanaRpcConfig config) {
        this.rpcUrls = config.getRpcEndpoints();
        if (rpcUrls == null || rpcUrls.isEmpty()) {
            throw new IllegalStateException("No Solana RPC URLs configured!");
        }

        this.connectTimeout = config.getRpcConnectTimeout() != null
                ? config.getRpcConnectTimeout() : Duration.ofSeconds(30);
        this.requestTimeout = config.getRpcRequestTimeout() != null
                ? config.getRpcRequestTimeout() : Duration.ofSeconds(60);

        log.info("SolanaRpcClientService initialized with {} endpoints: {}", rpcUrls.size(), rpcUrls);
    }

    @PostConstruct
    public void init() {
        // 启动时立即刷新一次
        refreshHealthyClients();

        // 每 30 秒定时刷新(可配置)
        scheduler.scheduleAtFixedRate(this::refreshHealthyClients, 0, 30, TimeUnit.SECONDS);

        log.info("Started periodic RPC health check every 30 seconds");
    }

    @Override
    public void destroy() {
        // Spring 容器关闭时优雅停止定时器
        scheduler.shutdown();
        try {
            if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
                scheduler.shutdownNow();
            }
        } catch (InterruptedException e) {
            scheduler.shutdownNow();
            Thread.currentThread().interrupt();
        }
        log.info("SolanaRpcClientService shutdown completed");
    }

    /**
     * 业务代码调用此方法获取客户端(几乎无锁、高性能)
     */
    public SolanaRpcClient client() {
        SolanaRpcClient client = primaryClientRef.get();
        if (client != null) {
            return client;
        }

        // 极端情况:启动初期或全部节点宕机,降级到任意一个
        List<SolanaRpcClient> clients = healthyClientsRef.get();
        if (!clients.isEmpty()) {
            return clients.get(0); // 或 random
        }

        // 如果真的没有可用节点,抛异常或返回 null(业务自行处理)
        log.error("No available Solana RPC client at the moment");
        throw new IllegalStateException("No healthy Solana RPC endpoint available");
    }

    /**
     * 获取所有健康客户端(用于监控或手动选择)
     */
    public List<SolanaRpcClient> getHealthyClients() {
        return Collections.unmodifiableList(healthyClientsRef.get());
    }

    /**
     * 后台定时刷新健康节点列表
     */
    private void refreshHealthyClients() {
        List<SolanaRpcClient> newHealthy = new ArrayList<>();
        Exception lastError = null;

        for (String url : rpcUrls) {
            try {
                SolanaRpcClient client = buildClient(url);
                // 轻量健康检查
                client.getSlot().join();

                newHealthy.add(client);
                log.debug("Healthy RPC endpoint: {}", url);

            } catch (Exception e) {
                lastError = e;
                log.warn("RPC endpoint unhealthy: {} → {}", url, e.toString());
            }
        }

        if (!newHealthy.isEmpty()) {
            // 更新原子引用
            healthyClientsRef.set(Collections.unmodifiableList(newHealthy));

            // 随机或轮询选一个作为 primary(这里用第一个,也可 random)
            SolanaRpcClient newPrimary = newHealthy.get(0);
            primaryClientRef.set(newPrimary);

            log.info("Refreshed {} healthy RPC endpoints, primary: {}", 
                     newHealthy.size(), newHealthy.get(0));
        } else {
            log.error("All {} RPC endpoints are down! Last error: {}", 
                      rpcUrls.size(), lastError != null ? lastError : "unknown");
            // 可选:这里可以发告警(Prometheus、邮件等)
        }
    }

    private SolanaRpcClient buildClient(String url) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(connectTimeout)
                .build();
        return SolanaRpcClient.createClient(URI.create(url), httpClient);
    }
}

关键变化说明

  1. 使用 @PostConstruct 启动定时刷新

    • Spring Bean 初始化完成后立即执行一次 refreshHealthyClients()
    • 然后每 30 秒定时执行(可配置成 15s、60s 等)
  2. 使用 AtomicReference 存储健康列表和 primary 客户端

    • 保证多线程可见性,无需 synchronized
    • client() 方法几乎无锁,直接读取引用(极高性能)
  3. 实现了 DisposableBean 接口

    • 容器关闭时优雅停止 scheduler,避免线程泄漏
  4. 去掉了按需检查的 synchronized 块

    • 业务线程不再阻塞在刷新上
    • 刷新失败时业务仍可继续使用旧的 healthyClients(直到下次刷新成功)
  5. 降级策略

    • 如果健康列表为空,client() 会抛异常(可改成返回 null 或 fallback 到默认 RPC)

进一步优化建议

  • 可配置刷新间隔 :把 30 秒写到配置中 config.getHealthCheckInterval()
  • 轮询 primary:每次刷新后 random 或 round-robin 选 primaryClient
  • 监控:集成 Micrometer/Prometheus,暴露 healthyClients.size() 作为 gauge
  • 失败重试:在 refresh 时,对单个节点失败可以加重试(retry 2 次)

这样改造后,RPC 客户端变得更稳定、更高效,也更适合生产环境。

相关推荐
麦兜*1 小时前
SpringBoot Profile多环境配置详解,一套配置应对所有场景
java·数据库·spring boot
笃行客从不躺平1 小时前
Seata + AT 模式 复习记录
java·分布式
CTO Plus技术服务中1 小时前
强悍的Go语言开发面试题和答案
java·面试·职场和发展
黎雁·泠崖2 小时前
Java static入门:概述+静态变量特点与基础实战
java·开发语言
一条大祥脚2 小时前
26.1.21 根号分治 相向双指针
java·开发语言·redis
迦蓝叶2 小时前
JDBC元数据深度实战:企业级数据资源目录系统构建指南
java·jdbc·企业级·数据资源·数据血缘·数据元管理·构建指南
chilavert3182 小时前
技术演进中的开发沉思-327 JVM:内存区域与溢出异常(下)
java·jvm
冲刺逆向2 小时前
【js逆向案例六】创宇盾(加速乐)通杀模版
java·前端·javascript