一、为什么需要专门设计只读仓储

在日常开发中,我们经常会遇到这样的场景:系统需要展示大量数据,但这些数据很少被修改。比如电商网站的商品列表、新闻网站的文章列表等。如果每次都从主数据库中查询,会给数据库带来不必要的压力。

这时候,只读仓储就派上用场了。它专门为查询操作优化,与常规的仓储(负责增删改查)分开设计。这样做有几个明显好处:

  1. 查询性能更好,因为只读仓储可以针对查询做特殊优化
  2. 减轻主数据库负担,提高系统整体稳定性
  3. 可以灵活选择更适合查询的存储方案

举个例子,我们有个新闻发布系统,99%的请求都是查看新闻,只有1%是编辑新闻。如果所有请求都走同一个仓储,显然不太合理。

二、只读仓储的三种实现方式

1. 数据库视图方式

技术栈:C# + Entity Framework Core

// 新闻实体
public class News
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishTime { get; set; }
    public bool IsDeleted { get; set; }
}

// 只读仓储实现
public class NewsReadOnlyRepository
{
    private readonly AppDbContext _context;
    
    public NewsReadOnlyRepository(AppDbContext context)
    {
        _context = context;
    }
    
    // 使用数据库视图查询
    public async Task<List<News>> GetHotNewsAsync(int count)
    {
        return await _context.NewsView
            .Where(n => !n.IsDeleted)
            .OrderByDescending(n => n.PublishTime)
            .Take(count)
            .ToListAsync();
    }
}

这种方式适合数据量不是特别大的场景。优点是实现简单,缺点是性能提升有限。

2. 缓存结合方式

技术栈:C# + Redis

public class CachedNewsRepository
{
    private readonly NewsRepository _newsRepo;
    private readonly IDistributedCache _cache;
    
    public CachedNewsRepository(NewsRepository newsRepo, IDistributedCache cache)
    {
        _newsRepo = newsRepo;
        _cache = cache;
    }
    
    public async Task<List<News>> GetLatestNewsAsync()
    {
        // 先从缓存读取
        var cacheKey = "latest_news";
        var cachedData = await _cache.GetStringAsync(cacheKey);
        
        if (!string.IsNullOrEmpty(cachedData))
        {
            return JsonSerializer.Deserialize<List<News>>(cachedData);
        }
        
        // 缓存没有再从数据库读取
        var news = await _newsRepo.GetLatestNewsAsync();
        
        // 设置缓存,过期时间5分钟
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        };
        
        await _cache.SetStringAsync(
            cacheKey, 
            JsonSerializer.Serialize(news), 
            options);
            
        return news;
    }
}

这种方式适合读多写少的场景。优点是性能提升明显,缺点是需要处理缓存一致性问题。

3. 专门查询数据库方式

技术栈:C# + Elasticsearch

public class NewsSearchRepository
{
    private readonly ElasticClient _client;
    
    public NewsSearchRepository(ElasticClient client)
    {
        _client = client;
    }
    
    public async Task<List<News>> SearchNewsAsync(string keyword)
    {
        var response = await _client.SearchAsync<News>(s => s
            .Query(q => q
                .MultiMatch(m => m
                    .Fields(f => f
                        .Field(n => n.Title)
                        .Field(n => n.Content))
                    .Query(keyword)
                )
            )
            .Size(100)
        );
        
        return response.Documents.ToList();
    }
}

这种方式适合需要复杂查询的场景。优点是查询性能极佳,缺点是同步数据需要额外工作。

三、优化查询性能的实用技巧

1. 合理设计查询模型

不要直接使用领域模型作为查询返回结果。设计专门的DTO模型,只包含查询需要的字段。

// 专门为列表查询设计的DTO
public class NewsListItemDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public DateTime PublishTime { get; set; }
    public string Summary { get; set; }
}

// 在仓储中转换
public async Task<List<NewsListItemDto>> GetNewsListAsync(int page, int pageSize)
{
    return await _context.News
        .Where(n => !n.IsDeleted)
        .OrderByDescending(n => n.PublishTime)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(n => new NewsListItemDto
        {
            Id = n.Id,
            Title = n.Title,
            PublishTime = n.PublishTime,
            Summary = n.Content.Length > 100 
                ? n.Content.Substring(0, 100) + "..."
                : n.Content
        })
        .ToListAsync();
}

2. 使用分页查询

永远不要一次性查询大量数据。即使需要全部数据,也建议使用分批次查询。

public async Task<List<News>> GetAllNewsInBatchesAsync(int batchSize = 100)
{
    var result = new List<News>();
    int offset = 0;
    
    while (true)
    {
        var batch = await _context.News
            .Where(n => !n.IsDeleted)
            .OrderBy(n => n.Id)
            .Skip(offset)
            .Take(batchSize)
            .ToListAsync();
            
        if (!batch.Any())
            break;
            
        result.AddRange(batch);
        offset += batchSize;
    }
    
    return result;
}

3. 合理使用索引

确保查询用到的字段都有适当的索引。可以通过EF Core的配置来添加索引。

// 在DbContext中配置索引
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<News>()
        .HasIndex(n => n.PublishTime)
        .IsDescending();
        
    modelBuilder.Entity<News>()
        .HasIndex(n => new { n.IsDeleted, n.PublishTime });
}

四、实际应用中的注意事项

  1. 数据一致性问题:使用缓存或专门查询数据库时,要考虑数据同步延迟。对于强一致性要求的场景,需要特殊处理。

  2. 查询复杂度控制:不要让单个查询做太多事情。复杂的查询应该拆分成多个简单查询。

  3. 监控和调优:对查询性能进行监控,及时发现慢查询并优化。

  4. 测试环境差异:注意开发环境和生产环境的数据量差异,小数据量下表现良好的查询,可能在真实环境中变慢。

  5. 过度优化陷阱:不是所有查询都需要优化,优先优化真正影响性能的关键查询。

五、总结

只读仓储是DDD中一个非常实用的设计模式,特别适合读多写少的应用场景。通过将读写分离,我们可以针对查询操作做专门的优化,显著提升系统性能。

实现方式上,可以根据实际需求选择数据库视图、缓存或专门查询数据库等不同方案。每种方案都有其适用场景和优缺点,需要根据业务特点和技术条件来选择。

优化查询性能时,要特别注意查询模型设计、分页处理和索引使用等关键点。同时,实际应用中要处理好数据一致性、监控调优等问题。

记住,没有放之四海而皆准的方案。最好的设计永远是适合你当前业务需求和技术条件的设计。希望这些实践经验能帮助你在项目中更好地应用DDD,构建高性能的系统。