在多项目共存的技术栈里,连接字符串的管理往往散落各处:ASP.NET Core 读
appsettings.json,WinForms 读 App.config,Job
服务可能两边都要用。更麻烦的是,配置文件里的连接串通常不能明文存储,需要在加载时统一解密。本文基于
SwitchData 项目中的
DbConnectionManager,讲讲如何用一个静态类把这些事情一次性做对:原子初始化、双环境回退、线程安全缓存与启动重试。
一、要解决的问题
我们把需求拆开看:
- 单一入口:所有业务代码通过
DbConnectionManager.Get("数据库名")拿连接串,不再各自读配置; - 解密透明:配置文件里存的是密文,加载时自动解密,业务方无感;
- 双环境兼容:ASP.NET Core 环境用
IConfiguration,WinForms 环境用ConfigurationManager,同一套代码都要能跑; - 并发安全:静态类天然会被多线程访问,初始化必须只执行一次;
- 启动韧性:数据库可能晚于应用启动(容器编排场景常见),需要带重试的等待机制。
整体结构如下:
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;
}
三个设计点:
- 解密收口:
Add是唯一把密文变明文的地方,Init和回退加载都调用它。想换加密算法、加密钥轮换,只改这一处; StringComparer.OrdinalIgnoreCase:配置文件里写MainDb还是maindb不应导致查不到连接串,字典创建时就指定比较器,比每次ToLower()高效且无分配;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),初始化本身做成异步的,可以支持从远程配置中心拉取连接串的场景。但对于”本地配置
+
解密”这种毫秒级操作,本文的同步方案足够简单可靠——并发控制手段的选择,永远跟着操作的耗时和场景走。