基于 Azure Elastic Pool 与 Loki 构建 SQL Server 多租户分片的自动化管理与可观测性平面


一个增长中的多租户SaaS平台,其数据层正不可避免地走向单体SQL Server数据库的性能与容量瓶颈。垂直扩展的成本曲线愈发陡峭,而租户数据的强隔离性需求与爆炸式增长的数据量,共同指向了水平分片(Sharding)架构。然而,数据库分片的核心挑战并非数据切分本身,而在于其整个生命周-期的管理:新租户的自动化安置、跨分片的负载均衡、架构的弹性伸缩,以及最重要的——在一个由成百上千个微型数据库组成的复杂系统中,如何获得清晰、统一的可观测性。单纯依赖Azure Monitor的指标和日志,在排查与特定租户或特定操作相关的性能问题时,往往显得力不从心。

本文将记录一个架构决策过程,最终选择并实现了一套基于Azure SQL弹性池(Elastic Pool)、弹性数据库客户端库(Elastic Database Client Library)、Azure Functions以及Grafana Loki的自动化分片管理与可观测性平面。目标是构建一个不仅能自动处理租户生命周期,还能为运维和开发团队提供深度操作洞察的系统。

方案权衡:应用层手动路由 vs. 基于库的元数据管理

在技术选型初期,我们评估了两种主流的分片实现路径。

方案A:纯应用层分片逻辑与手动管理

这种方法将分片逻辑完全封装在应用代码中。应用层维护一个映射表(可能在Redis或一个独立的配置库中),记录每个TenantId对应的数据库连接字符串。

  • 优势:

    • 实现门槛低,初期开发速度快。
    • 不依赖任何特定的库或框架,技术栈完全自主可控。
  • 劣势:

    • 运维灾难: 新租户入驻需要手动(或通过临时脚本)分配数据库并更新映射表。这是一个极易出错且无法规模化的流程。
    • 僵化的拓扑: 当某个分片过热,需要进行租户迁移或分片分裂时,整个过程需要复杂的自定义脚本、数据同步和应用层映射表的原子性更新,风险极高。
    • 查询路由僵硬: 应用代码与分片拓扑紧密耦合。任何拓扑变更都可能需要应用重新部署。
    • 可观测性黑洞: 除非投入巨大精力在应用层构建详尽的日志和追踪,否则很难知道哪个租户的请求流向了哪个分片,以及操作的耗时分布。

在真实项目中,这种方案会在SaaS平台用户数超过两位数后迅速退化为技术债的核心来源。每一个运维操作都如履薄冰,系统的弹性几乎为零。

方案B:Azure弹性数据库客户端库与自定义控制平面

此方案利用Microsoft官方提供的Microsoft.Azure.SqlDatabase.ElasticScale.Client库。这个库的核心是分片映射管理器(Shard Map Manager),它本身是一个专用的SQL数据库,用于存储分片拓扑的元数据。应用通过这个库来解析TenantId并获取到目标分片的正确连接。

  • 优势:

    • 解耦: 应用代码与物理分片拓扑解耦。应用只需知道分片键(TenantId)和分片映射管理器的位置。分片的增删、租户的迁移,对应用层是透明的。
    • 成熟的工具集: 客户端库提供了数据依赖路由(Data-Dependent Routing)和多分片查询(Multi-Shard Query)等核心功能,虽然后者性能需谨慎使用。
    • 原子性操作: 库提供了用于管理分片和映射的API,这些操作具备更好的事务性保障。
  • 劣势:

    • 库本身不是万能药: 它只提供了分片管理的“SDK”,并未提供一个自动化的“服务”。租户如何智能地分配到最合适的分片?分片负载如何监控并触发迁移?这些都需要我们自行构建一个控制平面(Control Plane)
    • 学习曲线: 需要理解分片映射、范围映射(Range Mapping)、列表映射(List Mapping)等概念。

最终决策: 我们选择方案B。虽然它要求我们额外构建一个控制平面,但这正是架构的核心价值所在。将分片管理的“大脑”集中到一个独立的、自动化的服务中,而不是将其散落在应用代码或手动脚本里,是实现长期可维护性和弹性的唯一途径。Azure SQL弹性池为众多分片提供了极具成本效益的托管方案,而我们将使用Azure Functions作为这个控制平面的无服务器计算载体。

核心实现:自动化控制平面与Loki可观测性注入

我们的目标系统架构如下:

