49. 框架升级与数据库迁移
升级 = 换程序集 + 合并配置 + 增量建表 + 增量种子四件事。框架用 [IncreTable] / [IncreSeed] / [IgnoreUpdateSeed] 三个特性保证"升级不丢数据、不覆盖客户数据",用 SqlSugarSetup.InitDatabase 在启动时一次性完成结构同步。
1. 升级全流程图
升级前 升级中 升级后
─────────────────────────────────────────────────────────────────────────
① 全量备份(库 + 配置文件 + 上传目录)
② 记录当前版本号 git log / CHANGELOG
③ 拉代码或替换发布包
│
▼
④ 合并 Configuration/*.json(见第 3 节)
│
▼
App 启动 → services.AddSqlSugar()
│
├─ EnableInitDb 建库(生产 false)
├─ EnableInitTable 全量表结构同步(生产 false)
├─ EnableIncreTable ★只同步贴 [IncreTable] 的表
├─ EnableInitSeed 全量种子(生产 false)
├─ EnableIncreSeed ★只跑贴 [IncreSeed] 的种子
└─ EnableDiffLog 结构/数据差异写 SysLogDiff
│
▼
⑤ 验证:菜单、字典、配置、定时任务、租户库
⑥ 失败则回滚(见第 7 节)代码位置:Admin.NET.Core/SqlSugar/SqlSugarSetup.cs 的 InitDatabase / InitTable / InitSeedData。
2. 升级前检查清单
□ 备份数据库(主库 + 日志库 + 每一个租户库),导出为 .sql / .bak 并验证可恢复
□ 备份 Admin.NET.Web.Entry 运行目录下的 Configuration/ 整个文件夹
□ 备份上传目录 wwwroot/upload(或 OSS Bucket 快照)
□ 记录当前版本:git rev-parse HEAD,以及 Admin.NET/Admin.NET.Web.Entry 的 dll 版本
□ 确认 .NET SDK / 运行时版本(csproj 目标框架为 net8.0;net10.0,别跨大版本直接升)
□ 确认数据库类型与版本(MySql / SqlServer / Sqlite / Oracle / PostgreSQL / Dm / Kdbndp)
□ 确认是否多节点部署 → 多节点必须 Redis 缓存,且 SnowId WorkerId 不冲突
□ 确认是否有自定义实体贴了 [Tenant] / [LogTable] → 影响表归属哪个库
□ 通知业务方停机窗口(增量建表会锁表,大表要预留时间)
□ 准备回滚包:把当前运行目录整体打成 zip3. Configuration 配置文件的合并方式
3.1 配置文件是怎么到运行目录的
Admin.NET.Application.csproj 把 Configuration/**/* 全部标为 Content,CopyToOutputDirectory = PreserveNewest(保留较新的),发布时 CopyToPublishDirectory = PreserveNewest:
<!-- Admin.NET.Application/Admin.NET.Application.csproj -->
<Content Include="Configuration\**\*">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<!-- 环境专属文件 App.*.json / Database.*.json / JWT.*.json / HttpRemote.*.json -->
<Content Remove="Configuration\App.*.json" />
<Content Include="Configuration\App.*.json">
<DependentUpon>App.json</DependentUpon>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory> <!-- ★ 从不发布 -->
</Content>两个结论:①生产发布包里不会出现 *.Development.json;②PreserveNewest 不会用包里的旧文件覆盖目标机器上较新的同名文件,所以升级时直接丢文件可能"看起来没生效",必须走第 3.2 节的手动合并。
3.2 合并策略(必须手动,别无脑覆盖)
| 文件 | 处理方式 |
|---|---|
Database.json | 保留本地连接串,只合并新增的开关字段;EnableInitTable 生产必须 false |
App.json | 逐段对比:SnowId(WorkerId / BaseTime 不可改)、Cryptogram(改密钥要重新初始化账号)、JobSchedule、Tianditu |
JWT.json | 改 IssuerSigningKey 会让所有在线用户掉线,非必要不动 |
Cache.json | 多节点改 CacheType: Redis |
Swagger.json / Logging.json / Limit.json | 可直接用新版 |
| 新增配置文件 | 从新版本拷贝过来补上 |
推荐做法(三向合并):
# 1. 备份当前生效配置
cp -r /opt/adminnet/Configuration /opt/adminnet/Configuration.bak.$(date +%Y%m%d)
# 2. 对比新旧,逐项合并(以 Database.json 为例)
diff -u /opt/adminnet/Configuration/Database.json \
./Admin.NET.Application/Configuration/Database.json
# 3. 合并完成后校验 JSON 合法
python -m json.tool /opt/adminnet/Configuration/Database.json > /dev/null && echo OK
# 4. 用 jq 确认关键开关
jq '.DbConnection.ConnectionConfigs[].TableSettings, .DbConnection.ConnectionConfigs[].SeedSettings' \
/opt/adminnet/Configuration/Database.json3.3 环境覆盖文件的命名约定
App.json + App.{Environment}.json 双层结构,后者的键覆盖前者。仓库里已带:App.Development.json、Database.Development.json、JWT.Development.json、HttpRemote.Development.json。本地开发新增覆盖文件时,务必同步改 csproj(改 CopyToPublishDirectory 为 Never),否则会被打进生产包。
4. 增量种子:保证升级不丢数据
4.1 四个特性各管什么
// Admin.NET.Core/Attribute/*.cs
[IncreSeed] // 标记种子类:EnableIncreSeed=true 时只有这些会被执行
[IgnoreUpdateSeed] // 标记种子类:只插入新数据,绝不更新已存在的记录
[IgnoreUpdateSeedColumn] // 标记实体属性:upsert 时该列不参与更新
[SeedData(order)] // 排序,越大越后执行
[TenantSeed] // 租户种子:主库初始化时跳过,只在建租户库时执行4.2 执行逻辑(真实代码 SqlSugarSetup.InitSeedData + SqlSugarExtension.InitTableSeedData)
var seedDataTypes = App.EffectiveTypes
.Where(u => u.GetInterfaces().Any(i => i.HasImplementedRawGeneric(typeof(ISqlSugarEntitySeedData<>))))
.Where(u => !u.IsDefined(typeof(TenantSeedAttribute), false)) // 排除租户种子
.WhereIF(enableIncreSeed, u => u.IsDefined(typeof(IncreSeedAttribute), false)) // 增量模式只跑 [IncreSeed]
.OrderBy(u => ((SeedDataAttribute)u.GetCustomAttributes(typeof(SeedDataAttribute), false)[0]).Order)
.ToList();
// ★ 串行执行:应用层会重写 Core 层种子,顺序必须稳定
foreach (var seedType in seedDataTypes)
{
var tuple = dbProvider.InitTableSeedData(seedType);
}进入 InitTableSeedData 后:
// 1. 按 ConfigId 过滤:主库只跑 SysTable 或无 Log/Tenant 特性的实体
// 2. 反射调用 HasData() 拿种子列表
// 3. Id 为空时自动用 ConfigId 递增补号
dbProvider.QueryFilter.ClearAndBackup(); // 清过滤器,保证种子能全量写入
// 4. 有主键 → Storageable 按主键 upsert
var storage = dbProvider.StorageableByObject(seedDataList).ToStorage();
if (seedType.GetCustomAttribute<IgnoreUpdateSeedAttribute>() == null) // 没贴 [IgnoreUpdateSeed] 才更新
updateCount = storage.AsUpdateable
.IgnoreColumns(entityInfo.Columns
.Where(u => u.PropertyInfo.GetCustomAttribute<IgnoreUpdateSeedColumnAttribute>() != null)
.Select(u => u.PropertyName).ToArray())
.ExecuteCommand();
insertCount = storage.AsInsertable.ExecuteCommand();
// 5. 无主键 → 只在表为空时插入一次
if (!dbProvider.Queryable(entityInfo.DbTableName, entityInfo.DbTableName).Any())
dbProvider.InsertableByObject(seedDataList).ExecuteCommand();
dbProvider.QueryFilter.Restore();4.3 结论:升级时种子会不会覆盖客户数据
| 场景 | 结果 |
|---|---|
开了 EnableInitSeed + EnableIncreSeed=false(全量) | 所有种子都会 upsert,客户改过的出厂字典/配置会被改回 |
开了 EnableInitSeed + EnableIncreSeed=true(增量) | 只跑贴 [IncreSeed] 的种子,老的完全不动 |
种子类贴了 [IgnoreUpdateSeed] | 只 AsInsertable,已有主键的记录一行都不更新 |
实体属性贴了 [IgnoreUpdateSeedColumn] | upsert 时该列不进 UPDATE 语句,客户的值保住 |
| 无主键表 | 表非空就整表跳过 |
生产推荐组合:EnableInitSeed = true + EnableIncreSeed = true,并且新增的种子类一律贴 [IncreSeed] + [SeedData(order)];对客户可自定义的数据额外贴 [IgnoreUpdateSeed]。
4.4 写一个安全的升级种子
using Admin.NET.Core;
namespace Admin.NET.Application.SeedData;
/// <summary>
/// 升级期新增的字典(只新增,不覆盖已有值)
/// </summary>
[IncreSeed] // 增量模式下会被执行
[IgnoreUpdateSeed] // 已有主键的记录不做更新
[SeedData(900)] // 排在内置种子之后
public class MyDictSeedData : ISqlSugarEntitySeedData<SysDictType>
{
public IEnumerable<SysDictType> HasData()
{
return
[
new SysDictType { Id = 1300000000001, Name = "业务状态", Code = "biz_status",
Status = StatusEnum.Enable, OrderNo = 100, Remark = "升级 v2.1 新增" },
];
}
}实体上保护客户可改列:
[SugarTable(null, "业务配置表")]
public class BizConfig : EntityBase
{
[SugarColumn(ColumnDescription = "编码", Length = 64)]
public string Code { get; set; }
[SugarColumn(ColumnDescription = "名称", Length = 64)]
[IgnoreUpdateSeedColumn] // ★ 种子 upsert 时不覆盖客户改过的名称
public string Name { get; set; }
}5. CodeFirst 增量表与差异日志
5.1 开关含义
// Configuration/Database.json
"TableSettings": {
"EnableInitTable": false, // 全量建表/改表,生产必须为 false
"EnableIncreTable": true // 只处理贴了 [IncreTable] 的新表
},
"DbSettings": {
"EnableInitDb": false, // 建库,Oracle 不支持
"EnableInitView": false, // 建视图(实现 ISqlSugarView)
"EnableDiffLog": true, // 差异日志 → SysLogDiff
"EnableUnderLine": false, // 驼峰转下划线列名,上线后不可改
"EnableConnEncrypt": false // 连接串国密加密
}5.2 InitTable 的过滤规则
var entityTypes = App.EffectiveTypes
.Where(u => u.IsClass && u.IsDefined(typeof(SugarTable), false))
.Where(u => !u.GetCustomAttributes<IgnoreTableAttribute>().Any()) // 排除 [IgnoreTable]
.WhereIF(enableIncreTable, u => u.IsDefined(typeof(IncreTableAttribute), false)); // ★ 只看 [IncreTable]
// 主库:贴 [SysTable] 的,或既没 [LogTable] 也没 [Tenant] 的
// 日志库:只处理贴 [LogTable] 的;其他库:只处理 [Tenant(configId)] 匹配的
// 先 Drop 掉所有 ISqlSugarView 视图 → 并行建表(Task.Run + WaitAll)→ 可选重建视图
// 控制台打印每张表耗时,便于判断哪张表拖慢启动5.3 新增表的标准动作
实体贴 [IncreTable](让生产环境的 EnableIncreTable 能建到它)+ [SysTable](指定落主库):
[SugarTable(null, "业务订单表")]
[SysTable] // 主库;日志库换 [LogTable],租户库换 [Tenant(configId)]
[IncreTable] // ★ 关键:增量模式下才会被建
public class BizOrder : EntityBaseData
{
[SugarColumn(ColumnDescription = "订单号", Length = 64)]
public string OrderNo { get; set; }
[SugarColumn(ColumnDescription = "金额", DecimalDigits = 2)]
public decimal Amount { get; set; }
}验证流程:
# 1. 备份表结构(MySql 示例)
mysqldump -u root -p --no-data admin > schema_before.sql
# 2. 灰度:先在测试库开 EnableIncreTable=true 跑一遍,看控制台输出
# "初始化表 Admin.NET.Application.Entity.BizOrder (1300000000001 - 001/001) 耗时:xx ms"
# 3. 生产发布前确认 EnableInitTable=false / EnableIncreTable=true
# 4. 启动后到「日志管理 → 差异日志」核对 SysLogDiff 里的 DDL 变更5.4 差异日志
开启 EnableDiffLog 后,Aop.OnDiffLogEvent 把每次增删改的前后数据、SQL、业务数据写进 SysLogDiff(SysLogDiff 自身不记录,避免自递归)。⚠️ 它会显著放大写压力,只在排障窗口临时开启,平时保持 false。
6. 多库 / 租户库迁移
6.1 三种库角色
| 库 | ConfigId | 实体标记 | 说明 |
|---|---|---|---|
| 主库 | SqlSugarConst.MainConfigId(1300000000001) | 贴 [SysTable],或无 [LogTable] / [Tenant] | 系统表 + 业务表 |
| 日志库 | SqlSugarConst.LogConfigId | 贴 [LogTable] | SysLog* 系列 |
| 租户库 | 租户 Id(动态 AddConnection) | 无 [SysTable] / [LogTable] / [Tenant] 的业务表 | 每租户独立 |
6.2 租户库建库与灌数据
SysTenantService 提供 InitTenantDb(建库 + 建业务表)、InitTenantData(灌 [TenantSeed] 种子)、SyncTenantDb(批量同步全部租户库),对应 SysMenuSeedData 里的 sysTenant/initTenantDb、sysTenant/initTenantData、sysTenant/syncTenantDb 三个按钮权限:
// Admin.NET.Core/Service/Tenant/SysTenantService.cs
public async Task InitTenantDb(TenantInput input) // 建库 + 建业务表
{
var tenant = await _sysTenantRep.GetByIdAsync(input.Id);
if (tenant.DbType == SqlSugar.DbType.Oracle) throw Oops.Oh(ErrorCodeEnum.Z1002);
if (string.IsNullOrWhiteSpace(tenant.Connection) || tenant.Connection.Length < 10)
throw Oops.Oh(ErrorCodeEnum.Z1004);
var defaultConfig = App.GetOptions<DbConnectionOptions>().ConnectionConfigs.FirstOrDefault();
var tenantConnConfig = new DbConnectionConfig
{
ConfigId = tenant.Id.ToString(),
DbType = tenant.DbType,
IsAutoCloseConnection = true,
ConnectionString = tenant.Connection,
DbSettings = new DbSettings
{
EnableInitDb = true,
EnableDiffLog = false,
EnableUnderLine = defaultConfig.DbSettings.EnableUnderLine, // ★ 与主库保持一致
},
};
SqlSugarSetup.InitTenantDatabase(tenantConnConfig);
}
public async Task InitTenantData(TenantInput input) // 灌租户种子([TenantSeed])
{
SqlSugarSetup.InitTenantData(_sysTenantRep.AsTenant(), SqlSugarConst.MainConfigId.ToLong(), tenant.Id);
}
public async Task SyncTenantDb() // 批量同步全部租户库租户库建表规则(SqlSugarSetup.InitTenantDatabase):
var entityTypes = App.EffectiveTypes
.Where(u => !u.GetCustomAttributes<IgnoreTableAttribute>().Any())
.Where(u => u.IsDefined(typeof(SugarTable), false)
&& !u.IsDefined(typeof(SysTableAttribute), false)
&& !u.IsDefined(typeof(LogTableAttribute), false)
&& !u.IsDefined(typeof(TenantAttribute), false)) // ★ 业务应用表
.ToList();
foreach (var entityType in entityTypes)
{
var splitTable = entityType.GetCustomAttribute<SplitTableAttribute>();
if (splitTable == null) db.CodeFirst.InitTables(entityType);
else db.CodeFirst.SplitTables().InitTables(entityType);
}
InitTenantData(iTenant, config.ConfigId.ToLong(), config.ConfigId.ToLong());6.3 租户种子
[TenantSeed] // ★ 主库初始化时跳过,只在 InitTenantData 时执行
[SeedData(100)]
public class TenantOrgSeedData : ISqlSugarEntitySeedData<SysOrg>
{
public IEnumerable<SysOrg> HasData() => [ new SysOrg { Name = "默认机构" } ];
}InitTenantData 会自动做三件事:给 TenantId 字段赋当前租户 Id、给 Pid 字段赋租户 Id、给为 0 的 Id 按租户 Id 递增补号。所以租户种子的 Id 不要写死。
6.4 新增业务表后如何同步到所有租户库
新增业务表后同步到租户库:租户管理 → 选中租户 → 初始化租户库(实时单租户);调 /api/sysTenant/syncTenantDb(批量全租户);或用「库表管理」(SysDatabaseService)导出建表 SQL 交 DBA 逐库执行(离线,适合大表)。
7. 回滚方案与常见冲突处理
7.1 回滚矩阵
| 变更类型 | 能否回滚 | 做法 |
|---|---|---|
| 程序集(dll) | ✅ | 用升级前打的运行目录 zip 整体还原 |
| 配置文件 | ✅ | 用 Configuration.bak.* 还原,注意别覆盖客户改过的连接串 |
新增表([IncreTable]) | ✅ | DROP TABLE(业务表通常为空,风险低) |
| 加列 / 改列类型 | ⚠️ | 手工 ALTER 改回;扩长没问题,缩短/改类型可能丢数据 |
| 删列 / 删表 | ❌ | 只能从备份库恢复该表 |
| 种子数据 | ⚠️ | [IgnoreUpdateSeed] 的种子删掉对应行即可;全量种子执行过的需按备份比对 |
EnableUnderLine 改动 | ❌ | 会重命名全部列,上线后绝对不可改 |
SnowId:BaseTime 改动 | ❌ | 会导致 Id 变小甚至重复,上线后绝对不可改 |
Cryptogram 密钥改动 | ❌ | 已加密密码无法解密,需重置全部用户密码 |
7.2 常见冲突与处理
| 冲突现象 | 原因 | 处理 |
|---|---|---|
| 启动时报"对象名已存在" | 同时开了 EnableInitTable 和 EnableIncreTable | 生产只开 EnableIncreTable |
| 新表没建出来 | 实体忘了贴 [IncreTable],或贴了 [IgnoreTable] | 补特性后重启 |
| 新表建到了错的库 | [SysTable] / [LogTable] / [Tenant] 标记错误 | 改标记;已建错的表手工 DROP 后重建 |
| 菜单位置乱了 / 按钮权限丢失 | SysMenuSeedData 的 Id、Pid 与库中冲突 | 新菜单 Id 用全新雪花 Id,别复用已删除的 Id |
| 客户自定义字典被还原 | 跑了全量种子(EnableIncreSeed=false) | 改回 true;被覆盖的值从 SysLogDiff 的 BeforeData 里捞回来 |
| 启动卡在建表阶段 | 大表加列锁表,或数据库账号无 DDL 权限 | 提升账号权限 / 停机窗口执行 / 改手工 DDL |
| 租户库表结构不一致 | 新增业务表后没执行租户库同步 | 执行 syncTenantDb |
| 升级后所有人掉线 | 改了 JWT:IssuerSigningKey | 回滚 JWT 配置,或让用户重新登录 |
| 主键冲突 | 多节点 SnowId:WorkerId 相同 | 各节点配不同 WorkerId,或缓存切 Redis 让 YitIdHelperExtension 自动分配 |
SysLogDiff 暴涨 | EnableDiffLog 忘记关 | 关掉开关并清理 SysLogDiff |
8. 步骤清单:一次标准生产升级
- 停服(或切到维护页),确认无正在执行的定时任务。
- 备份:数据库(主库 + 日志库 + 全部租户库)、
Configuration/、wwwroot/upload、当前运行目录 zip。 - 拉代码 / 解压新发布包到新目录(不要直接覆盖,方便回滚)。
- 合并配置:逐文件
diff,保留本地连接串与密钥,只补新增字段;jq校验 JSON 合法。 - 设置开关:
EnableInitTable=false、EnableIncreTable=true、EnableInitSeed=true、EnableIncreSeed=true、EnableInitDb=false、EnableDiffLog=false。 - 先在测试库完整跑一遍,记录控制台的建表/种子耗时与新增/更新条数。
- 生产启动,观察控制台:
初始化表 xxx 耗时、初始化种子数据 xxx(数据量:n,新增 n 条,更新 n 条)。 - 启动后验证:登录 → 菜单完整 → 字典/配置正确 → 定时任务在跑 → 租户可切换且数据正确。
- 打开「平台管理 → 更新日志」(
/api/sysUpgrade/*)发布本次升级说明,通知用户。 - 观察 30 分钟:异常日志(
SysLogEx)、慢 SQL 日志为空,再放开流量。 - 出问题 → 用第 2 步的 zip 整目录回滚;只涉及新表的,单独
DROP即可。
⚠️ 注意事项 / 常见坑
EnableInitDb生产必须false:库由运维建好,程序建库可能覆盖字符集/排序规则。Oracle 不支持自动建库。EnableUnderLine上线后不可改:开了会把CreateTime变成create_time,关掉会反向重命名,必然报错。SnowId:BaseTime上线后不可改:主键会变小导致历史 Id 冲突。Cryptogram密钥改了要重新初始化账号:已加密密码解不开,只能通过重置密码解决。- 种子
Id要写死且全局唯一:内置种子用的是固定 Id(如菜单1310000000111),新增种子千万别撞号,否则会覆盖已有记录。 - 应用层种子最后执行:
InitSeedData特意用串行foreach(建表才并行),就是为了保证金应用层重写 Core 层种子的顺序。不要在种子类之间做跨类依赖。 - 租户种子 Id 不要写死:
InitTenantData会给为 0 的 Id 自动补号(租户 Id 递增)。 [IncreSeed]和[IgnoreUpdateSeed]建议成对使用:前者让它被执行,后者保证不覆盖客户数据。- 无主键表的种子只在表为空时插入:后期想追加数据必须手工插。
- 视图会被先删再建:
InitTable开头DropView,EnableInitView=false时视图不会自动恢复,升级后要检查视图是否还在。 - 并行建表要小心外键:
InitTable用Task.Run并行,表之间有外键依赖时可能顺序错乱,这种表建议手工建。 - 日志库没配时日志落主库:
SqlSugarSetup.LogDb会IsAnyConnection(LogConfigId)判断,未配则回退主库,此时主库体积增长要额外关注。
小结
生产升级的开关组合固定为 EnableInitTable=false + EnableIncreTable=true + EnableInitSeed=true + EnableIncreSeed=true。新表贴 [IncreTable],新种子贴 [IncreSeed] + [IgnoreUpdateSeed] + [SeedData(order)],客户可改的列贴 [IgnoreUpdateSeedColumn]。配置一律三向合并而不是覆盖,App.*.json 这类环境文件不会进发布包。回滚靠"整目录 zip + 配置备份 + 库备份"三件套,真正不可逆的只有删列删表和 EnableUnderLine / BaseTime / 加密密钥这三类改动。