SqlSugar(及数据库 ORM)并发冲突

深入理解 SqlSugar(及数据库 ORM)并发冲突:“A command is already in progress” 错误原理与解决方案

1. 错误现象

在使用 SqlSugar 或其他 .NET 数据库驱动(如 Npgsql、EF Core、Dapper)时,多线程并发操作可能会抛出异常:

A command is already in progress

该错误常见于:

  • MQTT 消息并发处理
  • Web API 多请求共享同一个 SqlSugarClient 实例
  • 后台服务多个异步任务共用数据库操作对象

2. 根本原因:数据库连接是“单工”通道

2.1 顺序式单命令通道

PostgreSQL(以及绝大多数关系型数据库)的 连接协议 是顺序式的,一个物理数据库连接(TCP 连接)在同一个时间 只能处理一个 SQL 命令

  • 即使使用异步代码 await db.InsertAsync(...),底层仍然需要按顺序发送命令、等待响应。
  • 若前一个命令尚未完成,又在同一连接上发起第二个命令(例如来自另一线程),驱动会拒绝并抛出上述异常。

类比:这就像打电话给客服,你还没说完第一件事,又急着说第二件事——对方会要求你“等我说完”再继续。

2.2 SqlSugarClient 的本质

SqlSugarClient 封装了一个数据库连接(DbConnection,如 NpgsqlConnection),并在内部持有该连接:

var db = new SqlSugarClient(config);
  • IsAutoCloseConnection = false(默认),连接会一直保持打开,所有操作共用同一个物理连接。
  • 如果多个线程共享这一个 db 实例,它们会尝试在同一个连接上并发执行 SQL → 违反“单命令”规则 → 报错。

示例(错误写法):

// 假设单例或静态共享实例
SqlSugarClient db = new SqlSugarClient(config);

// 线程 A
await db.InsertAsync(data1); // 执行 INSERT

// 线程 B 几乎同时
await db.InsertAsync(data2); // ❌ 同一个连接再次发起命令 → 冲突!

3. 解决方案:每次操作新建 SqlSugarClient 实例

3.1 原理

每次创建新的 SqlSugarClient 并设置 IsAutoCloseConnection = true 时:

  1. 内部会从 连接池 获取一个空闲的物理连接(或新建)。
  2. 执行完 SQL 后自动调用 connection.Close(),将连接归还池中,不会长时间占用。
  3. 每个操作拥有独立的“逻辑连接”,底层由连接池高效复用物理连接,实现线程隔离。

正确写法:

using var db = new SqlSugarClient(config);
db.Ado.IsAutoCloseConnection = true;
await db.Insertable(data).ExecuteCommandAsync();

3.2 流程步骤

步骤 说明
1. 创建 SqlSugarClient 内部新建一个 NpgsqlConnection
2. 执行 SQL 从连接池获取空闲物理连接(或新建)
3. IsAutoCloseConnection = true 执行完后自动调用 connection.Close()
4. using 释放 SqlSugarClientDispose,资源清理
5. 连接归还池 物理连接不会真正断开,归还连接池供下次复用

✅ 结果:每个操作都有自己的数据库上下文,天然线程安全,无并发冲突。


4. 连接池(Connection Pooling)的作用

Npgsql(及其他 ADO.NET 驱动)默认 启用连接池

  • 调用 connection.Close() 时,物理 TCP 连接 并不会真正断开,而是放回池中。
  • 下次新建连接时,直接从池中拿一个现成的,开销极小(纳秒~微秒级)。
  • 因此,频繁创建/销毁 SqlSugarClient 几乎无性能损失,反而是最佳实践。

5. 为什么不建议共享 SqlSugarClient 实例?

场景 是否安全 原因
单线程顺序执行 ✅ 安全 命令串行,无并发
多线程/异步并发 ❌ 不安全 多个任务争用同一个连接
ASP.NET Core 每个请求用同一个实例 ❌ 危险 框架多线程处理请求,多个请求共享同一实例会导致冲突

⚠️ SqlSugar 官方文档明确说明:SqlSugarClient 不是线程安全的,不应跨线程共享。


6. EF Core 的类比

EF Core 的 DbContext 同样不是线程安全的,官方推荐:

  • Web 请求:每个请求一个 DbContext(注册为 Scoped)。
  • 后台服务:每次操作用 IServiceScope 创建新上下文。

原理完全一致:隔离连接上下文,避免并发冲突


7. 实战案例分析:TelemetryBuffer 遥测缓冲区

7.1 问题代码