graph TD
    subgraph "Application Layer"
        WebApp[SaaS Web App]
    end

    subgraph "Control Plane (Azure Function App)"
        TenantOnboardingFunc["POST /tenants (Tenant Onboarding)"]
        SchemaMigrationFunc["POST /migrations (Schema Management)"]
        RebalancingFunc["POST /rebalance (Shard Rebalancing - Future)"]
    end



    subgraph "Data Tier on Azure"
        SMM[Shard Map Manager DB]
        subgraph "Azure SQL Elastic Pool"
            Shard01[Shard DB 1]
            Shard02[Shard DB 2]
            Shard03[Shard DB 3]
            ShardN[Shard DB N...]
        end
    end

    subgraph "Observability Plane"
        Loki[Grafana Loki]
        Grafana[Grafana Dashboard]
    end

    WebApp -- "1. Get Shard Connection" --> SMM
    WebApp -- "2. Connect to correct Shard" --> Shard02

    TenantOnboardingFunc -- "Updates Metadata" --> SMM
    TenantOnboardingFunc -- "Creates Schema" --> Shard03
    
    TenantOnboardingFunc -- "Sends Structured Logs" --> Loki
    SchemaMigrationFunc -- "Sends Structured Logs" --> Loki

    Grafana -- "Queries Logs (LogQL)" --> Loki

    linkStyle 0 stroke:#666,stroke-width:2px;
    linkStyle 1 stroke:#666,stroke-width:2px;
    linkStyle 2 stroke:#00a8ff,stroke-width:2px,stroke-dasharray: 5 5;
    linkStyle 3 stroke:#00a8ff,stroke-width:2px,stroke-dasharray: 5 5;
    linkStyle 4 stroke:#f9a825,stroke-width:2px;
    linkStyle 5 stroke:#f9a825,stroke-width:2px;
    linkStyle 6 stroke:#4caf50,stroke-width:2px;

1. 初始化分片映射管理器

这是所有工作的第一步。我们需要一个独立的SQL数据库作为分片映射管理器。然后使用客户端库的API来初始化它。

// C# - Initialization Code (can be a one-off console app)
using Microsoft.Azure.SqlDatabase.ElasticScale.ShardManagement;

// Connection string to the database that will become the Shard Map Manager
var shardMapManagerDbConnectionString = "your_smm_db_connection_string";

// Shard Map Manager store factory
var shardMapManagerFactory = ShardMapManagerFactory.GetSqlShardMapManager(
    shardMapManagerDbConnectionString,
    ShardMapManagerLoadPolicy.Lazy);

// Attempt to get the shard map. If it doesn't exist, create it.
// We use ListShardMap for mapping individual keys (TenantId) to shards.
ListShardMap<Guid> shardMap;
if (!shardMapManagerFactory.TryGetListShardMap<Guid>("SaaS_Tenant_Shard_Map", out shardMap))
{
    shardMap = shardMapManagerFactory.CreateListShardMap<Guid>("SaaS_Tenant_Shard_Map");
}

2. 构建控制平面:租户自动化入驻

我们使用一个HTTP触发的Azure Function来实现新租户的自动化入驻。这个Function是整个控制平面的核心。

它的职责包括:

  1. 决策: 选择一个最合适的分片来安置新租户。当前的策略可以很简单(例如,随机或轮询),但未来可以扩展为基于负载(如每个分片的租户数量、DTU使用率)。
  2. 更新元数据: 在分片映射管理器中创建TenantId到目标分片的映射。
  3. 准备数据库: 在目标分片数据库上为新租户创建必要的表结构。
  4. 结构化日志: 将整个过程的关键步骤、决策依据、耗时和结果以结构化JSON格式输出到Loki。
// C# - Azure Function for Tenant Onboarding
// Dependencies: Microsoft.Azure.SqlDatabase.ElasticScale.Client, Serilog, Serilog.Sinks.Loki

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Microsoft.Azure.SqlDatabase.ElasticScale.ShardManagement;
using System.Diagnostics;
using Serilog.Context;

public static class TenantOnboardingFunction
{
    private static readonly ILogger _log = InitializeLogger(); // Serilog logger configured for Loki

