【系列:TDengine 工业物联网实战:从零搭起可运行系统 · 第 4 篇】

高频时序写入的朴素写法是:来一条数据,INSERT 一次。每秒上千条时,网络往返、内存堆积、连接池全部亮红灯。本文拆解一个生产级 asyncio 写入管线:有界队列背压、双触发攒批、多 worker 并发,外加转义、重试与优雅停机,一套可照抄的写入模式。

某天凌晨两点,监控告警把运维群里所有人炸醒:数据库连接 100% 占满,CPU 打满,写入延迟从 5ms 飙升到 5 秒。查下来,罪魁祸首是一段"教科书级"的写入代码------每条数据一次 INSERT,配合一个无限增长的队列。

这个场景,做 IoT 的同学大概率不陌生。高频时序数据写入,朴素写法人人都能写,但生产环境从不惯着朴素写法。今天拆解一个真正能扛住的 asyncio 写入管线,读完你就能回答:队列为什么必须有界、攒批怎么做到快慢都能发、多 worker 并发为什么还要加锁。

先看全景:一条数据从采集到入库

写入侧并不是简单地把数据塞进数据库,它是一条流水线:

复制代码
submit() → 有界队列 → 4 个 worker 并行攒批 → flush 组装 SQL → TDengine

每个环节各司其职:

  • submit:把记录放进队列,队列满就等待,这是背压;
  • worker:攒够一批或到时间就 flush,这是批量;
  • flush:组装 SQL、重试、失败落盘,这是兜底。

背压:宁可让源头等一下,不让内存爆炸

为什么要给队列加上限?先算一笔账:生产者峰值速率 × 消费故障时长 = 内存增长量。消费端一次抖动,队列里就可能堆出几十万个 Python 对象,直接 OOM。

本方案的解法是把队列上限设成 50000 条,put 是 await 的:

python 复制代码
self.queue: asyncio.Queue[Record] = asyncio.Queue(settings.queue_capacity)

async def submit(self, record: Record) -> None:
    if not self._started:
        raise RuntimeError("pipeline is not started")
    if self.stop_event.is_set():
        raise RuntimeError("pipeline is stopping")
    await self.queue.put(record)
    QUEUE_DEPTH.set(self.queue.qsize())

当队列满了,await put 会挂起当前协程,生产者的速率被自然压到与消费者一致------这比任何限流算法都简单,因为背压的反馈信号就是队列本身。同时 QUEUE_DEPTH 是 Prometheus 指标,队列深度随时可观测。

对比三种方案:无限队列会 OOM;满了丢弃会丢数据;满了等待则慢但不丢。怎么选,看取舍:宁可让源头等一下,也不能让内存和 DB 被突刺打崩。

攒批:批量上限兜住吞吐,时间上限兜住延迟

队列解决了削峰,但吞吐还不够。每条数据一次 execute,网络往返是最大的开销。攒批就是把 N 次往返合并成一次。

worker 的核心循环长这样:

python 复制代码
while True:
    timeout = max(0.0, deadline - monotonic())
    try:
        record = await asyncio.wait_for(self.queue.get(), timeout=timeout)
        batch.append(record)
    except TimeoutError:
        pass
    should_flush = (
        len(batch) >= self.settings.batch_size
        or (batch and monotonic() >= deadline)
        or (batch and self.stop_event.is_set() and self.queue.empty())
    )
    if should_flush:
        await self._flush(batch, index)
        batch.clear()
        deadline = monotonic() + self.settings.flush_interval_seconds
    if self.stop_event.is_set() and self.queue.empty() and not batch:
        return

重点在 asyncio.wait_for(queue.get(), timeout):不是死等队列,而是限时等待。超时不是错误,恰是"该判断一下要不要 flush"的时机。

三个触发条件:

  • :batch 攒满 1000 条(batch_size 默认值),立即 flush;
  • :超过 0.2 秒(flush_interval_seconds 默认值)且 batch 非空,强制 flush;
  • 停机:stop_event 置位 + 队列空 + batch 非空,最后一批也 flush 出去。

这就是"双触发"的意义:流量高峰时,批量上限兜住吞吐,1000 条一写;流量低谷时,时间上限兜住延迟,最多等 0.2 秒就发。不会傻等攒满,也不会小批量狂发。

并发:4 个 worker 攒批,1 把锁写库

攒批有收益,但单 worker 的瓶颈在 execute 本身。所以 pipeline 默认起 4 个 worker,每个 worker 独立攒批、独立 flush:

