如何设计一个生产级 Doris 数据录入组件

如何设计一个生产级 Doris 数据录入组件:从连接池到可观测性的全链路实践

在大数据开发中,Apache Doris 凭借其极致的查询性能和标准 MySQL 协议,成为很多企业实时数仓的存储底座。而将业务数据可靠、高效地写入 Doris,往往是整个数据管道的第一个关键环节。Doris 提供了一种高性能的 HTTP 写入协议 ------ Stream Load,但要用好它并不只是发个 PUT 请求那么简单。连接泄漏、网络抖动、FE 重定向、幂等性、资源生命周期等问题都会在生产环境中被放大。

本文将基于我们团队在实际项目中沉淀的 DorisHelper 组件,从设计视角完整拆解如何封装一个"写得快、写得稳、好运维"的 Doris 数据录入工具。无论你是否使用 Spring 生态,其中的设计理念和实现技巧都值得参考。


1. 设计目标:不只是能写,更要写得稳

在设计之前,我们首先明确了组件的核心目标:

  • 高可靠:在面对网络超时、服务重启、FE/BE 切换等异常时,数据不丢失、不重复。
  • 高性能:复用 HTTP 连接,避免频繁 TCP 三次握手,支撑大批量实时写入。
  • 易运维:暴露关键指标,支持动态配置,故障快速定位。
  • 业务完整:覆盖数据交换中常见的"写入 + 删除"场景,提供统一的 API。

围绕这些目标,我们逐步构建了具备连接池管理、空闲自动回收、幂等重试、307 重定向处理、事务性删除以及可观测性 能力的 DorisHelper


2. 整体架构与依赖

DorisHelper 是一个 Spring 管理的 @Component,深度融入 Spring 生态:

  • 通过 @Value 注入外部配置,实现参数可配;
  • 通过 @Autowired 引入 JdbcTemplate(配合动态数据源 @DS)实现 JDBC 删除和查询;
  • 通过 @PostConstruct / @PreDestroy 管理资源生命周期;
  • 可选集成 Micrometer MeterRegistry,实现监控埋点。

这种架构让组件从"一把梭"的工具类升级为"可管理"的基础服务,同时避免了传统工具类需要手动传参和关闭的麻烦。


3. 连接管理:从"即用即建"到连接池复用

HTTP 连接是宝贵的系统资源。如果每次 Stream Load 都新建一个 HttpClient 实例并创建 TCP 连接,不仅性能低下,还容易在连接数爆增后出现端口耗尽或 NoHttpResponseException(服务端关闭了空闲连接)。

3.1 连接池设计

我们使用了 Apache HttpClient 的 PoolingHttpClientConnectionManager,并根据 Doris 的部署规模设定合理的连接池参数:

  • maxTotalConnections:最大总连接数,通常与 BE 节点数或并发度匹配;
  • maxPerRoute:单路由最大连接数,防止某个 BE 被过度连接;
  • validateAfterInactivity:连接空闲超过该阈值后,复用前会先发送校验报文,避免拿到已被服务端单边关闭的死连接;
  • evictIdleConnections / evictExpiredConnections:定时驱逐空闲和过期连接;
  • connectionTimeToLive:限制单个连接的最大存活时间,避免被网络中间设备强制关闭。
java 复制代码
PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager();
connManager.setMaxTotal(maxTotalConnections);
connManager.setDefaultMaxPerRoute(maxPerRoute);
connManager.setValidateAfterInactivity(validateAfterInactivityMs);

httpClient = HttpClients.custom()
    .setConnectionManager(connManager)
    .setDefaultRequestConfig(requestConfig)
    .evictIdleConnections(60, TimeUnit.SECONDS)
    .evictExpiredConnections()
    .setConnectionTimeToLive(connTtlMinutes, TimeUnit.MINUTES)
    .build();

3.2 懒加载与空闲回收

