高频时序写入的朴素写法是:来一条数据,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_number:None和NaN/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()
stop_event.set():worker 循环感知停机,把剩余批次 flush 出去,submit()也开始拒绝新数据;queue.join():最多等 30 秒,等队列里的记录全部处理完 (worker 每 flush 完一批,就对批内每条记录task_done()一次,所有记录处理完 join 才返回);- 排空后才
cancel()worker、关闭连接------顺序反了,就会出现"连接已关,worker 还在写"的诡异报错。
上线前,把这几个指标盯住
写管线不是"写完就完",上线前必须把监控配上。项目里四个指标,缺一不可:
- QUEUE_DEPTH:队列深度。持续不降,说明消费端有瓶颈;
- RECORDS_WRITTEN:写入成功计数。按传输方式分标签,能看出哪个 worker 在干活;
- RECORDS_FAILED:失败计数。突刺出现时,配合重试日志定位;
- WRITE_LATENCY:单次 execute 耗时。慢到秒级,就该查连接或 SQL 了。
背压生效、攒批稳定、无重试风暴是三个健康信号;反之,队列满、批量小、重试多,就该逐项排查了。
写在最后
回头看这套管线:
有界队列 → 扛住峰值,不让内存爆炸
双触发攒批 → 批量兜吞吐,时间兜延迟
多 worker 并行 → 攒批不堵,连接复用
拼 SQL + 转义 → 一次往返,杜绝注入
重试 + 落盘 → 不丢数据,兜住底
优雅停机 → 发布不丢一批
这几板斧不是魔法,而是把"来一条写一条"的惯性思维,换成"攒一批再写"的系统思维。你的服务扛不扛得住流量,不取决于单条写得多快,而取决于整条管线在峰值时如何优雅地降速、排队、批量消化。
如果你的系统还在为写入发愁,不妨从最小改动开始:先加有界队列,再攒批,最后上多 worker。每一步都能看到立竿见影的变化。
下一篇文章讲重试和落盘的细节:指数退避怎么退、磁盘缓冲怎么设计、进程重启后怎么把没写进去的数据按序回放------把"不丢数据"这四个字落到实处。
觉得有用?点个关注,持续获取优质内容。