ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TDengine异地多活架构实战:从副本同步到故障切换

TDengine异地多活架构实战:从副本同步到故障切换

TDengine 的异地多活方案,是我近半年反复推演和压测最多的一类架构。起因并不复杂:我们运营的设备监控平台把几十万测点的数据全部集中到一个集群,某个机房的光缆一断,整个写入链路就会立刻报警,业务侧只能临时降级,而“降级”这个词在运维值班沟通里,往往意味着半夜加班。

一开始我以为“异地多活”就是把 TDengine 的副本数调大、把 dnode 分散到两个机房就完事了。真正推演之后才发现,这条路在跨地域场景下几乎走不通。这篇文章不打算只讲概念,而是把我们实际选型、拓扑设计、故障切换和长期治理的经验整理出来,给想用 TDengine 构建高可靠的时序数据中心的团队一个可落地的参考。

1. 为什么一家做设备监控的团队会盯上“异地多活”

1.1 先回答:多活和备份的本质差异

很多团队把“多活”和“备份”混在一起,这是第一个要纠正的认知偏差。备份是保留一份数据副本,平时不对外服务,故障发生后再想办法恢复;多活则是多个数据中心同时承担读写,任何一个点故障时,访问路径可以在分钟级甚至秒级切换到另一个点,业务基本无感。

我们的设备监控平台是典型的时序场景:设备侧采集器不断上报电压、电流、温度、运行状态等遥测数据,每秒峰值写入超过十万条测点。这些数据有三个明显特点:写入不能随便暂停、时间轴连续不能断、业务对缺失窗口的容忍上限通常只有几分钟。一旦单机房停电或者出口光缆中断,采集器还在继续产生数据,本地缓存写满之后就开始丢点,而后台的分析报表、实时大屏、报警规则全都依赖这条时序列。备份工具恢复得再快,也没法解决“写入入口已经不可用”的问题,因为从故障发生到备份拉起,中间的历史窗口已经无法补救。

所以我们设计的目标不是“把数据复制一份”,而是让读写两端在任何单点故障下都能继续工作。RTO按分钟算,RPO按秒到分钟算,而不是把希望寄托在几小时后的手工恢复上。

1.2 什么时候该上异地多活,什么时候不该硬上

不是所有场景都适合异地多活。时序数据的写入天然是分布式追加模式,每一条记录几乎都是独立事件,没有跨行事务、没有复杂的并发修改,这决定了它比关系型数据库更容易做多活。但如果你的业务强依赖“全局唯一的设备ID”“原子递增的序列号”或者“写入后立即在所有节点读到同一条数据”,那就要谨慎了,这类强一致需求不是时序数据库的设计重点。

我的建议是给数据分级。设备上报的遥测指标,可以接受几秒甚至几十秒的滞后,适合走异步同步;但控制指令、配置下发的状态记录,直接影响设备动作,必须保证不丢不重。分级之后,核心少量表走强一致或者应用层特殊处理,海量遥测表走异步同步,整体成本会低很多。千万别把“所有时序数据一视同仁”当成省事,结果就是该保的没保住,该省的没省下来。

2. TDengine 自身的冗余机制:replica、选主和多数派到底怎么工作

2.1 从 dnode 到 vnode,再到复制组

想设计异地多活,先得理解 TDengine 集群的冗余单位。一个集群由若干 dnode 组成,dnode 就是跑 taosd 的物理节点或虚机;集群里还有 mnode 负责管理元数据、用户权限和库表结构,mnode 本身也是一个多副本的 Raft 组。业务数据写入时会按时间窗口拆分成多个 vnode,每个 vnode 可以理解为一段数据集的独立复制单元,它的多个副本分散在不同的 dnode 上,副本数量由建库时的 replica 参数决定。

replica=1 表示单副本,只解决存储不解决高可用;replica=3 是最常见的集群高可用配置。注意,这里的高可用指的是“单台或两台机器故障时数据不丢、服务不中断”,它默认的网络环境是一个低延迟、稳定的数据中心内部网络。一旦把这三个副本拆到不同城市、不同机房,问题就开始变得复杂了,因为每个 vnode 内的数据同步都要跨物理距离。

2.2 一次写入的完整握手,为什么跨地域后就慢了

TDengine 的写入路径大概是这样的:客户端把数据发给某个 vnode 的 leader,leader 先写自己的 WAL,然后把日志同步给同组的 follower,得到多数派确认后才给客户端返回成功。这个过程不是“发出去就完事”,而是要等网络来回确认。