为了在流量波谷时释放资源,我们设计了 "按需创建 + 空闲自动销毁" 的懒加载模式:

  • HttpClient 在首次实际写入时才创建(getOrCreateHttpClient);
  • 通过一个守护线程定时检查 lastActiveTime,如果超过 15 分钟无任何写入操作,则主动 close 当前 HttpClient,下次写入时再自动重建;
  • 使用 volatile + 双重检查锁保证线程安全。
java 复制代码
private void checkIdleAndClose() {
    long idle = System.currentTimeMillis() - lastActiveTime;
    if (idle > idleTimeoutMs && httpClient != null) {
        synchronized (lock) {
            if (httpClient != null) {
                httpClient.close();
                httpClient = null;
            }
        }
    }
}

这样的设计让 HttpClient 的生命周期对业务完全透明,既避免了长期占用资源,又不会在高峰期反复创建连接。


4. 幂等性:让重试"无副作用"

在网络不可靠的分布式环境中,重试是必然的。但重试最大的风险是数据重复 。Doris Stream Load 本身提供了基于 label 的幂等写入能力:相同 label 的请求只会被成功执行一次,后续重复请求会直接返回 Label Already Exists

因此,label 的生成策略至关重要。

4.1 避免随机 label

早期很多实现使用 System.currentTimeMillis() + random 生成 label,这会导致重试时生成新 label,如果第一次请求实际已成功但响应丢失,第二次重试就会写入重复数据。

4.2 基于内容指纹的确定性 label

我们改为对请求体内容计算 MD5 作为 label 的一部分:

java 复制代码
String label = "update_" + table + "_" +
    DigestUtils.md5DigestAsHex(jsonPayload.getBytes(StandardCharsets.UTF_8));

同一批数据,无论重试多少次,label 都完全一致。Doris 在收到重复 label 请求时会直接返回成功,并保证数据不会重复写入。通过这种方式,我们将"至少一次"语义安全地转化为"精确一次"效果,让应用层重试变得毫无负担。


5. 分层重试:连接层 + 应用层联合防守

我们将重试拆分为两个独立的层级,各司其职:

