第十八章:效能優化 - 習題解答
本文件提供第十八章實戰練習的完整解答,涵蓋快取實作、N+1 問題解決和效能基準測試。
練習 1:實作快取層
題目
- 為
BookAppService的所有查詢方法加入 Redis 快取。 - 實作快取失效策略(更新/刪除時移除快取)。
- 監控快取命中率。
解答
步驟 1:配置 Redis
bash
# 安裝套件
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
dotnet add package Volo.Abp.Caching.StackExchangeRediscsharp
// BookStoreApplicationModule.cs
using Volo.Abp.Caching;
using Volo.Abp.Caching.StackExchangeRedis;
[DependsOn(typeof(AbpCachingStackExchangeRedisModule))]
public class BookStoreApplicationModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
Configure<AbpDistributedCacheOptions>(options =>
{
options.KeyPrefix = "BookStore:";
options.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20);
options.GlobalCacheEntryOptions.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
});
context.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = configuration["Redis:Configuration"];
options.InstanceName = "BookStore:";
});
}
}json
// appsettings.json
{
"Redis": {
"Configuration": "localhost:6379"
}
}步驟 2:實作帶快取的 BookAppService
csharp
// Application/Books/BookAppService.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Caching;
using Volo.Abp.Domain.Repositories;
namespace BookStore.Books
{
public class BookAppService : ApplicationService, IBookAppService
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IDistributedCache<BookDto> _bookCache;
private readonly IDistributedCache<PagedResultDto<BookDto>> _bookListCache;
public BookAppService(
IRepository<Book, Guid> bookRepository,
IDistributedCache<BookDto> bookCache,
IDistributedCache<PagedResultDto<BookDto>> bookListCache)
{
_bookRepository = bookRepository;
_bookCache = bookCache;
_bookListCache = bookListCache;
}
public async Task<BookDto> GetAsync(Guid id)
{
var cacheKey = $"Book:{id}";
// 嘗試從快取讀取
var cached = await _bookCache.GetAsync(cacheKey);
if (cached != null)
{
Logger.LogDebug("Cache hit for book {BookId}", id);
return cached;
}
Logger.LogDebug("Cache miss for book {BookId}", id);
// 從資料庫讀取
var book = await _bookRepository.GetAsync(id);
var dto = ObjectMapper.Map<Book, BookDto>(book);
// 儲存到快取(10 分鐘)
await _bookCache.SetAsync(
cacheKey,
dto,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
return dto;
}
public async Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input)
{
var cacheKey = $"BookList:{input.SkipCount}:{input.MaxResultCount}:{input.Sorting}";
// 嘗試從快取讀取
var cached = await _bookListCache.GetAsync(cacheKey);
if (cached != null)
{
Logger.LogDebug("Cache hit for book list");
return cached;
}
Logger.LogDebug("Cache miss for book list");
// 從資料庫讀取
var queryable = await _bookRepository.GetQueryableAsync();
var totalCount = await AsyncExecuter.CountAsync(queryable);
var books = await AsyncExecuter.ToListAsync(
queryable
.OrderBy(input.Sorting ?? "name")
.Skip(input.SkipCount)
.Take(input.MaxResultCount));
var result = new PagedResultDto<BookDto>(
totalCount,
ObjectMapper.Map<List<Book>, List<BookDto>>(books));
// 儲存到快取(5 分鐘)
await _bookListCache.SetAsync(
cacheKey,
result,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
return result;
}
public async Task<BookDto> CreateAsync(CreateUpdateBookDto input)
{
var book = new Book(
GuidGenerator.Create(),
input.Name,
input.Type,
input.PublishDate,
input.Price);
await _bookRepository.InsertAsync(book);
// 清除列表快取
await InvalidateListCacheAsync();
return ObjectMapper.Map<Book, BookDto>(book);
}
public async Task<BookDto> UpdateAsync(Guid id, CreateUpdateBookDto input)
{
var book = await _bookRepository.GetAsync(id);
book.Name = input.Name;
book.Type = input.Type;
book.PublishDate = input.PublishDate;
book.Price = input.Price;
await _bookRepository.UpdateAsync(book);
// 移除單一書籍快取
await _bookCache.RemoveAsync($"Book:{id}");
// 清除列表快取
await InvalidateListCacheAsync();
return ObjectMapper.Map<Book, BookDto>(book);
}
public async Task DeleteAsync(Guid id)
{
await _bookRepository.DeleteAsync(id);
// 移除單一書籍快取
await _bookCache.RemoveAsync($"Book:{id}");
// 清除列表快取
await InvalidateListCacheAsync();
}
private async Task InvalidateListCacheAsync()
{
// 簡單的做法:移除所有列表快取
// 更好的做法:使用 Redis 的 pattern matching 或 cache tags
await _bookListCache.RemoveManyAsync(new[] { "BookList:*" });
}
}
}步驟 3:實作快取命中率監控
csharp
// Application/Books/CacheMetricsService.cs
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace BookStore.Books
{
public class CacheMetricsService : ISingletonDependency
{
private readonly ConcurrentDictionary<string, CacheMetrics> _metrics = new();
public void RecordHit(string cacheKey)
{
var metrics = _metrics.GetOrAdd(cacheKey, _ => new CacheMetrics());
metrics.Hits++;
}
public void RecordMiss(string cacheKey)
{
var metrics = _metrics.GetOrAdd(cacheKey, _ => new CacheMetrics());
metrics.Misses++;
}
public CacheMetrics GetMetrics(string cacheKey)
{
return _metrics.GetOrAdd(cacheKey, _ => new CacheMetrics());
}
public Dictionary<string, CacheMetrics> GetAllMetrics()
{
return new Dictionary<string, CacheMetrics>(_metrics);
}
}
public class CacheMetrics
{
public long Hits { get; set; }
public long Misses { get; set; }
public double HitRate => Hits + Misses == 0 ? 0 : (double)Hits / (Hits + Misses) * 100;
}
}csharp
// 在 BookAppService 中使用
public class BookAppService : ApplicationService, IBookAppService
{
private readonly CacheMetricsService _cacheMetrics;
public async Task<BookDto> GetAsync(Guid id)
{
var cacheKey = $"Book:{id}";
var cached = await _bookCache.GetAsync(cacheKey);
if (cached != null)
{
_cacheMetrics.RecordHit(cacheKey);
return cached;
}
_cacheMetrics.RecordMiss(cacheKey);
// ... 其餘邏輯
}
}csharp
// Application/Books/CacheMetricsAppService.cs
public class CacheMetricsAppService : ApplicationService
{
private readonly CacheMetricsService _cacheMetrics;
public Dictionary<string, CacheMetrics> GetCacheMetrics()
{
return _cacheMetrics.GetAllMetrics();
}
}練習 2:解決 N+1 問題
題目
- 在現有專案中找出所有的 N+1 查詢。
- 使用
Include或投影重構。 - 使用 SQL Profiler 驗證查詢次數減少。
解答
步驟 1:識別 N+1 問題
問題範例:
csharp
// ❌ 錯誤:會產生 N+1 查詢
public async Task<List<OrderDto>> GetOrdersWithCustomersAsync()
{
var orders = await _orderRepository.GetListAsync();
var orderDtos = new List<OrderDto>();
foreach (var order in orders)
{
// 每個訂單都會觸發一次查詢
var customer = await _customerRepository.GetAsync(order.CustomerId);
orderDtos.Add(new OrderDto
{
Id = order.Id,
OrderNumber = order.OrderNumber,
CustomerName = customer.Name // N+1 問題
});
}
return orderDtos;
}步驟 2:使用 Include 解決
csharp
// ✅ 正確:使用 Include
public async Task<List<OrderDto>> GetOrdersWithCustomersAsync()
{
var orders = await (await _orderRepository.GetQueryableAsync())
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ToListAsync();
return ObjectMapper.Map<List<Order>, List<OrderDto>>(orders);
}步驟 3:使用投影(Projection)優化
csharp
// ✅ 更好:使用投影,只查詢需要的欄位
public async Task<List<OrderSummaryDto>> GetOrderSummariesAsync()
{
return await (await _orderRepository.GetQueryableAsync())
.Select(o => new OrderSummaryDto
{
Id = o.Id,
OrderNumber = o.OrderNumber,
CustomerName = o.Customer.Name, // 自動 JOIN
TotalAmount = o.TotalAmount,
ItemCount = o.Items.Count,
CreationTime = o.CreationTime
})
.ToListAsync();
}步驟 4:使用 SQL Profiler 驗證
啟用 EF Core 日誌:
csharp
// Program.cs 或 appsettings.json
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Information);json
{
"Logging": {
"LogLevel": {
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}執行前後對比:
sql
-- ❌ N+1 問題:會產生 1 + N 次查詢
SELECT * FROM Orders;
SELECT * FROM Customers WHERE Id = @p0; -- 重複 N 次
-- ✅ 使用 Include:只產生 1-2 次查詢
SELECT o.*, c.*
FROM Orders o
LEFT JOIN Customers c ON o.CustomerId = c.Id;步驟 5:進階優化 - Split Query
對於多個 Include,使用 Split Query 避免笛卡爾積:
csharp
public async Task<List<OrderDto>> GetOrdersWithDetailsAsync()
{
var orders = await (await _orderRepository.GetQueryableAsync())
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.AsSplitQuery() // 避免笛卡爾積
.ToListAsync();
return ObjectMapper.Map<List<Order>, List<OrderDto>>(orders);
}練習 3:效能基準測試
題目
- 使用 BenchmarkDotNet 比較三種查詢方式的效能。
- 分析記憶體使用情況。
解答
步驟 1:安裝 BenchmarkDotNet
bash
dotnet add package BenchmarkDotNet步驟 2:建立基準測試
csharp
// Benchmarks/BookQueryBenchmarks.cs
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.EntityFrameworkCore;
using BookStore.Books;
using BookStore.EntityFrameworkCore;
namespace BookStore.Benchmarks
{
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class BookQueryBenchmarks
{
private BookStoreDbContext _dbContext;
[GlobalSetup]
public void Setup()
{
var options = new DbContextOptionsBuilder<BookStoreDbContext>()
.UseSqlServer("Server=localhost;Database=BookStore_Benchmark;User Id=sa;Password=YourPassword;TrustServerCertificate=True")
.Options;
_dbContext = new BookStoreDbContext(options);
// 確保資料庫有測試資料
SeedData().Wait();
}
private async Task SeedData()
{
if (!await _dbContext.Books.AnyAsync())
{
for (int i = 0; i < 1000; i++)
{
_dbContext.Books.Add(new Book(
Guid.NewGuid(),
$"Book {i}",
BookType.Fiction,
DateTime.Now,
10 + i));
}
await _dbContext.SaveChangesAsync();
}
}
[Benchmark(Baseline = true)]
public async Task<List<Book>> WithoutTracking()
{
return await _dbContext.Books
.AsNoTracking()
.ToListAsync();
}
[Benchmark]
public async Task<List<Book>> WithTracking()
{
return await _dbContext.Books
.ToListAsync();
}
[Benchmark]
public async Task<List<BookDto>> WithProjection()
{
return await _dbContext.Books
.Select(b => new BookDto
{
Id = b.Id,
Name = b.Name,
Price = b.Price
})
.ToListAsync();
}
[Benchmark]
public async Task<List<Book>> WithPagination()
{
return await _dbContext.Books
.AsNoTracking()
.OrderBy(b => b.Name)
.Skip(0)
.Take(100)
.ToListAsync();
}
[GlobalCleanup]
public void Cleanup()
{
_dbContext?.Dispose();
}
}
public class BookDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public float Price { get; set; }
}
}步驟 3:執行基準測試
csharp
// Program.cs
using BenchmarkDotNet.Running;
using BookStore.Benchmarks;
class Program
{
static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<BookQueryBenchmarks>();
}
}bash
dotnet run -c Release步驟 4:分析結果
基準測試會產生類似以下的報告:
| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated |
|------------------ |----------:|---------:|---------:|------:|-------:|-------:|----------:|
| WithoutTracking | 45.23 ms | 0.892 ms | 0.835 ms | 1.00 | 1000.0 | 500.0 | 8.5 MB |
| WithTracking | 52.67 ms | 1.045 ms | 0.977 ms | 1.16 | 1500.0 | 750.0 | 12.3 MB |
| WithProjection | 38.91 ms | 0.765 ms | 0.715 ms | 0.86 | 800.0 | 400.0 | 6.2 MB |
| WithPagination | 5.12 ms | 0.098 ms | 0.092 ms | 0.11 | 100.0 | 50.0 | 850 KB |分析:
WithProjection最快且記憶體使用最少(只查詢需要的欄位)WithPagination因為只載入 100 筆,效能最佳WithTracking因為 EF Core 的變更追蹤,記憶體使用較高
步驟 5:進階基準測試 - 快取對比
csharp
[MemoryDiagnoser]
public class CacheBenchmarks
{
private IDistributedCache<BookDto> _cache;
private BookStoreDbContext _dbContext;
private Guid _testBookId;
[GlobalSetup]
public async Task Setup()
{
// 設定 Redis 快取
var services = new ServiceCollection();
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
var provider = services.BuildServiceProvider();
_cache = provider.GetRequiredService<IDistributedCache<BookDto>>();
// 設定資料庫
var options = new DbContextOptionsBuilder<BookStoreDbContext>()
.UseSqlServer("...")
.Options;
_dbContext = new BookStoreDbContext(options);
// 準備測試資料
var book = new Book(Guid.NewGuid(), "Test Book", BookType.Fiction, DateTime.Now, 10);
_dbContext.Books.Add(book);
await _dbContext.SaveChangesAsync();
_testBookId = book.Id;
// 預熱快取
await _cache.SetAsync($"Book:{_testBookId}", new BookDto { Id = _testBookId, Name = "Test Book" });
}
[Benchmark(Baseline = true)]
public async Task<BookDto> FromDatabase()
{
var book = await _dbContext.Books.FindAsync(_testBookId);
return new BookDto { Id = book.Id, Name = book.Name, Price = book.Price };
}
[Benchmark]
public async Task<BookDto> FromCache()
{
return await _cache.GetAsync($"Book:{_testBookId}");
}
}總結
本章練習涵蓋了效能優化的核心技術:
快取實作:
- 使用 Redis 分散式快取
- 實作快取失效策略
- 監控快取命中率
N+1 問題解決:
- 使用
Include預先載入關聯資料 - 使用投影只查詢需要的欄位
- 使用 SQL Profiler 驗證優化效果
- 使用
效能基準測試:
- 使用 BenchmarkDotNet 進行微基準測試
- 分析記憶體使用情況
- 比較不同查詢策略的效能
最佳實踐:
- 優先使用快取減少資料庫查詢
- 避免 N+1 問題,使用 Include 或投影
- 對唯讀查詢使用
AsNoTracking() - 始終使用分頁,避免一次載入大量資料
- 使用基準測試驗證優化效果