48. 性能调优与压测
性能问题的 90% 出在数据库访问上。框架已经把观测开关(SQL 打印、慢 SQL 告警、二级缓存、压测接口)都做好了,调优顺序固定为:先看 SQL → 再改查询 → 再加缓存 → 最后改架构。查询语法本身见《SqlSugar 查询》一章,本篇只讲怎么让它跑得快。
1. 观测链路图
请求进来
│
├─ EnableConsoleSql=true ──► Aop.OnLogExecuting 控制台彩色打印 SQL(SELECT 绿/UPDATE 黄/DELETE 红)
├─ Aop.OnLogExecuted ──► SqlExecutionTime > 5s 自动写 Warning 日志(含文件名/行号/方法名)
├─ Aop.OnError ──► 错误 SQL 完整参数化语句写 Error 日志
├─ Ado.CommandTimeOut ──► 默认 30 秒(SqlSugarSetup.SetDbAop)
└─ EnableDiffLog=true ──► Aop.OnDiffLogEvent → SysLogDiff
代码位置:Admin.NET.Core/SqlSugar/SqlSugarSetup.cs(SetDbAop / SetDbDiffLog)开启观测(改 Admin.NET.Application/Configuration/Database.json):
{
"DbConnection": {
"EnableConsoleSql": true, // 调试神器,生产必须关掉
"ConnectionConfigs": [
{
"DbSettings": {
"EnableDiffLog": true // 差异日志落 SysLogDiff,会放大写压力,压测时关掉
}
}
]
}
}2. 查询优化:先消灭 N+1 和 SELECT *
2.1 N+1 长什么样
// ❌ 100 条用户 = 101 次数据库往返
var users = await _sysUserRep.AsQueryable().ToListAsync();
foreach (var u in users)
{
// 每次循环都查一次库
u.OrgName = (await _sysOrgRep.GetFirstAsync(o => o.Id == u.OrgId))?.Name;
u.RoleName = string.Join(",", (await _sysUserRoleRep.AsQueryable()
.LeftJoin<SysRole>((a, b) => a.RoleId == b.Id)
.Where(a => a.UserId == u.Id).ToListAsync()).Select(x => x.Name));
}2.2 一次查询搞定(真实代码 SysUserService.Page)
// Admin.NET.Core/Service/User/SysUserService.cs
return await _sysUserRep.AsQueryable()
.LeftJoin<SysOrg>((u, a) => u.OrgId == a.Id)
.LeftJoin<SysPos>((u, a, b) => u.PosId == b.Id)
.Where(u => u.AccountType != AccountTypeEnum.SuperAdmin)
.WhereIF(orgList != null || extOrgUserIdList != null,
u => orgList.Contains(u.OrgId) || extOrgUserIdList.Contains(u.Id))
.WhereIF(!string.IsNullOrWhiteSpace(input.RealName), u => u.RealName.Contains(input.RealName))
.OrderBy(u => new { u.OrderNo, u.Id })
.Select((u, a, b) => new UserOutput
{
OrgName = a.Name,
PosName = b.Name,
// 一对多文本用子查询直接拼字符串,避免循环查库
RoleName = SqlFunc.Subqueryable<SysUserRole>()
.LeftJoin<SysRole>((m, n) => m.RoleId == n.Id)
.Where(m => m.UserId == u.Id)
.SelectStringJoin((m, n) => n.Name, ","),
DomainAccount = SqlFunc.Subqueryable<SysUserLdap>()
.Where(m => m.UserId == u.Id)
.Select(m => m.Account)
}, true) // ★ 末尾 true = 自动填充主表同名字段,别漏
.ToPagedListAsync(input.Page, input.PageSize);2.3 只取需要的列
// ❌ 把大字段(Remark / 文件内容)也捞出来
var list = await _rep.AsQueryable().Where(...).ToListAsync();
// ✅ 只查列表需要的 3 列
var list = await _rep.AsQueryable()
.Where(u => u.Status == StatusEnum.Enable)
.Select(u => new { u.Id, u.Name, u.CreateTime })
.ToListAsync();
// ✅ 只需要判断存在性时,别 Count 全表
var exists = await _rep.AsQueryable().Where(u => u.Code == code).AnyAsync();
// ✅ 只要一条就别 ToList
var one = await _rep.AsQueryable().Where(u => u.Id == id).FirstAsync();2.4 索引建议
| 场景 | 建议索引 | 说明 |
|---|---|---|
| 分页列表的筛选条件 | (Status, CreateTime DESC) 联合索引 | 覆盖 Where + OrderBy |
Contains 模糊查询 | 前缀匹配 LIKE 'xx%' 可走索引;'%xx%' 不行 | 中缀搜索上全文索引/ES |
| 租户 + 业务表 | (TenantId, CreateOrgId) | 全局过滤器自动带这两个条件 |
| 软删除表 | (IsDelete, Id) | IDeletedFilter 固定带 IsDelete = false |
父子树(Pid) | (Pid, OrderNo) | SysOrg / SysRegion / SysMenu |
| 日志表按时间查 | (CreateTime) + 按月分区 | SysLog* 数据量大,务必分区或定期清理 |
建索引后一定要用 EnableConsoleSql 确认 SQL 真的走了索引,别凭感觉。
3. 批量写入:BulkCopy / Storageable / BulkMerge
3.1 大批量新增用 Fastest<T>().BulkCopyAsync(真实代码 SysRegionService.SyncRegionGD)
// Admin.NET.Core/Service/Region/SysRegionService.cs
await _sysRegionRep.AsDeleteable().ExecuteCommandAsync();
await _sysRegionRep.Context.Fastest<SysRegion>().BulkCopyAsync(regionList);3.2 有则更新无则插入用 Storageable(真实代码 SysPosService.Import)
// Admin.NET.Core/Service/Pos/SysPosService.cs
await _sysPosRep.Context.Storageable(posList).WhereColumns(u => u.Code).ExecuteCommandAsync();按主键 upsert 且需要知道新增/更新条数时,用 ToStorage()(真实代码 SqlSugarExtension.InitTableSeedData):
var storage = dbProvider.StorageableByObject(seedDataList).ToStorage();
// 只更新未贴 [IgnoreUpdateSeedColumn] 的列
updateCount = storage.AsUpdateable
.IgnoreColumns(entityInfo.Columns
.Where(u => u.PropertyInfo.GetCustomAttribute<IgnoreUpdateSeedColumnAttribute>() != null)
.Select(u => u.PropertyName).ToArray())
.ExecuteCommand();
insertCount = storage.AsInsertable.ExecuteCommand();3.3 分块批量合并(真实代码 SysEnumService.EnumToDict)
// Admin.NET.Core/Service/Enum/SysEnumService.cs
if (enableBulk) await _db.Fastest<SysDictType>().PageSize(300).BulkMergeAsync(updatedDictTypes);
if (enableBulk) await _db.Fastest<SysDictData>().PageSize(300).BulkMergeAsync(updatedDictDatas);3.4 选型对照
| 方式 | 适用 | 注意 |
|---|---|---|
Insertable(list).ExecuteCommandAsync() | 几百条以内 | 单条 SQL 多行值,参数过多会超限 |
Fastest<T>().BulkCopyAsync(list) | 万级以上纯新增 | SqlServer 走 SqlBulkCopy,MySql 走 LOAD DATA,最快 |
Fastest<T>().PageSize(n).BulkMergeAsync(list) | 万级 upsert | PageSize 控制每批行数 |
Storageable(list).WhereColumns(...) | 按业务唯一键 upsert | 不指定则按主键 |
Storageable(list).ToStorage() | 需要新增/更新分别计数 | 种子数据就是这么做的 |
4. 缓存策略
4.1 三层缓存位置
L1 内存缓存(Cache.Default) → 过滤器表达式、Aop 列表(SqlSugarFilter / SqlSugar_Aop)
L2 SysCacheService(Memory/Redis) → 业务数据,键统一加 CacheOptions.Prefix(默认 "adminnet_")
L3 SqlSugar 二级缓存(SqlSugarCache)→ .WithCache() 的查询结果,键前缀 CacheConst.SqlSugar4.2 SysCacheService 用法
// 注入(ISingleton,可在任意生命周期注入)
private readonly SysCacheService _sysCacheService;
// 读
var val = _sysCacheService.Get<string>(key);
bool hit = _sysCacheService.ExistKey(key);
// 写(不传 expire 则永不过期)
_sysCacheService.Set(key, value, TimeSpan.FromMinutes(10));
// 读不到则加载(推荐,天然防缓存击穿)
var list = _sysCacheService.GetOrAdd(CacheConst.KeyUserApi + userId,
_ => LoadApiListFromDb(userId), 3600); // 第三个参数是过期秒数,-1 表示不过期
// 删 / 前缀删
_sysCacheService.Remove(key);
_sysCacheService.RemoveByPrefixKey(CacheConst.KeyConfig);
// 分布式锁
using var locker = _sysCacheService.BeginCacheLock("my:lock", msTimeout: 500, msExpire: 10000);4.3 缓存 Key 规范(Admin.NET.Core/Const/CacheConst.cs)
全部常量都在 CacheConst 里,新增缓存必须在这里加常量,禁止硬编码字符串:
| 常量 | 值 | 用途 |
|---|---|---|
KeyUserToken | sys_user_token: | 用户 Token |
KeyAllApi | sys_all_api | 全量接口树(改接口后必清) |
KeyUserApi | sys_user_api: | 用户按钮权限 |
KeyUserOrg / KeyUserRole | sys_user_org: / sys_user_role: | 机构/角色集合 |
KeyRoleMaxDataScope | sys_role_maxDataScope: | 最大数据范围 |
KeyConfig | sys_config: | 系统参数 |
KeyDict / KeyConst | sys_dict: / sys_const: | 字典 / 常量 |
KeyTenantHash | sys_tenant_hash | 租户映射 |
KeyUserOnline | sys_user_online | 在线用户 |
KeyDisLockJob | dis_lock_job: | 任务调度分布式锁 |
SqlSugar | sys_sqlSugar: | SqlSugar 二级缓存前缀 |
KeyExcelTemp | sys_excel_temp: | 导入错误标记 Excel |
Key 拼接约定:前缀 + 业务标识,带分片的用冒号结尾再拼 Id({CacheConst.KeyUserApi}{userId})。清缓存时按前缀批量删(RemoveByPrefixKey)。
4.4 Redis 与降级(真实代码 SysCacheService.GetKeyList)
// 内存缓存走 Keys 过滤,Redis 走 SCAN,避免 KEYS 阻塞
return _cacheProvider.Cache == Cache.Default
? [.. _cacheProvider.Cache.Keys.Where(u => u.StartsWith(_cacheOptions.Prefix))...]
: [.. ((FullRedis)_cacheProvider.Cache).Search($"{_cacheOptions.Prefix}*", 0, int.MaxValue)...];切换到 Redis(Configuration/Cache.json):
{
"Cache": {
"Prefix": "adminnet_",
"CacheType": "Redis", // Memory → Redis
"Redis": {
"Configuration": "server=127.0.0.1:6379;password=;db=5;",
"MaxMessageSize": "1048576",
"AutoDetect": false // 阿里云代理模式集群必须 false
}
}
}Redis 不可用时:NewLife.Redis 会降级为内存缓存并后台重连,接口不会 500,但多节点间缓存不再一致(Token、在线用户、雪花 WorkerId 都会出问题)。此时要么修 Redis,要么把 CacheType 显式改回 Memory 并保持单节点。
5. 分页大数据量优化
5.1 框架自带分页
// Admin.NET.Core/SqlSugar/SqlSugarPagedList.cs
await query.ToPagedListAsync(input.Page, input.PageSize); // 实体
await query.ToPagedListAsync<TEntity, TResult>(page, size, u => new TResult{}); // 投影
await query.ToPagedListDataMaskAsync(page, size); // 脱敏分页ToPagedListAsync 内部会额外执行一次 COUNT(*)。千万级表上这个 COUNT 就是瓶颈。
5.2 优化手段
| 手段 | 做法 | 适用场景 |
|---|---|---|
| 覆盖索引 | 让 COUNT 走二级索引而非聚簇索引 | 通用首选 |
| 游标分页 | Where(u => u.Id > lastId).OrderBy(u => u.Id).Take(size),不查总数 | 无限滚动 / 导出 |
| 限制深翻页 | 前端最大页码限制,或 Page * PageSize > 10000 时强制走游标 | 列表页 |
| 异步导出 | 大数据导出改后台任务 + 文件下载,不走接口同步返回 | 导出 |
| 定期归档 | SysLogOp.Clear() / 定时任务 CleanSysLogJob 清理历史日志 | 日志表 |
游标分页示例:
// 不查总数,靠"是否还有下一页"驱动
var pageSize = input.PageSize + 1;
var rows = await _rep.AsQueryable()
.WhereIF(input.LastId > 0, u => u.Id > input.LastId)
.OrderBy(u => u.Id)
.Take(pageSize)
.Select(u => new XxxOutput { }, true)
.ToListAsync();
var hasMore = rows.Count == pageSize;
var items = hasMore ? rows.Take(input.PageSize) : rows;6. 异步与并行
- 一路到底 async:Controller → Service → Repository 全部
async/await,禁止.Result/.GetAwaiter().GetResult()(会死锁线程池)。框架内部必须同步的地方用Scoped.Create(...)包裹(见SqlSugarFilter)。 - IO 密集并行:多个无依赖查询用
Task.WhenAll,但注意SqlSugarScope是单例、连接是按ConfigId池化的,单库并行查询过多会抢连接池。
var userTask = _userRep.AsQueryable().Where(u => u.Status == 1).ToListAsync();
var orgTask = _orgRep.AsQueryable().ToListAsync();
await Task.WhenAll(userTask, orgTask);
// 已完成任务,取 .Result 不会阻塞
var users = userTask.Result;
var orgs = orgTask.Result;- CPU 密集:交给后台任务(
SysJobService建作业)或事件总线,不要在请求线程里做图片压缩/大文件解析。
7. 内置压测能力(真实存在,不是玩具)
SysCommonService.StressTest 直接暴露成接口,前端页面在「开发中心 → 接口压测」(Web/src/views/system/stressTest/index.vue)。
// Admin.NET.Core/Service/Common/SysCommonService.cs
[DisplayName("性能压力测试")]
public async Task<StressTestHarnessResult> StressTest(StressTestInput input)
{
return await _httpRemoteService.SendAsync(HttpRequestBuilder.StressTestHarness(input.RequestUri)
.SetNumberOfRequests(input.NumberOfRequests) // 每轮请求量 1-100
.SetNumberOfRounds(input.NumberOfRounds) // 轮次 1-100
.SetMaxDegreeOfParallelism(input.MaxDegreeOfParallelism) // 最大并发度 0-10000,默认 CPU 核数
.With(builder => builder.WithHeaders(input.Headers)
.WithQueryParameters(input.QueryParameters)
.WithPathParameters(input.PathParameters)
.SetJsonContent(input.JsonContent)));
}调用示例(带 Token 压测自己的接口):
TOKEN=$(curl -s -X POST http://localhost:5005/api/sysAuth/login \
-H "Content-Type: application/json" \
-d '{"account":"superAdmin","password":"Admin.NET++010101"}' | jq -r '.result.accessToken')
curl -s -X POST http://localhost:5005/api/sysCommon/stressTest \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"requestUri": "http://localhost:5005/api/sysUser/page",
"numberOfRequests": 20,
"numberOfRounds": 5,
"maxDegreeOfParallelism": 50,
"headers": { "Authorization": "Bearer '"$TOKEN"'" },
"jsonContent": [ { "key": "page", "value": "1" }, { "key": "pageSize", "value": "20" } ]
}'压测时注意:
- 被测地址要写完整 URL(含
http://host:port),不是相对路径。 - 需要鉴权的接口靠
headers传 Token;jsonContent是KeyValuePair<string,string>数组,会被序列化成请求体。 - 上限是每轮 100 请求 × 100 轮,超出会报
Range校验错误。这只是轻量基准工具,做正式容量评估请用 wrk / JMeter / k6。 - 压测前关掉
EnableConsoleSql和EnableDiffLog,否则瓶颈在日志 I/O 上。
8. 生产环境诊断清单
按顺序打勾,不要跳步:
□ 1. 现象定位:是全部接口慢,还是个别接口慢?个别慢 → 第 2 步;全慢 → 第 6 步
□ 2. 抓 SQL:临时开 EnableConsoleSql=true,复现一次,复制出全部 SQL
□ 3. 看执行计划:把 SQL 拿到数据库客户端 EXPLAIN,确认是否走索引、是否全表扫描
□ 4. 数往返次数:一次请求产生的 SQL 条数 > 20 说明有 N+1,回到第 2 节
□ 5. 看慢 SQL 日志:搜索 "超时SQL",日志里带文件名/行号/方法名,直接定位到代码
□ 6. 看资源:/api/sysServer/hardwareInfo + /api/sysServer/runtimeInfo(CPU/内存/线程/句柄)
□ 7. 看连接池:数据库连接数是否打满(MySql 默认 100),是否长事务未提交
□ 8. 看缓存命中:/api/sysCache/keyList 确认热点 Key 存在;Redis 用 INFO 看 hitrate
□ 9. 看日志表体积:SysLogOp / SysLogHttp 是否百万级以上未清理,清理后重建索引
□ 10. 看线程:大量 Thread.Sleep / .Result 阻塞 → 线程池饥饿,改全异步
□ 11. 看部署:是否多节点共享同一 WorkerId(雪花 Id 冲突)、是否 Redis 串了环境
□ 12. 改完复测:同一组参数再跑一次 stressTest,对比前后数据关键配置核查表:
| 配置 | 文件 | 生产建议 |
|---|---|---|
EnableConsoleSql | Database.json | false |
EnableDiffLog | Database.json | false(除非要做审计) |
EnableInitTable / EnableIncreTable | Database.json | false / true |
Cache:CacheType | Cache.json | Redis(多节点必选) |
SnowId:WorkerId | App.json | 多节点必须互不相同,或依赖 Redis 自动分配 |
JWT:ValidateLifetime / ClockSkew | JWT.json | 保持 true / 60 |
Cluster:Enabled | Cache.json | 多节点 true |
Logging 级别 | Logging.json | Information,压测时 Warning |
9. 步骤清单:给一个慢接口做一次完整调优
- 打开
Database.json的EnableConsoleSql: true,重启。 - 前端操作一次慢页面,回到控制台复制该请求产生的全部 SQL。
- 数 SQL 条数:> 20 条 → 有 N+1,改用
LeftJoin+Select(..., true)或SqlFunc.Subqueryable(...).SelectStringJoin(...)。 - 检查
Select:是否取了不需要的大字段,改成只查列表列。 - 取最长那条 SQL 去数据库
EXPLAIN,补联合索引(覆盖Where+OrderBy,带TenantId/IsDelete)。 - 高频读且很少变的数据加
SysCacheService.GetOrAdd,Key 用CacheConst常量,写操作处同步Remove/RemoveByPrefixKey。 - 列表接口数据量过大 → 改游标分页或限制深翻页。
- 改完关掉
EnableConsoleSql,用stressTest跑前后对比。 - 上线后观察慢 SQL 日志(
超时SQL)是否还有该接口。
⚠️ 注意事项 / 常见坑
EnableConsoleSql生产必须关:每条 SQL 都Console.WriteLine,QPS 一上来日志 I/O 直接拖垮服务。Select(..., true)的true别漏:漏了主表字段全是默认值,表现为"列表有数据但字段为空",很容易误判成性能问题。- 超管跳过全部过滤器:用超管测性能测不出来,因为少了
TenantId/IsDelete/机构三组条件,SQL 比真实用户简单得多。 - 全局过滤器是默认生效的:软删除、租户、数据范围都会自动加
Where,加索引时必须把这些字段算进去,否则过滤器一加就全表扫。 BulkCopy不走全局过滤器也不触发数据审计:批量导入要自己补TenantId/CreateUserId,Aop.DataExecuting对Fastest不生效。GetOrAdd的委托里不要写数据库写操作:并发下会重复执行;需要"只执行一次"用BeginCacheLock。- 缓存 Key 别漏
CacheConst前缀:SysCacheService内部会自动拼CacheOptions.Prefix,但你传入的 Key 本身必须带业务前缀,否则RemoveByPrefixKey清不掉。 - 改了
SysConfig要清缓存:SysConfigService.RemoveConfigCache清四个 Key,直接改库不生效。 - 压测接口会真打数据库:
stressTest直接请求目标地址,务必确认目标不是生产库的写入接口。 - 别在请求线程里
.Result:SqlSugarFilter里用的是Scoped.Create(...)+GetAwaiter().GetResult(),那是因为过滤器是同步上下文;业务代码一律await。 - 日志表要定期清:
SysLogOp.Clear()或CleanSysLogJob定时任务,别等它涨到千万行才处理。 - 多节点
SnowId:WorkerId冲突会主键重复:Memory 缓存模式下 WorkerId 取配置文件固定值,多节点必须各不相同;用 Redis 缓存时YitIdHelperExtension会自动分配。
小结
调优路径固定:开 EnableConsoleSql 看 SQL → 消灭 N+1 和多余列 → 补覆盖索引 → 热点数据进 SysCacheService(Key 用 CacheConst)→ 大批量写用 BulkCopy/Storageable → 大数据列表改游标分页 → 用 /api/sysCommon/stressTest 做前后对比。生产环境按第 8 节清单逐项排查,先定位是 SQL 慢、缓存失效、连接池满还是线程池饥饿,再动手改代码。