    [FunctionName("OnboardNewTenant")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "tenants")] HttpRequest req)
    {
        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        Guid tenantId = data?.tenantId;

        if (tenantId == Guid.Empty)
        {
            return new BadRequestObjectResult("Please provide a valid 'tenantId'.");
        }
        
        // Use Serilog's LogContext to enrich all log messages within this scope
        using (LogContext.PushProperty("TenantId", tenantId))
        using (LogContext.PushProperty("Operation", "TenantOnboarding"))
        {
            var stopwatch = Stopwatch.StartNew();
            _log.LogInformation("Starting onboarding process for tenant.");

            try
            {
                var shardMapManager = GetShardMapManager();
                var shardMap = shardMapManager.GetListShardMap<Guid>("SaaS_Tenant_Shard_Map");

                // 1. Decision Making: Select a shard
                // In a real project, this logic would be more complex, checking shard load, etc.
                // Here, we use a simple round-robin for demonstration.
                var shards = shardMap.GetShards();
                if (!shards.Any())
                {
                    _log.LogError("No shards are registered in the Shard Map Manager.");
                    return new StatusCodeResult(500);
                }
                
                var tenantCounts = await GetTenantCountsPerShard(shardMap);
                var targetShard = tenantCounts.OrderBy(kvp => kvp.Value).First().Key;

                _log.LogInformation("Decision: Tenant will be placed on Shard {ShardLocation} with {TenantCount} existing tenants.", targetShard.Location.Database, tenantCounts[targetShard]);

                // 2. Update Metadata: Create the mapping
                PointMapping<Guid> mapping;
                if (shardMap.TryGetMappingForKey(tenantId, out mapping))
                {
                    _log.LogWarning("Tenant already has a mapping to Shard {ShardLocation}. Onboarding skipped.", mapping.Shard.Location.Database);
                    return new ConflictObjectResult($"Tenant '{tenantId}' already exists.");
                }

                mapping = shardMap.CreatePointMapping(tenantId, targetShard);
                _log.LogInformation("Successfully created point mapping in Shard Map Manager.");

                // 3. Prepare Database: Execute schema creation script
                await CreateTenantSchemaInShard(targetShard, tenantId);

                stopwatch.Stop();
                _log.LogInformation("Onboarding process completed successfully in {DurationMs} ms.", stopwatch.ElapsedMilliseconds);
                
                // Return the location for the client
                return new OkObjectResult(new {
                    tenantId = tenantId,
                    shardLocation = targetShard.Location.Database
                });
            }
            catch (Exception ex)
            {
                stopwatch.Stop();
                // This is a critical log message for observability
                _log.LogError(ex, "Onboarding process failed after {DurationMs} ms. Error: {ErrorMessage}", stopwatch.ElapsedMilliseconds, ex.Message);
                return new StatusCodeResult(500);
            }
        }
    }
    
    // Helper methods (GetShardMapManager, CreateTenantSchemaInShard, etc.) would be defined here.
    // They handle connection strings, retry logic, and other boilerplate.
    private static async Task CreateTenantSchemaInShard(Shard shard, Guid tenantId)
    {
        // This method connects to the target shard and runs DDL statements.
        // In a production system, this should be an idempotent operation.
        // It would use a tool like DbUp or EF Migrations to apply a schema.
        // For simplicity, we just log the action.
        _log.LogInformation("Executing schema creation for Tenant on Shard {ShardLocation}.", shard.Location.Database);
        await Task.Delay(150); // Simulate DB work
        _log.LogInformation("Schema creation completed.");
    }

    // This is where the magic of observability begins.
    private static ILogger InitializeLogger()
    {
        // In Azure Function's Startup.cs or equivalent
        var logger = new LoggerConfiguration()
            .Enrich.FromLogContext()
            .WriteTo.Console() // For local debugging
            .WriteTo.LokiHttp("http://your-loki-instance:3100")
            .CreateLogger();
        // Return a Microsoft.Extensions.Logging.ILogger adapter
        // ...
    }
}

注意代码中的日志记录方式。我们不再使用_log.LogInformation($"Processing tenant {tenantId}")这样的字符串插值,而是使用_log.LogInformation("Processing tenant {TenantId}", tenantId)这样的结构化日志模板。Serilog和Loki Sink会自动将TenantId作为一个独立的、可索引的字段。同时,通过LogContext, 我们为整个操作范围内的所有日志都自动附加了TenantIdOperation标签。这是实现强大查询能力的基础。

3. 应用层的数据依赖路由

应用层现在不再需要管理连接字符串列表。它只需要知道TenantId和分片映射管理器的位置。

// C# - Application code to get a sharded connection

public class TenantRepository
{
    private readonly ListShardMap<Guid> _shardMap;

    public TenantRepository(ShardMapManager shardMapManager)
    {
        // ShardMap is retrieved once and cached.
        _shardMap = shardMapManager.GetListShardMap<Guid>("SaaS_Tenant_Shard_Map");
    }