在同一机房的局域网里,这个往返可能不到 1 毫秒,你的写入几乎感受不到副本同步的开销。可一旦跨城市,专线 RTT 也许就有 10 到 30 毫秒,如果走公网或者链路不稳定,可能就是 50 毫秒往上。每次写入都额外付出这么一段等待时间,写入吞吐量就会断崖式下降。

可以想象一个场景:三个秘书在不同城市共同维护一份会议纪要,写一条记录之前必须获得至少两个人的确认,而每一次确认都要打一个跨城电话。大量记录并行处理时,通讯线路就是最大的瓶颈。这就是为什么“同一集群、replica=3、节点横跨两地”在很多异地场景下跑不出理想性能。

2.3 多数派陷阱:三副本两地分布不等于高可用

这里有一个特别容易踩的坑。假设你在 A 机房放两个 dnode,B 机房放一个 dnode,整个集群三副本。看起来“两个机房都有副本,应该很安全”,但 Raft 的多数派机制决定了:如果 A 机房整体故障,两个 A 机房的副本同时失联,剩下的那个副本无法凑齐多数派,整个库会拒绝写入。

同样的问题也出现在读路径上,leader 选举需要多数派,客户端访问也需要大多数节点正常。所以在跨机房部署单一集群时,我更推荐“三个机房各一个副本”这种三角形分布,任何一个机房故障,另外两个机房的副本仍然构成多数派,服务可以继续。但这样一来,网络链路的数量、链路成本、运维复杂度都会上升,本质上是在用基础设施复杂度换高可用。

3. 三条跨地域数据流:同步副本、异步同步、双写之间的取舍

3.1 先给结论:没有银弹,只有分层

异地多活没有一种配置适合所有数据。我们最后采用的思路是:按数据重要性和访问模式,把数据流拆成三类,分别走不同路线。这样既控制了成本,也把真正核心的数据放到了最高的可靠性保障上。

先看一张对比表,再展开讲每条路线的适用边界。

维度路线A:同一集群跨地域多副本路线B:两套集群+异步同步路线C:应用层双写
一致性强一致,Raft 多数派确认最终一致,滞后秒级到分钟级取决于实现,通常最终一致
写入性能受跨地域 RTT 影响明显写入各自集群,互不阻塞两个集群各写一份,应用开销大
单点故障容忍需节点分布满足多数派任一集群故障仍可服务任一集群故障仍可服务
回切复杂度低,自动选主中,需设计反向同步高,需业务层切换和补偿
运维复杂度低,但网络要求苛刻中,需要维护同步链路高,需要幂等机制
适用距离同城/专线低延迟异地、跨地域小数据量关键表

3.2 同步副本和独立集群的根本冲突在“一致性和可用性”

路线A的优势是简单,数据天然就是一份,不存在两套集群之间的差异。只要网络足够好,比如同城双中心、专线延迟稳定在 5 毫秒以内,完全可以把 TDengine 三副本拆到两个或三个机房,利用 Raft 自动实现故障切换。它的短板也恰恰来自强一致:网络抖动会直接影响写入成功率,跨城市的长链路带来的不确定延迟会放大每一次写放大。

路线B是我们主推的异地方案。每个区域部署一套独立集群,设备只写入本区域集群,然后把增量数据异步同步到中心或另一个集群。这样做的好处是,区域内的写入完全不受远端网络影响,故障时本地采集链路照样工作;坏处是两套集群之间有数据滞后,故障切换时需要容忍一小段脏数据或数据空洞,回切时还要处理“旧集群里新写入的数据”和“新集群里已积累的数据”如何合并。

异步同步的具体实现,可以用官方提供的数据同步工具,也可以自己写增量拉取任务。数据方向一般是从生产集群同步到中心查询集群,或者核心生产集群同步到灾备集群。同步粒度通常按数据库或者按表来控制,没必要把所有历史冷数据都实时同步过去。

3.3 双写需要注意幂等和乱序

路线C听起来最直接:业务代码把同一条数据同时写入两套集群。但实际做起来最折磨人。首先应用侧写入成本翻倍,其次两条链路到达目的集群的顺序可能不一致,如果场景里存在“同一时间戳、同一设备”的多条记录,后写入的不一定是最新状态,就需要在目标端设计幂等或者版本策略。

我们的经验是:双写只用于少数关键表,比如设备启停记录、告警确认状态这类数据量小但价值高的场景;海量遥测数据走路线B的异步同步,既不压垮应用,也不会因为同步慢而阻塞核心链路。

4. 从“双集群”到“可切换”:一份拓扑设计与配置示例