python 复制代码
self.tasks = [
    asyncio.create_task(self._worker(index), name=f"writer-{index}")
    for index in range(self.settings.writer_workers)
]

这里有个关键设计:4 个 worker 共享一个 TDengine 连接和 cursor。 为什么?时序数据库的连接创建成本高,频繁建连会打爆服务端。共享连接就必须串行化 execute:

python 复制代码
async with self._lock:
    affected = await asyncio.to_thread(self._cursor.execute, sql)
  • asyncio.Lock 保证同一时刻只有一个协程在真正执行 SQL;
  • asyncio.to_thread 把阻塞的驱动调用丢到线程池,不卡事件循环。

所以这里的并发收益是"攒批并行 + IO 切换",真正的写库是串行的。这也是时序写入代码里的常见形态:连接复用 + 锁串行。

一条 SQL 写几十个子表

攒出来的 1000 条记录,来自不同的设备、不同的子表。如果每个子表一条 SQL,那还是几十次往返,攒批就白攒了。

解法是分组后拼成一条 SQL。build_insert_sql 先按(超级表, 子表名)分组:

python 复制代码
grouped: dict[tuple[str, str], list[Record]] = defaultdict(list)
for record in records:
    if isinstance(record, Telemetry):
        grouped[("telemetry", record.tags.table_name())].append(record)
    elif isinstance(record, VehicleTrack):
        grouped[("vehicle_track", record.tags.table_name())].append(record)

每一组拼一段 INSERT INTO 子表 USING 超级表 TAGS (...) VALUES (...),整批合成一条 SQL:

python 复制代码
fragments = ["INSERT INTO"]
for (kind, table_name), rows in grouped.items():
    first = rows[0]
    qualified = f"{database}.{table_name}"
    if kind == "telemetry":
        telemetry_tags = first.tags
        fragments.extend(
            (
                qualified,
                f"USING {database}.telemetry TAGS",
                "(" + ",".join(
                    quote_string(value)
                    for value in (
                        telemetry_tags.device_id,
                        telemetry_tags.product_key,
                        telemetry_tags.factory_id,
                        telemetry_tags.workshop_id,
                        telemetry_tags.device_type,
                        telemetry_tags.region,
                    )
                ) + ") VALUES",
                " ".join(telemetry_values(row) for row in rows if isinstance(row, Telemetry)),
            )
        )
    # vehicle_track 分支同理,TAGS 换成车辆 4 个标签
return " ".join(fragments)

1000 条记录最终落到一条 INSERT INTO ... USING ... TAGS ... VALUES ...,一次 execute 写完。这里的 USING ... TAGS 正是第 2 篇讲的隐式建表:子表不存在会自动创建,所以写入方永远不用先建表。

严格转义:入库前的最后一道防线

SQL 是拼出来的,设备数据又来自网络报文,转义就是最后一道防线。sql.py 里三个函数各管一摊:

python 复制代码
def quote_string(value: str) -> str:
    if "\x00" in value:
        raise ValueError("NUL is not valid in a TDengine string")
    return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"

def sql_number(value: float | int | None) -> str:
    if value is None:
        return "NULL"
    if isinstance(value, float) and not math.isfinite(value):
        return "NULL"
    return repr(value)

def sql_timestamp(value: datetime) -> str:
    utc_value = value.astimezone(UTC)
    return quote_string(utc_value.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3])
  • quote_string:反斜杠和单引号转义,拒绝 NUL 字符------TDengine 字符串里不允许 NUL,这就是"先把脏数据挡在门外";
  • sql_numberNoneNaN/Inf 统一转 NULL------传感器可能上报 NaN,不能让它污染列;
  • sql_timestamp:统一转 UTC、毫秒精度------不同时区的设备上报,时间戳不会串台。

两条原则值得写进团队规范:字符串值全转义,宁严勿松;数值和时间为空宁可 NULL 不可造假。 大多数写入事故,不是架构不够好,而是栽在一条没转义的引号上。

兜底:重试 5 次,再不行落盘

写入再稳,也会遇到网络抖动或 DB 重启。flush 的错误处理要分层:瞬时错误重试,持久错误落盘。

