← 返回

数据开放平台架构设计(六):落地实践与运维

上一篇: 数据开放平台(五):监控与治理

前面五篇聊了架构、引擎、鉴权、转换、监控,都是"怎么设计"。这一篇聊"怎么落地",接入流程怎么走、SDK 和文档怎么生成、上线怎么灰度、出了问题怎么兜底。

我自己的经验是,数据开放平台成不成,末尾不只看代码写得多漂亮,更看流程有没有闭环:申请、审批、联调、发布、监控、回滚、下线,每一步都要有人负责、有记录可查。


一、接入流程

一个新调用方从注册到正式使用,标准流程分四步:

接入流程要把审批、联调、发布、观察放进同一条链路,避免接口上线后没人认领:

flowchart LR A["应用申请"] --> B["权限审批"] B --> C["AK/SK 发放"] C --> D["Sandbox 联调"] D --> E["API 配置评审"] E --> F["灰度发布"] F --> G["监控观察"] G --> H{"指标达标"} H -- "是" --> I["全量发布"] H -- "否" --> J["回滚"] I --> K["定期复盘"] J --> K

第一步:申请接入

调用方在管理后台提交接入申请,填写:应用名称、负责人、使用场景、预期 QPS、需要访问的数据范围。审批通过后,系统自动生成 AK/SK 并分配配额。

@Service
public class AppRegistrationService {

    @Transactional
    public AppInfo register(AppRegistrationRequest request) {
        // 1. 生成 AK/SK
        String accessKey = "ak_" + UUID.randomUUID().toString().replace("-", "");
        String secretKey = "sk_" + UUID.randomUUID().toString().replace("-", "");

        // 2. 保存应用信息
        AppInfo app = AppInfo.builder()
            .appName(request.getAppName())
            .accessKey(accessKey)
            .secretKey(encrypt(secretKey)) // SK 加密存储
            .owner(request.getOwner())
            .status(AppStatus.PENDING)
            .dailyQuota(request.getDailyQuota() != null ?
                request.getDailyQuota() : 10000)
            .qpsLimit(request.getQpsLimit() != null ?
                request.getQpsLimit() : 100)
            .build();
        appMapper.insert(app);

        // 3. 发送审批通知
        notifyAdmin("新接入申请: " + request.getAppName(), app);

        return app;
    }
}

SK 只在创建时展示一次,后续后台只能重置不能查看。这个规则一定要写进产品交互里,否则密钥加密存储就只做了一半。

第二步:配置 API

审批通过后,调用方(或平台管理员)在界面上配置 API。填写 SQL 模板、参数规则、数据源、转换规则、鉴权方式。配置完可以在线测试,确认没问题再发布。

第三步:联调测试

调用方拿到 AK/SK 后,在测试环境联调。平台提供 Sandbox 环境,数据是脱敏的,不影响生产。联调期间可以:

  • 用 SDK 发请求,验证签名是否正确
  • 在线测试页面填参数,看返回结果
  • 查看调用链追踪,确认每步执行是否符合预期
  • 模拟异常场景(超时、限流、参数错误),验证调用方的容错处理

第四步:上线发布

联调通过后,发布 API 到生产环境。支持灰度发布(见下文)。


二、SDK 与文档生成

自动 SDK 生成

每个 API 配置完后,自动生成多语言 SDK 调用代码。调用方不用自己拼签名、构造请求,直接复制代码就能用。

@Component
public class SdkGenerator {

    /**
     * 生成 Java SDK 代码
     */
    public String generateJava(SqlApiConfig config) {
        String methodName = toCamelCase(config.getApiPath()
            .replace("/", "_").replace("-", "_"));

        return String.format("""
            /**
             * %s
             * %s
             */
            public Map<String, Object> %s(%s) {
                Map<String, Object> params = new HashMap<>();
                %s
                return client.call("%s", "%s", params);
            }
            """,
            config.getApiName(),
            config.getDescription(),
            methodName,
            generateParamList(config),
            generateParamPutStatements(config),
            config.getHttpMethod(),
            config.getApiPath()
        );
    }

    /**
     * 生成 cURL 命令
     */
    public String generateCurl(SqlApiConfig config) {
        StringBuilder curl = new StringBuilder();
        curl.append("curl -X ").append(config.getHttpMethod());
        curl.append(" 'https://api.example.com").append(config.getApiPath());

        if ("GET".equalsIgnoreCase(config.getHttpMethod())) {
            curl.append("?pageNum=1&pageSize=10");
        }
        curl.append("'");

        curl.append(" \\\n  -H 'X-Access-Key: {your-ak}'");
        curl.append(" \\\n  -H 'X-Timestamp: {timestamp}'");
        curl.append(" \\\n  -H 'X-Nonce: {nonce}'");
        curl.append(" \\\n  -H 'X-Signature: {signature}'");

        if ("POST".equalsIgnoreCase(config.getHttpMethod())) {
            curl.append(" \\\n  -H 'Content-Type: application/json'");
            curl.append(" \\\n  -d '{\"pageNum\":1,\"pageSize\":10}'");
        }

        return curl.toString();
    }
}