4.1 一套多写汇聚拓扑

我们落地时的真实环境是三个物理位置:两个生产园区 A、B,一个中心机房 C。A 和 B 分别部署一套独立的 TDengine 集群,负责承载本园区设备的所有写入;中心机房 C 部署一套汇聚集群,通过异步同步把 A、B 两套集群的核心库表汇聚到一起,供数据分析和统一看板查询。

这套拓扑的“多活”体现在两个层面:写入层面,A 和 B 各自独立存活,一个人挂了另一个不受影响;查询层面,中心集群挂了,各园区的本地集群仍然可以继续支撑本园区的实时查询。故障切换时,我们只需要切换接入域名或配置漂移,不需要迁移任何历史数据。

4.2 库表设计与同步实现

每个集群的库表结构必须保持完全一致,否则同步任务会持续报错。我们在发布流程里统一管理建库建表脚本,保证几个集群同时更新 schema。建库示例大概是这样的:

-- 每个园区生产集群 CREATE DATABASE edge_iot DURATION 1d KEEP 365d REPLICA 3 BUFFER 256 WAL_LEVEL 2; -- 中心汇聚集群,副本数可以根据查询量调整 CREATE DATABASE center_iot DURATION 1d KEEP 365d REPLICA 3 BUFFER 256 WAL_LEVEL 2;

注意,这里 REPLICA=3 是单集群内的副本数,不是跨集群的同步关系。跨集群的数据复制走的是同步任务,而不是让两套集群组成一个大集群。

同步任务的逻辑看起来非常简单,但细节都在容错上。我们用伪代码描述核心思路:

# 从源集群增量拉取数据,写入目标集群 last_ts = query_target_last_ts(source_table_name) while True: rows = fetch_from_source( start=last_ts, table=source_table_name, batch=1000 ) if not rows: sleep(5) continue write_to_target( target_sql, rows, upsert_strategy="last_row_win" ) last_ts = max(row["ts"] for row in rows)

这里最关键的参数是last_ts。它不是一个简单的时间戳,而是要精确记录“源端已经拉到哪一条”,并且要定时持久化,否则同步任务一旦重启,就可能从头开始或跳过一段数据,造成空洞或重复。

每个目标表建议加上一个标识来源机房的 tag,比如site,这样中心集群在查询时可以按来源域过滤。因为时序数据的主键本质是时间戳加设备标识,不同园区如果存在相同的设备编号,不加来源 tag 就会互相覆盖,这是一个我们在早期踩过的坑。

4.3 流量切换入口:尽量别改业务代码

流量切换最怕的是让每一台采集器去改配置。我们的做法是:所有采集器的写入地址统一指向一个内部域名,域名通过负载均衡解析到 A 或者 B 的集群;中心查询统一指向中心域名,中心域名后面挂多个只读地址。

故障发生时,运维只需要在负载均衡或 DNS 层把域名切到可用集群。应用代码不需要感知后端是谁,连接池也会在短暂的失败重连后自动恢复。为了让这个切换更平滑,客户端配置里可以一次配置多个节点地址,TDengine 原生驱动会在第一个地址不可用时尝试后面的节点。

5. 真正的考验是故障切换:一次跨地域切换的完整复盘

5.1 一次突然的故障演练

我们每季度会做一次切换演练。最近一次模拟的是 A 园区到中心机房的专线完全中断,A 园区集群自身还在正常运行,但中心查询集群的同步任务开始积压。

演练时间线大概是这样:

  1. 同步时延监控触发告警,值班人员在 5 分钟内确认是专线中断。
  2. A 园区采集链路仍然正常,本地集群继续写入,业务无感。
  3. 中心集群由于缺少 A 园区数据,大屏和报表出现局部缺口,自动切换规则触发后,中心域名的查询流量被切到 A 园区的本地查询入口。
  4. 同步任务进入“断点续传”模式,每 5 秒探测一次链路,链路恢复后自动追平。
  5. 追平后,我们把中心查询域名切回中心集群,并抽查最近 5 分钟数据的延迟。

这个过程听起来顺利,但真正执行时出现过好几个问题。

5.2 切换后真正卡壳的三个技术问题

第一个问题是连接池缓存。我们的应用服务启动时会从配置中心拉取 TDengine 地址列表,但连接池一旦建立,不会感知域名切换后的新地址。那次演练中,虽然域名解析已经切到 A 园区,但部分长期存活的应用进程仍然持着旧连接,等旧连接超时后才缓慢恢复。解决办法不复杂:在应用侧设置连接池的失败重连周期,并且不要用商用的 VIP 而用支持多地址的客户端配置,让驱动自动尝试备选节点。