5.1 连接层重试(HttpRequestRetryHandler

仅对 NoHttpResponseException(服务端单边关闭连接、完全无响应)进行重试,默认最多 3 次。这类异常发生在请求尚未被服务端处理时,重试是安全且必要的。对于协议错误、SSL 异常等直接失败,不重试。

java 复制代码
if (exception instanceof NoHttpResponseException) {
    log.warn("NoHttpResponse retry {}/{}", executionCount, maxRetry);
    return true;
}
return false;

5.2 应用层重试

batchInsertOrUpdate 中增加了循环重试逻辑,处理两类错误:

  • IOException(网络超时等)
  • HTTP 5xx 服务端错误(Doris 短暂过载)

重试次数、退避时间均通过 @Value 注入,默认 3 次,间隔 1 秒。重试过程中严格复用相同的 label,结合前文的幂等性设计,即使重试多次也绝不会产生重复数据。

java 复制代码
for (int attempt = 1; attempt <= streamLoadMaxRetries; attempt++) {
    try {
        sendStreamLoad(...);
        return;
    } catch (IOException e) {
        if (attempt < streamLoadMaxRetries) {
            TimeUnit.MILLISECONDS.sleep(retryBackoffMs);
        } else {
            throw e;
        }
    }
}

设计原则:连接层只重试明确安全的无响应异常,应用层控制业务级重试并自带退避,两层协作在提升成功率的同时不扩大风险。


6. Stream Load 307 重定向的正确处理

Doris Stream Load 的标准流程是:客户端 → FE → FE 返回 307 → 客户端重定向到 BE 完成实际写入。

Apache HttpClient 默认不会自动跟随带 Body 的 PUT 请求的 307 重定向(即使开启也可能降级为 GET 丢失 Body)。因此我们必须手动处理这个"二次请求":

  1. 检查响应是否为 307;
  2. 提取 Location 头获得 BE 地址;
  3. 消费原响应实体,关闭响应(避免连接泄漏);
  4. 完全相同的 headers(包含 label)、body 向 BE 重新发起 PUT 请求。

我们将这个逻辑封装在 sendStreamLoad 方法中,并将请求构造抽象为 buildStreamLoadRequest,确保 FE 和 BE 两段复用同一套构造逻辑,保证了幂等性和可维护性。


7. 业务完整性:事务性 JDBC 删除

Stream Load 不支持 DELETE 操作,我们通过 JDBC(MySQL 协议)来执行批量删除。数据交换中常见的场景是:根据上游变更,先删除旧的记录,再写入新数据。

为了避免部分批次删除失败导致数据不一致,我们在 deleteData 方法上添加了 @Transactional(rollbackFor = Exception.class),结合 Spring 事务管理,确保一个方法内的多批 DELETE 要么全部成功,要么全部回滚 。同时使用 @DS 注解动态切换数据源,与写入共用同一 Doris 集群的 JDBC 连接。

java 复制代码
@DS(DynDataSourceConstants.DC)
@Transactional(rollbackFor = Exception.class)
public void deleteData(String fullTableName, List<String> ids, String keyField) {
    // 分批删除,异常抛出触发回滚
}

这使得数据交换的"删+写"动作可以安全地放在同一个业务编排中,无需额外补偿逻辑。


8. 可观测性:让黑盒变白盒

没有指标的工具在线上就是"盲人摸象"。我们通过可选的 Micrometer 集成,埋点了四个核心指标:

  • doris.load.success:写入成功次数
  • doris.load.failure:写入失败次数
  • doris.load.retries:应用层重试次数
  • doris.load.duration:写入耗时分布(Timer)

借助 Spring Boot Actuator + Prometheus + Grafana,我们可以实时监控每张表的写入 QPS、延迟、成功率和重试趋势,告警规则也随之建立起来。

java 复制代码
// 指标记录示例
loadSuccessCounter.increment();
Timer.Sample sample = Timer.start(meterRegistry);
// ... 执行写入
sample.stop(loadTimer);

9. 可配置化:一份代码适配所有环境

硬编码是生产环境的大敌。我们将所有关键参数抽离为 @Value 注入的配置项,并设定了合理的默认值:

yaml 复制代码
doris:
  helper:
    idle-timeout-ms: 900000          # 空闲超时 15 分钟
    monitor-interval-ms: 300000      # 空闲检查间隔 5 分钟
    stream-load-max-retries: 3       # 应用层重试次数
    stream-load-retry-backoff-ms: 1000
    max-total-connections: 5
    connect-timeout-ms: 5000
    socket-timeout-ms: 60000
    # ...

开发、测试、生产环境只需调整配置文件,无需修改一行代码。


10. 其他设计亮点

  • 守护线程监控 :空闲检查线程设置为 daemon,不会阻止 JVM 退出。
  • record 重试处理器 :使用 Java 17 的 record 简化重试处理器的实现,代码更简洁。
  • 代码分层清晰getOrCreateHttpClientbuildStreamLoadRequestbatchInsertOrUpdatesendStreamLoadhandleStreamLoadResponse 各司其职,注释详尽,新成员也能快速上手。

结语

回顾整个 DorisHelper 的设计,我们并没有发明新的协议,而是将 Doris 的最佳实践与 Java 生态中成熟的技术(连接池、事务、监控、Spring 生命周期)有机地组合在一起,形成了一套安全、高效、可观测的数据录入方案。

如果你也在为 Doris 构建数据入口,不妨从以下几个方面审视你的工具类:

  • 连接是否复用?能否自动回收?
  • 重试是否安全?label 是否幂等?
  • 307 重定向是否正确处理?
  • 异常和性能是否可观测?
  • 配置是否集中、可调整?

当这些问题都有了清晰的答案时,你的 Doris 写入组件也就真正具备了生产级的能力。希望本文的分享能为你的实践带来一些启发。