Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance Tuning với .NET

Indexing Performance

Tối ưu Bulk Indexing

// Cách tính batch size tối ưu: 5-15 MB per request
// Với document ~1KB → batch 5000-10000 docs
// Với document ~10KB → batch 500-1000 docs

public class BulkIndexOptions
{
    public int BatchSize { get; init; } = 500;
    public int MaxDegreeOfParallelism { get; init; } = 2;
    public bool DisableRefreshDuringIndex { get; init; } = true;
}

public async Task BulkIndexOptimizedAsync<T>(
    IEnumerable<T> documents,
    string indexName,
    Func<T, string> getId,
    BulkIndexOptions? options = null) where T : class
{
    options ??= new BulkIndexOptions();

    if (options.DisableRefreshDuringIndex)
    {
        await _es.Indices.PutSettingsAsync(indexName, s =>
            s.RefreshInterval(new Duration("-1"))
        );
    }

    try
    {
        var batches = documents
            .Chunk(options.BatchSize)
            .ToList();

        // Xử lý song song với giới hạn concurrency
        await Parallel.ForEachAsync(
            batches,
            new ParallelOptions { MaxDegreeOfParallelism = options.MaxDegreeOfParallelism },
            async (batch, ct) =>
            {
                await _es.BulkAsync(b => b
                    .Index(indexName)
                    .IndexMany(batch, (d, doc) => d.Id(getId(doc)))
                , ct);
            }
        );
    }
    finally
    {
        if (options.DisableRefreshDuringIndex)
        {
            await _es.Indices.PutSettingsAsync(indexName, s =>
                s.RefreshInterval(new Duration("1s"))
            );
            await _es.Indices.RefreshAsync(indexName);
        }
    }
}

Search Performance

Dùng Filter thay vì Must khi không cần Score

// ❌ Chậm: Tính score cho tất cả documents
var slow = await _es.SearchAsync<Product>(s => s
    .Query(q => q
        .Bool(b => b
            .Must(
                m => m.Term(t => t.Field(f => f.Category).Value("smartphones")),
                m => m.Term(t => t.Field(f => f.InStock).Value(true))
            )
        )
    )
);

// ✅ Nhanh: Filter không tính score, được cache
var fast = await _es.SearchAsync<Product>(s => s
    .Query(q => q
        .Bool(b => b
            .Filter(
                f => f.Term(t => t.Field(field => field.Category).Value("smartphones")),
                f => f.Term(t => t.Field(field => field.InStock).Value(true))
            )
        )
    )
);

Source Filtering - Chỉ lấy fields cần thiết

// ❌ Lấy toàn bộ document (kể cả description dài)
var allFields = await _es.SearchAsync<Product>(s => s.MatchAll());

// ✅ Chỉ lấy fields cần hiển thị trong list
var listFields = await _es.SearchAsync<Product>(s => s
    .Source(src => src
        .Includes(i => i.Fields(
            f => f.Name,
            f => f.Price,
            f => f.Category,
            f => f.InStock
        ))
    )
    .Query(q => q.MatchAll())
);

Pagination - Search After thay vì Deep From/Size

// ❌ Deep pagination - rất chậm (from: 10000)
// ES phải fetch 10000 + size docs rồi discard

// ✅ Search After - consistent, nhanh hơn
public async Task<SearchAfterResult<Product>> SearchAfterAsync(
    string keyword,
    long[]? searchAfter = null,
    int pageSize = 20)
{
    var response = await _es.SearchAsync<Product>(s =>
    {
        s.Query(q => q
            .Bool(b => b.Must(m => m.Match(mm => mm.Field(f => f.Name).Query(keyword))))
        )
        .Size(pageSize)
        .Sort(so => so
            .Score(new ScoreSort { Order = SortOrder.Desc })
            .Field(f => f.Id, new FieldSort { Order = SortOrder.Asc }) // Tie-breaker
        );

        if (searchAfter is not null)
            s.SearchAfter(searchAfter.Select(v => (FieldValue)v).ToArray());

        return s;
    });

    var lastHit = response.Hits.LastOrDefault();
    var nextSearchAfter = lastHit?.Sort?.Select(v => (long)v).ToArray();

    return new SearchAfterResult<Product>
    {
        Items = response.Documents.ToList(),
        NextCursor = nextSearchAfter,
        HasMore = response.Documents.Count == pageSize,
    };
}

public record SearchAfterResult<T>
{
    public List<T> Items { get; init; } = [];
    public long[]? NextCursor { get; init; }
    public bool HasMore { get; init; }
}

Request Cache & Query Cache

