在多项目共存的技术栈里,连接字符串的管理往往散落各处:ASP.NET Core 读 appsettings.json,WinForms 读 App.config,Job 服务可能两边都要用。更麻烦的是,配置文件里的连接串通常不能明文存储,需要在加载时统一解密。本文基于 SwitchData 项目中的 DbConnectionManager,讲讲如何用一个静态类把这些事情一次性做对:原子初始化、双环境回退、线程安全缓存与启动重试。

一、要解决的问题

我们把需求拆开看:

  1. 单一入口:所有业务代码通过 DbConnectionManager.Get("数据库名") 拿连接串,不再各自读配置;
  2. 解密透明:配置文件里存的是密文,加载时自动解密,业务方无感;
  3. 双环境兼容:ASP.NET Core 环境用 IConfiguration,WinForms 环境用 ConfigurationManager,同一套代码都要能跑;
  4. 并发安全:静态类天然会被多线程访问,初始化必须只执行一次;
  5. 启动韧性:数据库可能晚于应用启动(容器编排场景常见),需要带重试的等待机制。

整体结构如下:

flowchart TD
    A[业务调用 Get 库名] --> B{Init 已执行?}
    B -- 已初始化 --> E[ConcurrentDictionary 取值]
    B -- 未初始化 --> C[双检锁回退加载 App.config]
    C --> D[解密并写入缓存]
    D --> E
    E -- 命中 --> F[返回明文连接串]
    E -- 未命中 --> G[抛 KeyNotFoundException]

二、Interlocked 做一次性初始化守卫

先看主入口的初始化逻辑:

private static int _initialized = 0;

public static void Init(IConfiguration configuration)
{
    // Interlocked.Exchange 原子地把 1 写入 _initialized
    // 并返回旧值:旧值为 1 说明别人已经初始化过了
    if (Interlocked.Exchange(ref _initialized, 1) == 1)
    {
        return;
    }

    var section = configuration.GetSection("ConnectionStrings");
    foreach (var child in section.GetChildren())
    {
        if (!string.IsNullOrWhiteSpace(child.Key) && !string.IsNullOrWhiteSpace(child.Value))
        {
            Add(child.Key, child.Value);
        }
    }
}

很多人写”只执行一次”会想到 lock,但其实一个 int 标志位 + Interlocked.Exchange 更轻:

  • Exchange 是硬件级原子操作,不涉及内核态锁竞争;
  • 它同时完成”写入”和”读取旧值”两步,天然防住了竞态:即使 10 个线程同时进入,也只有第一个拿到旧值 0 的线程会继续初始化,其余全部拿到 1 直接返回;
  • 相比 Lazy<T>,这种方式允许显式指定初始化时机(程序启动时 Init),而不是首次访问时才隐式触发。

注意这里 _initialized 声明为 int 而不是 bool——因为 Interlocked 系列方法只支持 int/long/object/float 等类型,这是写高性能守卫代码的常见习惯。

三、volatile + lock 的双检锁回退

ASP.NET Core 的 Program.cs 会显式调用 Init,但 WinForms 老项目没有这一步。于是 Get 里设计了回退路径:

private static int _fallbackInitialized = 0;
private static volatile bool _fallbackInitializedVolatile = false;
private static readonly object _fallbackLock = new();

public static string Get(string databaseName)
{
    if (_initialized == 0 && !_fallbackInitialized)
    {
        lock (_fallbackLock)
        {
            if (!_fallbackInitialized)
            {
                LoadFromConfigurationManager();
                _fallbackInitialized = true;
            }
        }
    }

    if (_connectionStrings.TryGetValue(databaseName, out var value))
    {
        return value;
    }

    throw new KeyNotFoundException($"数据库 '{databaseName}' 连接字符串不存在");
}

这就是经典的双检锁(Double-Check Locking),有两个容易被忽略的细节:

第一,为什么要检查两次? 第一层检查(lock 外)是为了性能——绝大多数调用发生在初始化完成后,直接读标志位返回,不碰锁。第二层检查(lock 内)是正确性保证——假设线程 A 和 B 同时通过第一层检查,A 先拿到锁完成初始化,B 进锁后必须再查一次,否则会重复加载。

第二,为什么字段要加 volatile? .NET 的内存模型允许编译器和 CPU 对指令重排。如果没有 volatile,另一个线程可能”看到”_fallbackInitialized = true 已经写入,却看到 LoadFromConfigurationManager 填充的字典内容还没完全可见(指令重排或缓存未刷新),从而跳过加锁直接读到一个不完整的字典。volatile 保证了写入标志位之前的所有写入,对后续读取该标志位的线程可见——先初始化,后置标志,这个顺序由 volatile 写的语义锁死。