自动文档生成

每个 API 自动生成接口文档,类似 Swagger UI,但信息更丰富:

@Service
public class ApiDocService {

    public ApiDoc generateDoc(SqlApiConfig config) {
        ApiDoc doc = new ApiDoc();
        doc.setApiName(config.getApiName());
        doc.setDescription(config.getDescription());
        doc.setMethod(config.getHttpMethod());
        doc.setPath(config.getApiPath());

        // 参数文档(从 SQL 模板自动提取)
        List<ParamDoc> params = extractParamDocs(config);
        doc.setParams(params);

        // 响应示例(从最近一次测试结果获取)
        doc.setResponseExample(getLatestResponseExample(config.getApiId()));

        // 认证说明
        doc.setAuthRequired(config.getAuthRequired());
        doc.setAuthType(config.getAuthType());

        // 限流说明
        doc.setRateLimit(getRateLimitInfo(config.getApiId()));

        // SDK 代码示例
        doc.setJavaSdk(sdkGenerator.generateJava(config));
        doc.setCurlExample(sdkGenerator.generateCurl(config));
        doc.setPythonSdk(sdkGenerator.generatePython(config));

        return doc;
    }
}

文档页面支持"一键复制"和"在线测试",调用方拿到文档就能开发,不用来回沟通接口细节。


三、灰度发布

新 API 或 API 变更直接全量上线风险太大。灰度发布先让小部分流量走新版本,没问题再全量。对数据接口来说,灰度不只看有没有报错,还要看数据口径是否一致。

发布与回滚要围绕配置版本推进,灰度路由只读取明确发布的快照:

sequenceDiagram participant Admin as 管理员 participant Config as 配置中心 participant Router as 灰度路由 participant Client as 调用方 participant Metrics as 指标系统 participant Audit as 审计库 Admin->>Config: 提交新版本 Config->>Config: 保存草稿并生成版本号 Admin->>Config: 发起灰度 Config->>Router: 下发灰度快照 Client->>Router: 请求命中新版本 Router->>Metrics: 上报成功率与耗时 Metrics-->>Admin: 指标对比 Admin->>Config: 晋升或回滚 Config->>Audit: 记录发布结果

灰度策略

@Data
public class GrayReleaseConfig {
    private String apiId;
    private String newVersionSql;       // 新版本 SQL 模板
    private GrayStrategy strategy;      // 灰度策略
    private double trafficRatio;        // 流量比例(0-100)
    private List<String> whitelistApps; // 白名单调用方
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private boolean autoPromote;        // 自动晋升为正式版本
}

三种灰度策略:

按比例灰度:随机分配一定比例的流量到新版本。

@Component
public class GrayRouter {

    private final Map<String, GrayReleaseConfig> grayConfigs
        = new ConcurrentHashMap<>();

    /**
     * 路由请求到新版本或旧版本
     */
    public SqlApiConfig route(String apiId, String appKey) {
        GrayReleaseConfig gray = grayConfigs.get(apiId);
        if (gray == null) {
            return getStableConfig(apiId);
        }

        // 白名单优先
        if (gray.getWhitelistApps() != null
            && gray.getWhitelistApps().contains(appKey)) {
            return getGrayConfig(apiId);
        }

        // 按比例分流
        if (RandomUtils.nextDouble(0, 100) < gray.getTrafficRatio()) {
            return getGrayConfig(apiId);
        }

        return getStableConfig(apiId);
    }
}

按调用方灰度:指定哪些调用方走新版本,其他走旧版本。适合定向邀请调用方验证。

按参数灰度:满足特定参数条件的请求走新版本。比如"城市=北京"的请求走新版本,其他走旧版本。

灰度监控

灰度期间重点监控新版本的指标:

  • 错误率:新版本错误率是否高于旧版本
  • 耗时:新版本 P99 耗时是否显著增加
  • 数据一致性:新旧版本返回结果是否一致(对比模式)
@Component
public class GrayMonitor {

    /**
     * 对比新旧版本的返回结果
     */
    public void compare(String apiId, Map<String, Object> params,
                         Object stableResult, Object grayResult) {
        boolean consistent = Objects.equals(
            JSONUtil.toJsonStr(stableResult),
            JSONUtil.toJsonStr(grayResult)
        );

        if (!consistent) {
            log.warn("灰度版本结果不一致 apiId={} params={}", apiId, params);
            Metrics.counter("gray_mismatch", "apiId", apiId).increment();
        }

        Metrics.counter("gray_compare_total",
            "apiId", apiId,
            "consistent", String.valueOf(consistent)
        ).increment();
    }
}

灰度期间如果发现异常,一键回滚到旧版本:

@DeleteMapping("/{apiId}/gray")
public R<Void> rollbackGray(@PathVariable String apiId) {
    grayConfigService.rollback(apiId);
    grayRouter.refresh();
    return R.ok();
}

四、容灾备份

数据开放平台挂了,所有依赖它的调用方都受影响。容灾方案分三层:

第一层:服务高可用

多实例部署,前面挂 Nginx、Ingress 或云负载均衡。单实例挂了不影响服务,配置和会话状态不要放在本机内存里。