    public async Task<string> GetDataForTenant(Guid tenantId)
    {
        // This is the core of Data-Dependent Routing.
        // The library handles the lookup in the Shard Map Manager.
        // A common mistake is to do this for every query. The mapping information
        // is cached by the client library, but the initial connection to the SMM
        // should be managed carefully.
        try
        {
            using (var connection = await _shardMap.OpenConnectionForKeyAsync(tenantId, "your_shard_user_credentials"))
            {
                // Now 'connection' is a regular SqlConnection pointing to the correct shard DB.
                var command = connection.CreateCommand();
                command.CommandText = "SELECT Data FROM TenantSpecificTable WHERE TenantId = @TenantId";
                command.Parameters.AddWithValue("@TenantId", tenantId);
                
                return (await command.ExecuteScalarAsync())?.ToString();
            }
        }
        catch (ShardManagementException ex)
        {
            // This can happen if the mapping for the tenant does not exist.
            // Handle this gracefully.
            throw new TenantNotFoundException($"No mapping found for tenant {tenantId}.", ex);
        }
        catch (Exception ex)
        {
            // Handle transient SQL connection errors with a retry policy (e.g., Polly).
            throw;
        }
    }
}

这里的关键是_shardMap.OpenConnectionForKeyAsync。它透明地完成了以下工作:

  1. 连接到分片映射管理器(如果缓存中没有映射信息)。
  2. 查询TenantId对应的分片位置。
  3. 返回一个指向该物理分片数据库的SqlConnection对象。
    应用代码完全与数据库的物理位置解耦。

4. Loki的可观测性实践

当控制平面的结构化日志流入Loki后,我们就获得了强大的诊断能力。Loki的查询语言LogQL,允许我们像使用Prometheus查询指标一样查询日志。

场景1:监控新租户入驻成功率和耗时

我们可以在Grafana中创建一个仪表盘,展示租户入驻的实时情况。

  • 入驻成功率 (过去1小时):
(
  sum(rate({job="azure-functions", Operation="TenantOnboarding"} | json | level="Information" | logfmt | message="Onboarding process completed successfully" [1m]))
  /
  sum(rate({job="azure-functions", Operation="TenantOnboarding"} [1m]))
) * 100

这个查询计算了“成功”日志消息的速率与该操作所有日志消息速率的比值。

  • P95入驻延迟:
histogram_quantile(0.95, sum(rate(
    {job="azure-functions", Operation="TenantOnboarding"} | json | unwrap duration_ms [1m]
)) by (le))

这依赖于将操作耗时duration_ms记录下来。我们可以看到95%的租户入驻请求在多长时间内完成。

场景2:排查特定租户的失败问题

当一个客户报告他的账户创建失败时,我们不再需要在成千上万条日志中大海捞针。

{job="azure-functions", TenantId="the_failed_tenant_id"}

这条简单的查询会立即返回与该租户相关的所有日志,无论它们是信息、警告还是错误,按照时间顺序排列,清晰地展示了整个失败的操作流程。

场景3:分析分片负载不均

我们的控制平面在选择分片时记录了其决策。我们可以利用这些日志来分析分片的分配情况。

sum by (shard) (count_over_time({job="azure-functions", Operation="TenantOnboarding"} | json | message="Decision: Tenant will be placed on Shard" | regexp `Shard (?P<shard>\S+)` [24h]))

这个查询使用regexp从日志消息中提取出被选中的分片名称,并按分片统计过去24小时内新租户的分配数量。如果发现某个分片的租户数量远超其他分片,就说明我们的分配策略需要调整。

架构的扩展性与局限性

这套架构解决了自动化管理和可观测性的核心问题,但它并非终点。

  • 局限性 - 跨分片查询: 弹性数据库客户端库虽然提供了MultiShardQuery,但它的性能非常有限,并且会给应用层带来极大的复杂性。设计时应遵循的核心原则是:尽可能避免需要跨租户(即跨分片)的查询。如果业务场景确实需要,通常会引入一个独立的、聚合了所有分片数据的分析型数据库(如Azure Synapse Analytics)。

  • 局限性 - 分片分裂与合并: 当前的控制平面只处理了租户的“出生”,没有处理“搬家”。当一个分片因为租户数据增长或租户数量过多而变得过热时,需要执行分片分裂或租户迁移。这是一个远比入驻复杂的操作,涉及到数据迁移、分片映射的原子更新、以及保证迁移过程中的服务可用性。ElasticScale.Client库提供了Split-Merge工具,但将其集成到自动化的控制平面中是下一阶段的重大挑战。

  • 局限性 - 模式(Schema)演进: 当应用需要迭代,数据库模式需要变更时,如何将变更同步应用到成百上千个分片数据库上是一个严峻的工程问题。这通常需要一个独立的、具备工作流编排能力的部署工具,可以逐个、灰度地将变更应用到所有分片,并具备失败回滚能力。

  • 未来展望 - 智能化的控制平面: 当前的租户放置策略还很初级。未来的控制平面应该集成来自Azure Monitor的DTU利用率、存储空间等实时指标,以及来自Loki的业务操作指标(如特定租户的API调用频率),构建一个更智能的调度器,实现真正基于负载的动态资源分配和租户安置。


  目录