// Bật request cache cho heavy aggregation queries
var response = await _es.SearchAsync<Product>(s => s
    .RequestCache(true) // Cache kết quả ở node level (size=0 queries)
    .Size(0)
    .Aggregations(a => a
        .Terms("categories", t => t.Field(f => f.Category))
    )
);

// Preference - cùng user luôn hit cùng shard replica (tận dụng cache)
var response2 = await _es.SearchAsync<Product>(s => s
    .Preference($"user-{userId}") // Consistent routing cho cùng user
    .Query(q => q.MatchAll())
);

Index Settings Tối ưu

// Tối ưu index cho search-heavy workload
await _es.Indices.CreateAsync<Product>("products", c => c
    .Settings(s => s
        .NumberOfShards(1)              // Bắt đầu với 1, scale sau
        .NumberOfReplicas(1)            // 1 replica cho HA
        .RefreshInterval(new Duration("5s")) // Tăng refresh interval nếu near-real-time không cần
        .Analysis(a => a               // ... analyzers
        )
    )
);

// Optimize cho sau khi full reindex (merge segments)
await _es.Indices.ForcemergeAsync("products", f => f
    .MaxNumSegments(1)   // Gộp tất cả thành 1 segment (read-only index)
);

Timeout & Circuit Breaker

// Timeout per request
var response = await _es.SearchAsync<Product>(s => s
    .Timeout("5s")          // Partial results sau 5s thay vì wait mãi
    .Query(q => q.MatchAll())
);

// Global timeout trong client settings
var settings = new ElasticsearchClientSettings(new Uri("https://localhost:9200"))
    .RequestTimeout(TimeSpan.FromSeconds(30))
    .DeadTimeout(TimeSpan.FromMinutes(1))   // Node considered dead sau bao lâu
    .MaxDeadTimeout(TimeSpan.FromMinutes(5));

Monitoring qua .NET

public class ElasticsearchHealthChecker
{
    private readonly ElasticsearchClient _es;

    public async Task<ElasticsearchHealthReport> CheckHealthAsync()
    {
        var health = await _es.Cluster.HealthAsync();
        var stats = await _es.Indices.StatsAsync("products");

        return new ElasticsearchHealthReport
        {
            ClusterStatus   = health.Status.ToString(),
            ActiveShards    = (int)health.ActiveShards,
            UnassignedShards = (int)health.UnassignedShards,
            DocumentCount   = stats.All.Total?.Docs?.Count ?? 0,
            StoreSizeBytes  = stats.All.Total?.Store?.SizeInBytes ?? 0,
        };
    }
}

// Đăng ký Health Check trong ASP.NET Core
builder.Services.AddHealthChecks()
    .Add(new HealthCheckRegistration(
        "elasticsearch",
        sp =>
        {
            var es = sp.GetRequiredService<ElasticsearchClient>();
            return new ElasticsearchHealthCheck(es);
        },
        HealthStatus.Degraded,
        new[] { "database", "search" }
    ));

public class ElasticsearchHealthCheck : IHealthCheck
{
    private readonly ElasticsearchClient _es;

    public ElasticsearchHealthCheck(ElasticsearchClient es) => _es = es;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken ct = default)
    {
        var ping = await _es.PingAsync(ct: ct);

        if (!ping.IsSuccess())
            return HealthCheckResult.Unhealthy("Elasticsearch unreachable");

        var health = await _es.Cluster.HealthAsync(ct: ct);

        return health.Status switch
        {
            HealthStatus.Green  => HealthCheckResult.Healthy("Elasticsearch healthy"),
            HealthStatus.Yellow => HealthCheckResult.Degraded("Elasticsearch degraded (yellow)"),
            HealthStatus.Red    => HealthCheckResult.Unhealthy("Elasticsearch unhealthy (red)"),
            _                   => HealthCheckResult.Unhealthy("Unknown status"),
        };
    }
}

Checklist Performance

Indexing:
□ Bulk indexing thay vì single document
□ Tắt refresh_interval trong bulk import lớn
□ ForceSegmentMerge sau reindex tĩnh
□ ScaledFloat cho tiền tệ thay vì double
□ Đừng dùng dynamic mapping trong production

Search:
□ Dùng filter thay vì must khi không cần score
□ Chỉ lấy fields cần thiết (source filtering)
□ Search After thay vì deep pagination
□ request_cache=true cho aggregation queries
□ Đặt timeout hợp lý

Cluster:
□ 1 primary shard nếu index < 50GB
□ Ít nhất 1 replica cho production
□ Heap size: 50% RAM, max 31GB
□ Monitor shard count (<1000 per node)
□ Dùng ILM để quản lý time-series data