flowchart TB Client["调用方"] --> LB["负载均衡"] LB --> A1["平台实例 1"] LB --> A2["平台实例 2"] LB --> A3["平台实例 3"] A1 --> DBP["MySQL 主库"] A2 --> DBP A3 --> DBP DBP --> DBR["MySQL 只读副本"] A1 --> Redis["Redis 高可用集群"] A2 --> Redis A3 --> Redis

第二层:缓存降级

Redis 挂了不能让所有请求都打到数据库。缓存降级策略:

@Component
public class CacheFallbackHandler {

    /**
     * 带降级的缓存读取
     */
    public <T> T getWithFallback(String key, Supplier<T> dbQuery,
                                  long cacheTtl) {
        try {
            // 先查缓存
            T cached = redisTemplate.opsForValue().get(key);
            if (cached != null) {
                return cached;
            }
        } catch (Exception e) {
            // Redis 异常,降级到直接查 DB
            log.warn("缓存降级: Redis 不可用,直接查数据库", e);
            Metrics.counter("cache_fallback_total").increment();
        }

        // 查数据库
        T result = dbQuery.get();

        try {
            // 回写缓存
            redisTemplate.opsForValue().set(key, result, cacheTtl,
                TimeUnit.SECONDS);
        } catch (Exception e) {
            // 写缓存失败不影响返回
            log.warn("缓存写入失败", e);
        }

        return result;
    }
}

第三层:数据库故障转移

数据源连接失败时,自动切换到备用数据源:

@Component
public class DataSourceFailover {

    /**
     * 带故障转移的数据源获取
     */
    public DataSource getDataSourceWithFailover(String name) {
        try {
            DataSource primary = routingDataSource.getDataSource(name);
            // 健康检查
            primary.getConnection().close();
            return primary;
        } catch (Exception e) {
            log.error("主数据源不可用: {},尝试备用数据源", name, e);
            Metrics.counter("ds_failover_total", "datasource", name).increment();

            DataSource fallback = routingDataSource.getFallbackDataSource(name);
            if (fallback == null) {
                throw new BusinessException("数据源不可用且无备用数据源: " + name);
            }
            return fallback;
        }
    }
}

限流兜底

极端情况下(比如调用方突发大量请求),限流是更直接的保护手段。限流分三级:

  • 应用级:每个调用方独立限流
  • API 级:单个 API 独立限流
  • 全局级:平台整体限流

超限时返回 429 并带上 Retry-After 头,告诉调用方多久后重试。


五、运维 Checklist

总结一下上线后需要关注的运维事项:

日常巡检(每天)

  • 检查 Grafana 仪表盘,有无异常指标
  • 查看告警列表,处理未解决的告警
  • 检查慢查询 TOP10,评估是否需要优化

定期维护(每周)

  • 清理过期的审计日志(保留 90 天)
  • 检查数据源连接池状态
  • 更新限流和配额配置
  • 审查新增 API 的 SQL 安全性

月度复盘(每月)

  • API 调用量趋势分析
  • 各调用方用量统计
  • 慢查询优化成果回顾
  • 平台资源使用率评估

六、踩过的坑

末尾分享几个实际落地中踩过的坑:

坑一:SQL 模板里的分号。 用户写 SQL 习惯在末尾加分号,但 JdbcTemplate 执行带分号的 SQL 在某些数据库驱动下会报错。解决方案:解析时自动去掉末尾分号。

坑二:大结果集 OOM。 有人写了个没有 LIMIT 的查询,返回了 100 万行数据,直接把服务打 OOM 了。解决方案:默认加 LIMIT 10000,超过需要特殊申请。

坑三:连接池泄漏。 动态数据源切换时,如果异常路径上没有清理 ThreadLocal,会导致连接池连接被占用不释放。解决方案:用 try-finally 保证 DataSourceContextHolder.clear() 一定执行。

坑四:配置缓存不一致。 多实例部署时,修改配置后只有当前实例刷新了路由表,其他实例还是旧的。解决方案:配置变更时通过 Redis Pub/Sub 通知所有实例刷新。

坑五:调用方机器时间不准。 AK/SK 签名依赖时间戳,调用方服务器时间漂移太大时会一直验签失败。解决方案:错误信息里明确提示时间偏差,SDK 里也可以暴露本地时间校验。


系列回顾

六篇文章,从 SQL2API 到数据开放平台,完整走了一遍:

  1. 架构设计,从工具到平台的演进思路
  2. SQL 转 API 引擎,核心能力,运行时执行任意 SQL
  3. 鉴权与访问控制,AK/SK 认证、租户隔离、流量控制
  4. 数据转换与加工,字段映射、脱敏、格式转换、聚合
  5. 监控与治理,调用链、慢查询告警、数据血缘、审计
  6. 落地实践与运维,接入流程、SDK 生成、灰度发布、容灾

核心思想就一句话:配置驱动,减少重复,让数据变成服务。 但配置驱动不等于没人负责。每个 API 都应该有负责人、权限边界、监控指标和下线机制,这样平台才能长期跑下去。


上一篇: 数据开放平台(五):监控与治理