
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 参数决定。replica1 表示单副本只解决存储不解决高可用replica3 是最常见的集群高可用配置。注意这里的高可用指的是“单台或两台机器故障时数据不丢、服务不中断”它默认的网络环境是一个低延迟、稳定的数据中心内部网络。一旦把这三个副本拆到不同城市、不同机房问题就开始变得复杂了因为每个 vnode 内的数据同步都要跨物理距离。2.2 一次写入的完整握手为什么跨地域后就慢了TDengine 的写入路径大概是这样的客户端把数据发给某个 vnode 的 leaderleader 先写自己的 WAL然后把日志同步给同组的 follower得到多数派确认后才给客户端返回成功。这个过程不是“发出去就完事”而是要等网络来回确认。在同一机房的局域网里这个往返可能不到 1 毫秒你的写入几乎感受不到副本同步的开销。可一旦跨城市专线 RTT 也许就有 10 到 30 毫秒如果走公网或者链路不稳定可能就是 50 毫秒往上。每次写入都额外付出这么一段等待时间写入吞吐量就会断崖式下降。可以想象一个场景三个秘书在不同城市共同维护一份会议纪要写一条记录之前必须获得至少两个人的确认而每一次确认都要打一个跨城电话。大量记录并行处理时通讯线路就是最大的瓶颈。这就是为什么“同一集群、replica3、节点横跨两地”在很多异地场景下跑不出理想性能。2.3 多数派陷阱三副本两地分布不等于高可用这里有一个特别容易踩的坑。假设你在 A 机房放两个 dnodeB 机房放一个 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;注意这里 REPLICA3 是单集群内的副本数不是跨集群的同步关系。跨集群的数据复制走的是同步任务而不是让两套集群组成一个大集群。同步任务的逻辑看起来非常简单但细节都在容错上。我们用伪代码描述核心思路# 从源集群增量拉取数据写入目标集群 last_ts query_target_last_ts(source_table_name) while True: rows fetch_from_source( startlast_ts, tablesource_table_name, batch1000 ) if not rows: sleep(5) continue write_to_target( target_sql, rows, upsert_strategylast_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 园区集群自身还在正常运行但中心查询集群的同步任务开始积压。演练时间线大概是这样同步时延监控触发告警值班人员在 5 分钟内确认是专线中断。A 园区采集链路仍然正常本地集群继续写入业务无感。中心集群由于缺少 A 园区数据大屏和报表出现局部缺口自动切换规则触发后中心域名的查询流量被切到 A 园区的本地查询入口。同步任务进入“断点续传”模式每 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 让切换变成一件乏味的事异地多活最怕的不是技术复杂而是平时没人敢碰切换按钮。我们第一年只做桌面推演第二年做季度演练第三年把切换流程写成了半自动脚本值班人员只需要在确认故障范围后点一个“确认切换”。这一步特别重要。因为真正发生故障时现场往往一片混乱与其让焦虑的人去翻手册不如把决策路径简到最少。我们的脚本会先做健康检查判断各集群存活状态和同步时延再根据预设策略选择切换目标最后执行流量切换并验证新集群的写入和查询。给准备做异地多活的团队一个建议不要一上来就追求“双活”这个概念先定义清楚故障发生后的第一分钟要做什么、第十分钟要做什么、第一天要做什么。把这几分钟内的动作做成脚本每季度演练一次剩下的工作都是为了让我那句话变得可信切换已经不是新鲜事而是整个时序数据中心最普通的例行操作。