SqlSugar Provider
这一页讲 XiHan.Framework.Persistence.SqlSugar:两种创建客户端的方式、SqlSugarConventions.Apply 施加了哪些约定、InitTables 建表、多库落点与命名连接、按时间分表、读写分离、差异日志、命令超时,以及可空值类型判空与 PostgreSQL 的两个注意事项。读完它,你能把应用切到 SqlSugar,并用上它独有的多库与分表能力。前置知识是持久化与 Provider与工作单元与事务。
为什么需要
已经用 SqlSugar 的项目不该为了接入框架推倒重来;有些场景 EF Core 也做不到:一次调用既写平台表又写租户分片库、日志类实体按月分表、写入时拿到前后值做差异日志、读走从库写走主库、运行时按记录连外部库。SqlSugar Provider 以作用域内的 ISqlSugarClient 实现仓储与事务型工作单元,实体不继承 SqlSugar 基类、不必标 [SugarColumn],映射由协议约定推出;原生查询继续用自己的客户端。
概念
| 项 | 内容 |
|---|---|
| 包 | XiHan.Framework.Persistence.SqlSugar,依赖 SqlSugarCore(自带各数据库驱动)与 Microsoft.Data.Sqlite;应用改用 SqlSugarCoreNoDrive 加单个驱动时要整体替换 |
| 能力 | 提供 persistence.relational@1(role: provider);依赖 kernel.hosting@1,可选依赖 multitenancy.sharding@1 |
| 特性入口 | SqlSugarPersistenceFeature:无参构造用应用注册的客户端,带回调的构造 Action<Maybe<BindingValue>, ConnectionConfig> 由特性创建客户端;DiffLog 选出开启差异日志的实体 |
| 绑定与配置 | 绑定 db(connection:relational,可选);配置节 XiHan:Persistence:SqlSugar |
| 健康项 | persistence.sqlsugar.database、persistence.sqlsugar.replicas |
| Native AOT | 不兼容:SqlSugar 的实体映射与表达式翻译基于运行时反射 |
| 主要类型 | SqlSugarConventions(Apply、InitTables)、SqlSugarConnections(Default、Shard)、IEntityConnectionSelector、SqlSugarUnitOfWork(Client、ConnectionFor(Type)、EnsureTransactionAsync)、SqlSugarDiffLogData |
最小示例
方式一,特性按回调创建客户端,连接串来自绑定 db:
using SimpleApp.Orders;
using SqlSugar;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Persistence.SqlSugar;
using XiHan.Framework.Profiles.Standard;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRepository<Order, Guid>();
builder.AddXiHan(x => x
.UseStandardProfile()
.UseFeature(new SqlSugarPersistenceFeature(static (binding, config) =>
{
config.DbType = DbType.PostgreSQL;
config.ConnectionString = binding.ValueOrThrow().Value;
})));
var app = builder.Build();
app.UseXiHan();
app.Run();{
"XiHan": {
"Bindings": {
"db": "Host=localhost;Database=app;Username=app"
},
"Persistence": {
"SqlSugar": {
"InitTables": true
}
}
}
}回调设置数据库类型与连接串,之后特性施加协议约定;默认连接的 ConfigId 由特性定为 default。运行后 InitTables 按已登记的实体建出 Order 表,POST /orders 的写入进入 PostgreSQL,操作代码与内存实现时完全相同。
方式二,应用自己注册带多个连接的客户端(替换上例里的装配),每个 ConnectionConfig 在创建客户端前先经 SqlSugarConventions.Apply,默认连接的 ConfigId 用 SqlSugarConnections.Default:
builder.Services.AddScoped<ISqlSugarClient>(_ => new SqlSugarClient(
[
SqlSugarConventions.Apply(new ConnectionConfig { ConfigId = SqlSugarConnections.Default, DbType = DbType.PostgreSQL, ConnectionString = appDb, IsAutoCloseConnection = true }),
SqlSugarConventions.Apply(new ConnectionConfig { ConfigId = "audit", DbType = DbType.PostgreSQL, ConnectionString = auditDb, IsAutoCloseConnection = true }),
]));
builder.AddXiHan(x => x.UseStandardProfile().UseFeature(new SqlSugarPersistenceFeature()));两种方式下仓储、工作单元与查询语义相同。方式二要自己保证每个作用域用一份配置副本(SqlSugarScope 自己会复制),CommandTimeoutSeconds 与 Connections 配置节对它不起作用,框架也不探测它的从库。
协议约定
SqlSugarConventions.Apply 在连接配置上登记的约定(保留配置里已有的实体服务与表服务回调,先执行它们):
- 名为
Id的属性是主键,缺省不自增;ConcurrencyStamp长度 32;[StringLength(n)]映射为长度 n,[LongText]映射为长文本类型,实体自己的[SugarColumn]已给长度或类型时不动。 DateTimeOffset经转换器按 UTC 时刻往返;可空类型的列可空;没有公开 setter 的属性与集合属性不映射。[EntityIndex]经表服务回调转成SugarIndexAttribute;软删除实体的唯一索引由InitTables建表后补建成部分唯一索引。- 字符串的
Contains/StartsWith/EndsWith以同名外部函数接管翻译,按字面匹配,直接用施加了约定的客户端写的查询也一样;要通配用SqlFunc.Like。 - PostgreSQL 连接串的启动参数追加
standard_conforming_strings=on,见下文。
软删除与租户过滤以表达式显式施加在每个查询上,不用 SqlSugar 的全局过滤器。列与索引的完整约定见建表约定与索引。
建表:InitTables
XiHan:Persistence:SqlSugar:InitTables 为 true 时,启动按已登记的实体建缺表、加缺列,经 SqlSugarConventions.InitTables,软删除实体的部分唯一索引随之建出。按落点分组:租户实体在默认连接建表(启动时没有分片),带 [Tenant] 或登记到命名连接的实体在各自的连接上建表;分表实体走 CodeFirst.SplitTables()。删列、改名、改类型不在范围内,生产库的结构演进走迁移脚本;索引按名字查不到才建,表里已有重复数据时唯一索引建不出来、启动失败。应用自己建表(租户独立库、迁移工具)也要调 SqlSugarConventions.InitTables(client, entityTypes),直接调 CodeFirst.InitTables 建不出部分唯一索引。
多库落点
客户端可以带多个连接,每种实体落在哪个连接由 IEntityConnectionSelector 决定。默认实现 SqlSugarConnectionSelector 的规则按顺序:
- 实体带 SqlSugar 的
[Tenant(configId)]:落到该ConfigId的连接;客户端里没有这个连接是装配错误(PERSISTENCE_VALIDATION_004),不回退到默认连接。 - 实体以
AddRepository<TEntity, TKey>(connection)登记到命名连接:落到同名ConfigId的连接,校验同上;与[Tenant]不一致同样是PERSISTENCE_VALIDATION_004。 - 实体实现
IMultiTenant,且客户端里有shard连接:落到分片连接。 - 其余:根(默认)连接。
装配了 multitenancy.sharding@1 时,带分片的调用把分片绑定交给同一个回调,再建一个 ConfigId = shard 的连接:租户实体落分片库,租户目录、授予、发件箱这类平台表仍落默认库,同一调用里一起提交或回滚。方式二由应用自己提供名为 shard 的连接。见租户分片与分库。
把审计类实体固定到 audit 库(配合上面方式二的客户端),实体类上标 SqlSugar 的 [Tenant("audit")] 即可,其余照常实现协议接口。替换规则时,在装配特性之前注册自己的实现:builder.Services.AddSingleton<IEntityConnectionSelector, MySelector>()。同一工作单元里对同一实体类型的选择结果必须稳定。工作单元在第一次写入时经 AsTenant() 在客户端的全部连接上开事务,提交逐连接提交,回滚逐连接回滚。
跨库提交是顺序提交,不是两阶段提交
一次调用写了多个连接时,提交逐连接进行;后一个连接提交失败时,前一个已经提交。需要严格原子性的写入放在同一个库。
命名连接
方式一在 XiHan:Persistence:SqlSugar:Connections:<name> 声明命名连接,回调对每个命名连接各调用一次,config.ConfigId 是连接名;方式二由应用把同名 ConfigId 的连接加进自己注册的客户端。每个作用域的客户端都带上命名连接,工作单元首次写入时只在默认连接与已经取用过的连接上开事务,此后首次取用的连接再补开:业务调用不碰命名连接,那个库就不被业务事务锁住。普通工作单元里,登记到命名连接的实体照常参与整体事务(与 EF Core 不同);绑定连接的工作单元(UnitOfWorkOptions(Connection: name))只用这一个连接,操作落在别处的实体抛 PERSISTENCE_VALIDATION_004。配置与代码示例见连接与运行时数据源。
分表
带 SqlSugar [SplitTable] 的实体自动走分表入口:表名含 {year}{month}{day} 占位符,[SplitField] 是按 UTC 写入的 DateTime 列(SqlSugar 按 DateTime 推表名,不能用 DateTimeOffset)。
using System.ComponentModel.DataAnnotations;
using SqlSugar;
using XiHan.Framework.Domain.Entities;
namespace SimpleApp.Auditing;
[SugarTable("login_logs_{year}{month}{day}")]
[SplitTable(SplitType.Month)]
public sealed class LoginLog : IEntity<Guid>, IHasCreationTime, IHasConcurrencyStamp
{
public Guid Id { get; init; }
// 决定落在哪张分表;写入后不要再改,更新与删除按它定位表
[SplitField]
public DateTime LoggedAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string ConcurrencyStamp { get; set; } = string.Empty;
}查询用 EntityQuery.All<LoginLog>().Within(from, to) 限定扫描的表(两端都含,只是扫描提示、不过滤行),没有提示时扫全部分表。写入按 [SplitField] 的值落表,缺少的分表由 SqlSugar 建出。分表查询是多表 UNION ALL,不能加行锁,分表实体的更新与删除靠并发戳防并发。分表实体不用 [EntityIndex],直接用 [SugarIndex] 与 {split_table} 占位符。
读写分离
从库在配置回调里给,回调对每个连接都调用,所以要看 ConfigId,只给它所属的连接:
new SqlSugarPersistenceFeature((binding, config) =>
{
config.DbType = DbType.PostgreSQL;
config.ConnectionString = binding.ValueOrThrow().Value;
if (Equals(config.ConfigId, SqlSugarConnections.Default))
{
config.SlaveConnectionConfigs =
[
new SlaveConnectionConfig { ConnectionString = replicaA, HitRate = 10 },
new SlaveConnectionConfig { ConnectionString = replicaB, HitRate = 10 },
];
}
});HitRate 是字段,配置绑定绑不上,从配置读时自己赋值;不大于 0 的权重按 Replicas:DefaultHitRate(缺省 10)归一化。
只读工作单元(动词 get、list,或 UnitOfWorkOptions(IsReadOnly: true))的读走从库,设了 ReadsPrimary 的读主库;其余工作单元的读写都走主库,第一次写入之前的读也一样。工作单元之外直接用客户端时,事务内与写入走主库,事务外的读走从库。分片连接默认没有从库。跨调用读己之写不保证:写完马上发起的只读调用可能读到落后的从库,框架读授予和租户目录时因此读主库。
周期探测把不可读的从库摘除,全部不可读时读回到主库,恢复之后过了冷却窗口才放回读流量;健康项 persistence.sqlsugar.replicas 有从库不可读时是 Degraded(PERSISTENCE_UNAVAILABLE_009),应用仍然就绪,只探连通、不看复制延迟。主库不同的连接共用同一个从库是装配错误(PERSISTENCE_VALIDATION_009):回调不看 ConfigId 时,租户的读会读到平台库。
差异日志
DiffLog 选出的实体,仓储的插入、更新(含软删除)、物理删除与批量方法都带 SqlSugar 的 EnableDiffLogEvent,应用在连接配置的 AopEvents.OnDiffLogEvent 上收到操作类型、表、主键与前后值,BusinessData 是 SqlSugarDiffLogData:
var feature = new SqlSugarPersistenceFeature(static (binding, config) =>
{
config.DbType = DbType.PostgreSQL;
config.ConnectionString = binding.ValueOrThrow().Value;
config.AopEvents = new AopEvents
{
OnDiffLogEvent = static diff =>
{
if (diff.BusinessData is SqlSugarDiffLogData data)
{
// 回调在写入后同步触发、不等提交:只复制需要的值进本调用的缓冲,提交成功后再落库
data.Services.GetRequiredService<ChangeBuffer>().Add($"{data.EntityType.Name}:{diff.DiffType}");
}
},
};
})
{
// 只给需要前后值的实体开:每次写入多一次按主键的读取
DiffLog = static type => type == typeof(Order),
};ChangeBuffer 是应用自己的作用域服务;feature 照常 UseFeature。缺省一个实体都不开;开启的实体必须有公开的无参构造函数。回调在写入执行后同步触发、不等提交,事务回滚由应用按调用缓冲后再落库。分表实体同样支持,但回调里不要保存 DiffLogModel 的引用(分表插入的各组之间复用同一个)。审计子系统本身见审计。
命令超时
XiHan:Persistence:SqlSugar:CommandTimeoutSeconds(缺省 300)设在特性建的客户端的每个连接上;不是正整数即启动失败,0 不接受(ADO.NET 的 0 是无限等待)。方式二要在自己的客户端上设 Ado.CommandTimeOut,配了这个键启动失败。个别长任务在调用点用 Ado.CommandTimeOut 临时调大。
可空值类型判空
规约在交给 SqlSugar 前先改写:可空值类型与 null 的 == 改写成 SqlFunc.EqualsNull(x, null),!= 改写成 HasValue,避免译成 = NULL 后静默返回错误的结果;逻辑非下推到叶子(德摩根律展开、!(a < b) 换成 a >= b),因为 SqlSugar 在 A && (!x.HasValue || B) 这类位置会漏写 OR / AND。应用直接用客户端写查询时没有这层改写,判空要自己写 SqlFunc.EqualsNull 或 HasValue。
PostgreSQL 注意事项
standard_conforming_strings
SqlSugar 在多行插入、批量更新、按实体删除、In、集合 Contains 里内联的字符串字面量只在 standard_conforming_strings = on 时正确;库或角色设成 off 时含反斜杠的值会让语句报错或被静默改掉。SqlSugarConventions.Apply 在连接串(含从库)的启动参数 Options 追加 -c standard_conforming_strings=on,服务器在建连时施加它,优先于库与角色的缺省值;连接串已显式写成别的值时 Apply 抛 ArgumentException。经这个配置的全部连接(含应用的原生 SQL)都是 on,原生 SQL 里要转义用 E'…'。PgBouncer 要能透传启动参数的版本;运行时数据源不加这个参数。
时间戳的 Kind
SqlSugar 的 PostgreSQL 提供器自己打开 Npgsql 的 legacy 时间戳模式,timestamptz 列读出来是换算成本地时间、Kind = Local 的 DateTime。约定的 DateTimeOffset 转换器按 Kind 处理:Local 先换回 UTC,Unspecified 按写入时的 UTC 解释,最终都交出偏移为零的 DateTimeOffset。应用直接用客户端读 DateTime 列时没有这层转换,要自己看 Kind:把 Local 值直接当 UTC 会多出本地时区偏移,经实体回写后逐次累加。
MySQL 的已知限制
MySQL 上 SqlSugar 对两个及以上值的 In(…) 与按实体删除把字符串内联成只转义单引号的字面量,缺省 sql_mode 下含反斜杠的字符串主键会静默漏行(开了 NO_BACKSLASH_ESCAPES 时集合 Contains 反过来漏行)。仓储里受影响的是 FindManyAsync、批量方法的标识预查与加锁取回、物理删除;Guid 与整数主键不受影响。应用的字符串主键首选不含反斜杠(写入前按 ^[A-Za-z0-9][A-Za-z0-9._-]*$ 这类规则校验),做不到时改用参数化写法(逐个 == 比较、FindAsync)。这是 SqlSugar 上游缺陷,待上游修复。
配置
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
XiHan:Persistence:SqlSugar:InitTables | 布尔 | false | 启动时建缺表、加缺列;只宜在开发与测试开启 |
XiHan:Persistence:SqlSugar:HealthProbeIntervalSeconds | 正数 | 15 | 健康项的重探间隔,也是单次探测的超时 |
XiHan:Persistence:SqlSugar:Connections:<name> | 字符串 | — | 方式一的命名连接;方式二写了这一节装配即失败 |
XiHan:Persistence:SqlSugar:CommandTimeoutSeconds | 正整数 | 300 | 命令超时(秒) |
XiHan:Persistence:SqlSugar:Replicas:DefaultHitRate | 正整数 | 10 | 权重不大于 0 的从库归一化成的权重 |
XiHan:Persistence:SqlSugar:Replicas:HealthCheck、HealthCheckIntervalSeconds、FailureCooldownSeconds | 布尔、正数、非负数 | true、30、120 | 是否周期探测从库、探测间隔、最后一次失败后恢复读流量前的冷却窗口 |
XiHan:Bindings:db | 字符串 | — | 方式一的连接绑定主值 |
健康项 persistence.sqlsugar.database 对默认连接走一次数据库往返,连不上或超时为 Degraded(KERNEL_UNAVAILABLE_005),应用仍在服务。
常见问题
原有的 SqlSugar 实体要改成什么样
实现 IEntity<TKey> 并把主键属性命名为 Id,其余 [SugarTable]、[SugarColumn] 照旧。想让仓储维护创建时间、租户、软删除就实现对应的协议接口;不实现的字段仓储不碰。原生查询继续用自己的客户端。
更新与删除为什么要先读一遍
仓储在事务内加行锁取回当前行(TranLock),比对并发戳后把调用方的实例整行写入,其中创建时间、租户与删除标记先换成当前行的值,保证读取与写入之间别人改不了这一行、协议字段不被覆盖。SQLite 不支持行锁,由数据库级写锁保证;分表实体不加行锁。
从库有延迟,刚写的数据读不到
只读调用走从库,跨调用的读己之写不保证。需要立即读到刚写数据的只读路径,开工作单元时设 ReadsPrimary = true;或者把读写放进同一个可写调用。
相关包与下一步
- 相关包:Persistence.SqlSugar、Persistence.Abstractions、Persistence.EntityFrameworkCore、Persistence.Generators
- 规范:RFC-0005《领域协议与持久化》
- 下一页:建表约定与索引,讲列的约定、索引声明与生成规则