python 复制代码
async def _write_with_retry(self, batch: Sequence[Record]) -> None:
    async def operation() -> None:
        with WRITE_LATENCY.labels(self.writer.transport).time():
            result = await self.writer.write(batch)
        kind = type(batch[0]).__name__.lower()
        RECORDS_WRITTEN.labels(self.writer.transport, kind).inc(result.written)

    await retry_async(
        operation,
        attempts=self.settings.max_retries,
        base_delay=self.settings.retry_base_seconds,
        retryable=(OSError, TimeoutError, ConnectionError),
    )
  • retry_async 指数退避 + 随机抖动,默认重试 5 次、基础延迟 0.25 秒------抖动是为了防止重试风暴,避免 DB 刚恢复就被同一秒的请求打垮;
  • 重试耗尽仍失败:RECORDS_FAILED 指标计数,若开了 spool_enabled 则整批 spool.store(batch) 落盘(第 5 篇详讲)------宁可晚到,不可丢数据

优雅停机:先停接收,再等队列排空

生产环境里,发布和重启是常态。如果直接杀进程,队列里的几十万条数据就没了。stop() 的关键在"先排空,再关闭":

python 复制代码
async def stop(self, drain_timeout: float = 30) -> None:
    self.stop_event.set()
    try:
        await asyncio.wait_for(self.queue.join(), timeout=drain_timeout)
    except TimeoutError:
        logger.error("writer queue did not drain before timeout")
    for task in self.tasks:
        task.cancel()
    for task in self.tasks:
        with suppress(asyncio.CancelledError):
            await task
    await self.writer.close()
  1. stop_event.set():worker 循环感知停机,把剩余批次 flush 出去,submit() 也开始拒绝新数据;
  2. queue.join()最多等 30 秒,等队列里的记录全部处理完 (worker 每 flush 完一批,就对批内每条记录 task_done() 一次,所有记录处理完 join 才返回);
  3. 排空后才 cancel() worker、关闭连接------顺序反了,就会出现"连接已关,worker 还在写"的诡异报错。

上线前,把这几个指标盯住

写管线不是"写完就完",上线前必须把监控配上。项目里四个指标,缺一不可:

  • QUEUE_DEPTH:队列深度。持续不降,说明消费端有瓶颈;
  • RECORDS_WRITTEN:写入成功计数。按传输方式分标签,能看出哪个 worker 在干活;
  • RECORDS_FAILED:失败计数。突刺出现时,配合重试日志定位;
  • WRITE_LATENCY:单次 execute 耗时。慢到秒级,就该查连接或 SQL 了。

背压生效、攒批稳定、无重试风暴是三个健康信号;反之,队列满、批量小、重试多,就该逐项排查了。

写在最后

回头看这套管线:

复制代码
有界队列       → 扛住峰值,不让内存爆炸
双触发攒批     → 批量兜吞吐,时间兜延迟
多 worker 并行 → 攒批不堵,连接复用
拼 SQL + 转义 → 一次往返,杜绝注入
重试 + 落盘    → 不丢数据,兜住底
优雅停机       → 发布不丢一批

这几板斧不是魔法,而是把"来一条写一条"的惯性思维,换成"攒一批再写"的系统思维。你的服务扛不扛得住流量,不取决于单条写得多快,而取决于整条管线在峰值时如何优雅地降速、排队、批量消化。

如果你的系统还在为写入发愁,不妨从最小改动开始:先加有界队列,再攒批,最后上多 worker。每一步都能看到立竿见影的变化。

下一篇文章讲重试和落盘的细节:指数退避怎么退、磁盘缓冲怎么设计、进程重启后怎么把没写进去的数据按序回放------把"不丢数据"这四个字落到实处。


觉得有用?点个关注,持续获取优质内容。

相关推荐
努力搬砖的咸鱼1 小时前
意图理解:让Agent从需求描述自动生成Pytest测试策略
人工智能·python·ai·单元测试·pytest·agent
2601_956319881 小时前
2026年下半年,量化学习要把交易认知和技术实现接起来
人工智能·python
EW Frontier1 小时前
【雷达信号处理】5 个阵元追平 16 个阵元:稀疏阵列 STAP 的实测报告【附python+matlab代码】
python·matlab·信号处理·雷达·mimo·稀疏阵列·stap
卷无止境1 小时前
FastAPI的测试事件在测什么?
后端·python·fastapi
selfsongs2 小时前
Python学习之——multiprocessing 多进程编程
python
卷无止境2 小时前
FastAPI CLI 你需要认识这把命令行利器
后端·python·fastapi
云泽8082 小时前
Python 开发环境搭建全指南:从 Python 安装到 PyCharm 配置详解
开发语言·python·pycharm
IT小白杨2 小时前
海外业务账号安全保障:主流浏览器产品底层机制对比
数据库·python·安全·自动化·安全架构·指纹浏览器
攻城狮7号2 小时前
时序大模型 TimechoAI:让时间序列预测从“玄学“变“标配“
时序数据库·时序预测·时序大模型·timechoai