CSFramework.EF分库分表解决方案
CSFramework.EF分库分表解决方案
本文基于 CSFramework.EF(C/S 框架网 V6.1 旗舰版 .NET8 数据访问层,EF Core 8)源码逐文件分析,回答三个问题:框架现在能不能分库、分表;分库分表时数据库名与表名该怎么命名;历史数据归档场景下,跨区间查询还能不能查得出来。文中所有结论均标注源码位置,便于复核。
一句话结论:“分库”框架层早已支持(消费方已实装账套机制);“分 Schema”是框架原生能力;“分表”目前只有预留字段、尚未接线,需要补齐四处改造。
一、结论速览
| 能力 | 现状 | 由谁决定 | 改造量 |
|---|---|---|---|
| 分库(多数据库实例) | 已支持 | 连接串 + 消费方账套表 | 0(核心库无需改动) |
| 分 Schema(同库多模式) | 已支持(框架原生) | DatabaseConfig.Schema | 0 |
| 分表(同库多表名) | 未实现(TableSuffix 空壳) | — | 中等:核心库 4 处 + 5 个提供程序收口 |
| 跨库事务 | 有 API,但非 2PC | DistributedTransaction | 强一致需另做 |
二、事实基线:三个分片维度
框架的分片入口只有一个:DatabaseConfig。它身上挂着三个维度——ConnectionString(库)、Schema(模式)、TableSuffix(表后缀)。三者的接线程度完全不同。
2.1 一个 IDatabase 实例 = 一个库
DatabaseFactory.GetDatabase(config) 每次调用产出一个独立实例,连接串直接决定打到哪个库。值得注意的是 IDatabase.DatabaseName 只是一个只读回读属性:取当前连接的 Database,取不到就退回 Schema(GenericDatabase.cs:61-73)。也就是说:框架既不生成库名,也不校验库名,库名完全由外部连接串决定。
2.2 Schema:唯一真正落地的分片维度
这是框架已经做实的分库分表能力,由三件东西配合完成:
GenericDbContext.ConfigSchema:调用modelBuilder.HasDefaultSchema(config.Schema)(GenericDbContext.cs:104-107);DatabaseEngine.RemoveEntitySchema(默认 true,DatabaseEngine.cs:202):自动剥掉实体[Table]上写死的 Schema,所以实体模型不需要指定 Schema;SchemaModelCacheFactory替换 EF 的IModelCacheKeyFactory,把 Schema 并入模型缓存键(SchemaModelCache.cs:35-55)。
第三点是关键:OnModelCreating 只会执行一次,EF 对模型做了缓存。正是缓存键里加了 Schema,同一实体在不同 Schema 下才会有各自独立的模型,“动态 DbContext”才成立。
2.3 TableSuffix:预留字段,尚未接线
DatabaseConfig.TableSuffix(DatabaseConfig.cs:101-104)的注释原话是“表名后缀(分表时需要)”。但全仓库检索它的出现位置只有两处:定义处,以及消费方赋值处——而且赋的是空串(WebApi 端 DatabaseBuilder.cs:125:TableSuffix = "")。
没有任何一行代码读取它、更没有用它拼表名。作者留好了接口位,但分表功能从未接线。
2.4 表名唯一来源:[Table] 特性
ReflectionHelper.GetTableName(Type) 优先取 [Table].Name,无特性时回退类名(Helper/ReflectionHelper.cs:30-39)。实体注册时只做 modelBuilder.Entity(T),不指定表名(GenericDbContext.cs:85-89),整个解决方案内 ToTable( / SetTableName( 零命中。读路径拼 SQL 用的也是这个口径:GetDataByKey、SelectAll、RemoveAll<T>、IsExistsKeyValue<T>。
结论:今天的表名是编译期写死的,运行期没有任何一处会改写它。顺带一提,模型缓存键目前只含 Schema、不含表名,这一点在后面做分表时会变成最大的隐形地雷。
三、分库:框架已支持,消费方已实装
核心库视角很简单——每次 GetDatabase 就是一个新库实例,库名无约束、无校验。真正的分库规则,在消费方的账套机制里。
3.1 账套加载链路
| 环节 | 说明 |
|---|---|
| 加载系统库 | 按配置创建系统库实例并缓存 |
| 读取账套清单 | 从系统库 tb_DataSet(IsUse="Y")读取,密码字段加密存储,读取时解密 |
| 组装连接串 | 按本地(局域网 IP/端口)或远程(公网 IP/端口)二选一,调用 BuildConnectionString 生成 |
| 取库 | GetDatabase(DBID) 按账套编号取实例,GetDatabaseByDbName(DBName) 按库名取 |
| 动态加库 | AddDatabase(DBID, type, connStr, schema),WebApi 模式下动态注册 |
3.2 账套定义表 tb_DataSet 的关键字段
DataSetID(账套编号,唯一)、DBName(物理库名)、DatabaseType、Schema、LocalServerIP/Port、RemoteServerIP/Port、DBUserName、DBUserPassword(加密存储)、IsUse、IsVisible。
现行命名习惯:系统库 CSFrameworkV6_System(账套 ID 固定 SystemDB),默认业务库 CSFrameworkV6_Normal(账套 ID Normal);表按前缀分三类:dt_* 基础资料、sys_* 系统表、tb_* 业务表。
3.3 WebApi 侧:一个接口 = 一个库
WebApi 开发框架的做法是用派生接口区分库:UseDatabase<TDatabase>(...) 以接口全名作为配置项名称注册,需要几个库就定义几个 IDatabase 派生接口(如系统库访问器、业务库访问器)。
四、分表:需要补齐的四个改造点
要让 TableSuffix 真正生效,需要改四处。前两处必须同时做,拆开做必踩坑。
① 给 DbContext 增加表名配置
// GenericDbContext.OnModelCreating 内,ConfigSchema 之后追加一步
protected virtual void ConfigTableName(ModelBuilder modelBuilder)
{
var suffix = _databaseConfig.TableSuffix; // 如 "_202601"
if (String.IsNullOrEmpty(suffix)) return;
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
// 基表名来自 [Table] 特性或类名
entityType.SetTableName(entityType.GetTableName() + suffix);
}
}② 模型缓存键必须并入 TableSuffix
// SchemaModelCache:缓存键只比 Schema,不比表名 —— 分表后必须一起改
readonly string _tableSuffix; // 取自 DatabaseConfig.TableSuffix
protected override bool Equals(ModelCacheKey other)
{
return base.Equals(other)
&& (other as SchemaModelCache)?._schema == _schema
&& (other as SchemaModelCache)?._tableSuffix == _tableSuffix;
}
public override int GetHashCode()
{
var hashCode = base.GetHashCode() * 168;
if (_schema != null) hashCode ^= _schema.GetHashCode();
if (_tableSuffix != null) hashCode ^= _tableSuffix.GetHashCode();
return hashCode;
}不改这里的后果:EF 会认为“模型没变”而复用第一个分片的模型,后续所有分片都指向同一批物理表——数据静默写进第一张表,而且不报错。
③ 读路径统一加后缀
框架的写操作走 EF,但读操作(GetDataTable / ExecuteScalar / GetDataSet / ExecuteSql(Type) 等)多是自建 ADO.NET 连接拼 SQL。所以除了 EF 模型,还要把这些拼表名的地方统一收口成一个方法(例如 GetPhysicalTableName(Type)),至少覆盖:GetDataByKey、SelectAll、RemoveAll<T>、IsExistsKeyValue<T>。
只改 EF、不改读路径 = 写走分表、读走原表,读写打到不同的表且不报错。
④ 五个数据库提供程序各自的 override
SqlServer / MySql / Oracle / PostgreSql / 达梦五个提供程序里,凡是自己组装 SQL 或指定批量导入目标表名的 override(如 Oracle 的 SelectAll、RemoveAll、BulkInsert)都要一并加后缀。顺带修一个现存问题:Oracle 的 SelectAll 仍在用类名而非 [Table] 名,遇到类名与表名不一致的实体(例如 sys_Data_SN 类对应 sys_DataSN 表)会直接报表不存在。
五、命名规则
5.1 数据库名(分库)
- 建议格式:
CSFrameworkV6_{DataSetID},如CSFrameworkV6_Normal、CSFrameworkV6_FactoryA; - 系统库固定为
CSFrameworkV6_System(多处常量引用,不要改); - 字符集只用
[A-Za-z0-9_],不要中文、空格、连字符——它会进入连接串、Oracle/达梦的 Schema、备份脚本的文件名; - 账套编号
DataSetID是逻辑键(登录界面选择、实例缓存 key),可与库名保持一致便于排错。
5.2 表名(分表后缀)
- 格式:
{基表名}{分隔符}{后缀},框架侧只有“追加后缀”一种形态,没有前缀、没有替换; - 时间分片:
tb_PO_202601(按月)、tb_PO_2026(按年);哈希分片:tb_PO_03(取模,定宽两位); - 后缀字符集同样只用
[A-Za-z0-9_],不要裸数字开头,不要含-; - Schema 与表名之间的点号由
FormatSchema自动补,后缀里不要带点。
5.3 各库标识符硬约束(决定上限)
| 数据库 | 长度上限 | 大小写 | 备注 |
|---|---|---|---|
| SqlServer | 128 | 不敏感(取决于排序规则) | 格式化为 [表名] |
| MySql | 64 | 取决于操作系统(Linux 下敏感) | 反引号包裹 |
| PostgreSql | 63(超出静默截断) | 未加引号一律折叠成小写 | sys_DataSN 实际是 sys_datasn |
| Oracle | 12.2 前为 30,之后 128 | 未加引号折叠成大写 | 加了双引号就大小写敏感,必须与建表写法完全一致 |
| 达梦 | 128 | 同 Oracle 风格 | 以 Schema(模式)连接 |
要五库通吃,按 30 个字符以内设计最保险。例如 tb_PO_202601 共 11 字符,安全;sys_Log_ApiVistior_202601 已 26 字符,接近上限。
六、历史数据归档实操
典型诉求:把几年前的旧数据搬出去做备份,归档表主要供查询。以采购订单 tb_PO(主表)/ tb_POs(明细)为例,把 2024/12/31 之前的数据分出去。
6.1 先看清实体,口径才不会错
| tb_PO(主表) | tb_POs(明细) | |
|---|---|---|
| 主键 | isid(字符串 GUID32) | isid(字符串 GUID32) |
| 业务单号 | PONO | PONO(关联键,未声明外键约束) |
| 日期字段 | PODate(业务日期)、CreationDate | 只有 CreationDate,没有业务日期 |
迁移口径只能以主表 PODate 为准:明细表没有业务日期,自己筛不了,必须靠 PONO IN (...) 跟着主表走。另外 PODate 可空,空值单据不会被归档;补录单据(PODate 早、创建时间晚)会被一并搬走——这两点要先定死口径。
6.2 归档数据放哪里
| 方案 | 做法 | 框架改造量 | 适用场景 |
|---|---|---|---|
| A. 独立归档库(推荐) | 新建归档库,表名保持不变(仍是 tb_PO/tb_POs) | 0,AddDatabase(DBID, 类型, 连接串, Schema) 即可访问 | 归档即备份,可整库脱机、设只读账号 |
| B. 同库分表 | 建 tb_PO_2024 / tb_POs_2024 | 需完成第四节的 ①②改造 | 必须留在同一个库 |
“归档 + 只读”这类诉求下方案 A 明显更划算:表名不变,就不用碰 TableSuffix、不用碰模型缓存键,现有 IDatabase 直接就能读。
6.3 搬迁步骤(幂等、可重跑)
- 建归档表——注意
SELECT INTO/ CTAS 不会带主键和索引,事后必须单独创建; - 抽主表:
WHERE PODate < '2025-01-01'(即 2024/12/31 及之前); - 抽明细:
WHERE PONO IN (SELECT PONO FROM 归档主表); - 对账:行数 + 金额合计双查,主表明细各一次,不一致就停下;
- 删原表:先明细、后主表,按 PONO 分批(每批 1000~5000 行),不要一次性 DELETE(会锁表、日志暴涨);
- 备份归档库,可设为只读账号或只读文件组。
不要用 DistributedTransaction 去包“插入 + 删除”:它的提交是对各库逐个 Commit,不是两阶段提交,第二个库失败时第一个库已提交、回滚不了。顺序必须定死为先插 → 对账 → 后删,每一步幂等,失败可重跑。