第二个问题是设备离线后补传。一些老设备在专线中断期间把数据缓存在本地,恢复后一次性补传,时间戳可能往前回填。如果目标端以同一时间戳的新数据覆盖旧数据,就可能把原本正确的记录给覆盖掉。后来我们在写入时做了策略区分:实时写入的遥测数据不管顺序,后到覆盖先到;但状态类、事件类数据单独走双写,用业务侧维护的序列号做最终一致。

第三个问题是报警规则的 label 漂移。中心集群的报警规则是基于中心数据库里的表结构配置的,切换查询源到 A 园区后,报警上下文里的 site 字段含义变了,导致一些告警误报。这个问题的根源是元数据管理没跟上拓扑设计,现在我们会在每个 tag 里明确标注来源机和路由路径,切换时规则自动带上 site 过滤条件。

5.3 回切比切换更折磨人

故障恢复后的回切,往往比故障切换更容易出错。链路刚恢复时不能立刻把流量整体切回,因为中心集群的数据还差一个时间窗,此时切回来会让大屏看到一段“过去”。稳妥的做法是让同步任务先追平,确认中心集群的 last_ts 已经接近 A 园区集群,再开启只读校验,最后再把写流量或者查询流量逐步切回。

回切时还要处理一个容易忽视的问题:A 园区集群在中断期间积累的本地数据,和中心集群里已同步的旧数据,可能存在交叉窗口。我们通常以源端为准,把交叉窗口的数据做一次全量覆盖回放,保证中心集群的最终状态和 A 园区一致。如果这个窗口恰好有几百万条记录,同步回放的时间要充分评估,否则原以为半小时能完成的切换,最后拖了几个小时。

6. 长期治理:把多活从“能看”升级到“能扛”

6.1 六个比进程监控更重要的指标

异地多活做得怎么样,不是看集群进程活着没有,而是看数据流是否可持续。我们日常盯六个指标,每个指标都对应一个具体故障模式。

指标含义建议监控方式
同步时延目标集群最新时间戳与源集群的差值超过 30 秒告警
断点位置差值同步任务持久化的 last_ts 与实际源端差距持续增长说明链路卡死
写入失败率各集群的写入拒绝率和超时率超过千分之一关注
重新选举次数复制组 leader 切换次数突增往往意味着网络抖动
vnode 副本状态副本数是否长期小于预期小于预期意味着冗余失效
最后写入时间目标表级别最近一次成功写入避免“表级静默断流”

其中最后写入时间尤其值得关注。有次我们只看集群整体写入量,完全正常,但其实某张核心表的同步任务早就停了,写入量被其他大表掩盖。加了表级最后写入时间监控之后,这类问题当天就能暴露。

6.2 数据对账与断点续传

异步同步做得再精细,也难免出现偶发丢数据。我们每天凌晨会跑一次对账任务,随机抽取若干表,比较源端和目标端的 last_ts 以及近 24 小时行数。偏差超过阈值就把对应表加入回补队列,用查询接口拉取缺失区间重新写入。

断点续传机制不能只依赖“同步任务重启后从头扫”。我们每个同步任务的断点状态都放在一个独立的状态表里,而且用 TDengine 自己来存,这样同步任务既处理业务数据,也把自身状态当成时序数据管理。任务重启后先从状态表里读最近提交的 last_ts,再往上覆盖一段 10 分钟的冗余窗口,宁可重复写一点,也不能留空洞。

6.3 让切换变成一件乏味的事

异地多活最怕的不是技术复杂,而是平时没人敢碰切换按钮。我们第一年只做桌面推演,第二年做季度演练,第三年把切换流程写成了半自动脚本,值班人员只需要在确认故障范围后点一个“确认切换”。

这一步特别重要。因为真正发生故障时,现场往往一片混乱,与其让焦虑的人去翻手册,不如把决策路径简到最少。我们的脚本会先做健康检查,判断各集群存活状态和同步时延,再根据预设策略选择切换目标,最后执行流量切换并验证新集群的写入和查询。

给准备做异地多活的团队一个建议:不要一上来就追求“双活”这个概念,先定义清楚故障发生后的第一分钟要做什么、第十分钟要做什么、第一天要做什么。把这几分钟内的动作做成脚本,每季度演练一次,剩下的工作都是为了让我那句话变得可信:切换已经不是新鲜事,而是整个时序数据中心最普通的例行操作。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进