回退加载本身很简单,唯一的坑是 ConfigurationManager 会返回一个系统内置的 LocalSqlServer 项,需要过滤掉:

private static void LoadFromConfigurationManager()
{
    var conSettings = System.Configuration.ConfigurationManager.ConnectionStrings;
    if (conSettings == null) return;

    for (int i = 0; i < conSettings.Count; i++)
    {
        var cs = conSettings[i];
        if (cs == null || string.IsNullOrWhiteSpace(cs.Name)) continue;

        // 过滤系统默认项,避免解密失败
        if (cs.Name.Equals("LocalSqlServer", StringComparison.OrdinalIgnoreCase))
        {
            continue;
        }
        Add(cs.Name, cs.ConnectionString);
    }
}

四、解密收口在 Add,缓存用 ConcurrentDictionary

所有连接串最终都汇入同一个私有方法:

private static readonly ConcurrentDictionary<string, string> _connectionStrings
    = new(StringComparer.OrdinalIgnoreCase);

private static string Add(string databaseName, string connectionString)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(databaseName);
    ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);

    // 唯一的解密入口:无论来源是 json 还是 App.config,都走这里
    var decrypted = SymmetricCryptographer.Decrypt(connectionString);

    _connectionStrings[databaseName] = decrypted;

    return decrypted;
}

三个设计点:

  1. 解密收口:Add 是唯一把密文变明文的地方,Init 和回退加载都调用它。想换加密算法、加密钥轮换,只改这一处;
  2. StringComparer.OrdinalIgnoreCase:配置文件里写 MainDb 还是 maindb 不应导致查不到连接串,字典创建时就指定比较器,比每次 ToLower() 高效且无分配;
  3. ConcurrentDictionary 而非 Dictionary + lock:初始化阶段有锁保护,但运行期是纯读多线程并发,ConcurrentDictionary 的读路径无锁,是最合适的选择。

五、WaitForReadyAsync:带退避的启动等待

容器化部署时经常出现”应用先起、数据库后起”。WaitForReadyAsync 提供了一个可配置的重试等待:

public static async Task WaitForReadyAsync(string databaseName,
    int retryCount = 10, int delaySeconds = 5, CancellationToken cancellationToken = default)
{
    var database = DatabaseFactory.CreateDatabase(databaseName);
    using var connection = database.CreateConnection();

    Exception lastException = null;
    for (int i = 0; i < retryCount; i++)
    {
        try
        {
            await connection.OpenAsync(cancellationToken);
            Logger.Info("数据库连接成功");
            return;
        }
        catch (Exception ex)
        {
            lastException = ex;
            Logger.Warn($"数据库未就绪,第 {i + 1}/{retryCount} 次重试:{ex.Message}");

            if (i < retryCount - 1)
            {
                await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
            }
        }
    }

    throw new Exception($"数据库连接超时(重试 {retryCount} 次)", lastException);
}

值得学习的三处细节:

  • using var connection:连接对象在方法退出时(无论成功失败)都会释放,重试共用一个连接对象,OpenAsync 失败后可以再次 Open;
  • Task.Delay + CancellationToken:等待期间支持取消,进程收到停止信号时不会白等 5 秒。注意 Task.Delay 传 token 后被取消会抛 OperationCanceledException,正好让外层中断启动流程;
  • 保留 lastException:最终抛出的聚合异常把最后一次的真实原因作为 innerException 带出来。如果只抛一句”连接超时”,排查时你会抓瞎。

六、总结

这个不到 130 行的静态类,浓缩了几个非常实用的工程模式:

技术点 解决的问题 关键 API
Interlocked.Exchange 显式初始化只跑一次,无锁 原子写 + 读旧值
volatile + lock 双检锁 隐式回退初始化的线程安全与可见性 内存屏障语义
ConcurrentDictionary + 比较器 运行期无锁读、大小写不敏感 构造函数传入 comparer
解密收口单方法 密钥、算法变更只改一处 单一职责
WaitForReadyAsync 数据库晚启动的韧性等待 重试 + CancellationToken

一个可以继续演进的方向:把 _initialized 换成 Task 字段(TaskCompletionSource),初始化本身做成异步的,可以支持从远程配置中心拉取连接串的场景。但对于”本地配置 + 解密”这种毫秒级操作,本文的同步方案足够简单可靠——并发控制手段的选择,永远跟着操作的耗时和场景走。