public class TelemetryBuffer
{
    private readonly SqlSugarClient _db; // ❌ 成员变量,整个生命周期内复用

    public TelemetryBuffer(...)
    {
        _db = new SqlSugarClient(SqlSugarFactory.CollectionDbConfig());
    }

    public void Add(CdTelemetryData telemetry)
    {
        _queue.Enqueue(telemetry);
        if (_queue.Count >= _bufferSize) Flush();
    }

    public void Flush()
    {
        // 多个线程可能同时进入这里
        _db.Insertable(telemetries).ExecuteCommand(); // ❌ 使用共享的 _db
    }
}

问题根源

  • TelemetryBuffer 可能被注册为 SingletonScoped,MQTT 消息异步并发到达。
  • 多个线程同时调用 AddFlush,它们共用同一个 _db 实例,底层同一个连接无法并发执行命令。

7.2 修复后的安全版本

public class TelemetryBuffer : IDisposable
{
    private readonly int _bufferSize;
    private readonly int _pageSize = 500;
    private readonly ILogger<TelemetryBuffer> _logger;
    private readonly ConcurrentQueue<CdTelemetryData> _queue = new();

    // ✅ 不再持有 _db 成员变量

    public TelemetryBuffer(int bufferSize, ILogger<TelemetryBuffer> logger)
        : this(bufferSize, 500, logger) { }

    public TelemetryBuffer(int bufferSize, int pageSize, ILogger<TelemetryBuffer> logger)
    {
        _bufferSize = bufferSize;
        _pageSize = pageSize;
        _logger = logger;
    }

    public void Add(CdTelemetryData telemetry)
    {
        _queue.Enqueue(telemetry);
        if (_queue.Count >= _bufferSize) Flush();
    }

    public void Flush()
    {
        if (_queue.IsEmpty) return;

        var telemetries = new List<CdTelemetryData>();
        while (_queue.TryDequeue(out var telemetry))
        {
            telemetry.CreateTime = DateTime.Now;
            telemetries.Add(telemetry);
        }

        // ✅ 每次 Flush 创建新 SqlSugarClient,用完立即释放
        using var db = new SqlSugarClient(SqlSugarFactory.CollectionDbConfig())
        {
            Ado = { IsAutoCloseConnection = true }
        };

        try
        {
            int resultCount = db.Insertable(telemetries)
                               .PageSize(_pageSize)
                               .ExecuteCommand();
            if (resultCount == 0)
            {
                _logger.LogError("批量插入遥测数据失败: {Data}", JsonConvert.SerializeObject(telemetries));
            }
            else
            {
                _logger.LogInformation("批量插入遥测数据成功,插入数量: {Count}", resultCount);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "批量插入遥测数据异常: {Message}", ex.Message);
            // 可根据业务需要将失败数据重新入队或存入死信队列
        }
    }

    public void Dispose()
    {
        Flush();
    }
}

关键修改

  • 移除成员变量 _db
  • Flush() 方法内部使用 using 创建临时 SqlSugarClient
  • 设置 IsAutoCloseConnection = true 确保连接归还池。
  • 每个 Flush() 调用完全隔离,线程安全。

7.3 进一步优化建议

  • 配置源头保证自动关闭:在 SqlSugarFactory.CollectionDbConfig() 中直接设置 IsAutoCloseConnection = true
  • 异步化(可选):将 Flush() 改为异步版本 FlushAsync(),使用 ExecuteCommandAsync(),但需注意避免在 MQTT 回调中直接 await,可结合后台任务定期批量刷新。
  • 连接池监控:在 Npgsql 连接字符串中添加 Maximum Pool Size 等参数,防止连接耗尽。

8. 总结与最佳实践

实践 原理/效果
每次操作新建 SqlSugarClient 为每个操作分配独立逻辑连接,线程安全
设置 IsAutoCloseConnection = true 确保连接用完归还池,避免泄漏
使用 using 语句 保证资源及时释放
绝不跨线程共享 ORM 实例 避免多线程竞争同一连接
充分利用连接池 高频创建/销毁连接几乎无性能损耗

💡 核心思想:在 .NET 中,几乎所有数据库客户端(EF Core、Dapper、SqlSugar、原生 ADO.NET)都要求“每个操作使用独立连接上下文”以保证线程安全。这不是 SqlSugar 的缺陷,而是所有基于连接的数据库驱动的通用约束。

掌握这个原理后,你可以举一反三地处理任何 ORM 的并发问题。

posted @ 2026-08-10 09:08  古月秋筠  阅读(10)  评论(0)    收藏  举报