时序数据的存储一直是监控系统设计的核心难题。指标数据写入频繁、单条价值低、查询集中在时间窗口聚合上,这些特点让传统关系型数据库很难胜任。本文通过一个真实的服务器指标采集项目,介绍如何把SQLite和Graphite结合起来,构建一套不依赖大型数据库集群、单机即可稳定运行的时序存储方案,并详细分析其中每个环节的技术细节和踩坑经验。

一、为什么选择SQLite与Graphite的组合
先说结论:这套组合适合的是单机或小规模集群的监控场景,比如公司内部的工具服务器、开发测试环境的指标采集、或者边缘设备上的轻量监控。这类场景的共同点是数据量在每天几百万条以内,但要求存储层足够简单,不希望为了一个监控面板专门维护一套数据库服务。
SQLite作为嵌入式数据库的最大优势是零部署成本。它不需要独立的数据库进程,整个存储引擎就是一个动态链接库加上若干数据文件。在采集端,SQLite可以充当本地缓冲区:网络抖动或者Graphite服务暂时不可用时,指标数据先落到本地SQLite文件里,服务恢复后再批量推送。这一点在生产环境非常关键,否则任何一次网络故障都会造成监控数据丢失。
Graphite则提供了专业的时序数据管理能力。它的Whisper存储引擎采用固定大小的预分配文件,每个指标对应一个.wsp文件,文件内部按时间槽组织数据。这种设计的读写效率极高,因为文件创建后大小就固定了,不会有碎片和扩容开销,天然适合指标数据这种写入模式固定的场景。同时Graphite自带归档(retention)能力,可以通过配置让数据自动降采样,比如原始数据保留7天,之后聚合成5分钟粒度保留一年,这是自己用关系型数据库实现起来非常麻烦的功能。
二、整体架构与数据流转设计
这套方案的架构分为三层:采集层负责从目标服务器抓取CPU、内存、磁盘等指标;缓冲层用SQLite暂存数据;存储层由Graphite的carbon接收端和Whisper文件系统组成。数据流转的完整链路是:采集脚本产生指标,先写入本地SQLite,再由一个独立的发送进程从SQLite读出批量数据,通过TCP或UDP发送给carbon,carbon落盘为Whisper文件。
把SQLite放在中间做缓冲,而不是让采集脚本直接发送给carbon,主要有两个原因。第一是削峰,采集任务可能在整点集中触发,直接发送会造成瞬时网络压力;第二是可靠性的保证,发送失败的数据可以标记状态后留在SQLite里重试。下面是SQLite缓冲表的建表语句和写入逻辑:
CREATE TABLE IF NOT EXISTS metric_buffer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
metric_path TEXT NOT NULL, -- 指标路径,如 servers.web1.cpu.usage
value REAL NOT NULL, -- 指标值
timestamp INTEGER NOT NULL, -- 数据点的Unix时间戳
sent INTEGER DEFAULT 0, -- 0未发送 1已发送
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_buffer_sent ON metric_buffer(sent, timestamp);
-- 批量写入时开启事务,减少磁盘刷写次数
BEGIN;
INSERT INTO metric_buffer (metric_path, value, timestamp)
VALUES ('servers.web1.cpu.usage', 45.2, strftime('%s','now'));
COMMIT;
这里有一个非常重要的细节:写入SQLite前一定要开启事务,把一批数据放在同一个事务里提交。SQLite默认每条INSERT都会触发一次fsync,机械磁盘上每秒只能承受几十次这样的写入,批量事务可以把性能提升一到两个数量级。此外,建议在连接时设置PRAGMA journal_mode=WAL,WAL模式允许读写并发,发送进程读取数据时不会阻塞采集进程的写入。
三、Whisper存储引擎的原理与配置
Whisper的核心机制是预分配定长文件。创建指标文件时,会根据归档策略一次性分配好磁盘空间,每个归档档位对应文件内的一段连续区域,每段区域按固定时间槽存放数据点。写入时直接根据时间戳计算偏移量,覆盖对应槽位,完全不需要B树查找。这种设计使得Whisper的写入路径极短,单机每秒处理几十万个数据点毫无压力。
归档策略通过storage-schemas.conf配置,这个文件定义了不同指标模式的保留规则。例如下面的配置:
[server_metrics] pattern = ^servers\..* retentions = 10s:7d,1m:30d,10m:1y
这行配置的含义是:所有以servers.开头的指标,以10秒粒度保留7天,之后自动聚合成1分钟粒度保留30天,再聚合成10分钟粒度保留一年。聚合算法可以选择平均值、最大值、最小值或求和,通过storage-aggregation.conf单独配置。要注意的一点是,粒度必须成整数倍关系,1m是10s的6倍没问题,但如果写成15s就会报错。
一个容易踩的坑是:Whisper文件的大小在创建时就固定了,如果后来修改了retentions配置,已存在的文件不会自动应用新策略,必须手动执行whisper-resize.py来迁移,而且这个操作会临时占用双倍磁盘空间。所以上线前一定要规划好保留策略,避免后期频繁调整。
四、发送进程的实现与失败重试
从SQLite到Graphite的发送进程建议独立运行,用Python实现非常简洁。它的职责是循环扫描sent为0的记录,按照carbon的明文协议批量发送。carbon的明文协议很简单,每行格式为“指标路径 值 时间戳”,多行直接拼接即可:
import socket
import sqlite3
def flush_metrics(db_path, graphite_host, graphite_port=2003, batch=500):
conn = sqlite3.connect(db_path)
conn.execute('PRAGMA journal_mode=WAL')
sock = socket.create_connection((graphite_host, graphite_port), timeout=5)
rows = conn.execute(
'SELECT id, metric_path, value, timestamp FROM metric_buffer '
'WHERE sent = 0 ORDER BY timestamp LIMIT ?', (batch,)
).fetchall()
if not rows:
return 0
# 按carbon明文协议拼装数据
payload = '\n'.join(f'{p} {v} {t}' for _, p, v, t in rows) + '\n'
sock.sendall(payload.encode('utf-8'))
sock.close()
# 发送成功后批量标记,减少事务数量
ids = [r[0] for r in rows]
conn.executemany(
'UPDATE metric_buffer SET sent = 1 WHERE id = ?',
[(i,) for i in ids]
)
conn.commit()
conn.close()
return len(rows)
重试逻辑上有两种策略可选。一种是上面的标记法,发送成功才标记sent为1,失败则留在表里下轮重试,简单可靠;另一种是给记录加时间戳,超过一定时长的未发送数据直接丢弃,因为监控数据过旧就没有意义了。实践中建议两者结合:重试三次仍失败就放弃,同时对表做定期清理,已发送的记录保留一天后删除,否则metric_buffer表会无限膨胀,SQLite文件膨胀到几个GB后查询速度会明显下降。
清理时不要用DELETE逐条删,推荐定期用VACUUM回收空间,或者更彻底的做法:按天分表,过期直接DROP整张表。SQLite删除数据后文件大小不会自动缩小,这一点和很多人直觉相反,是维护时必须注意的细节。
五、方案对比与适用边界
和直接使用InfluxDB或Prometheus相比,这套方案的优势在于极低的运维成本:没有额外的服务进程要管理,Graphite的carbon和Whisper都是稳定的老牌组件,SQLite更是嵌入式标准方案,整套系统故障点非常少。劣势也很明显:Whisper不支持高可用,单机故障就丢数据;指标基数大的时候,海量小文件的inode开销会成为瓶颈;查询能力也比不上InfluxDB的Flux或PromQL。
经验上的判断标准是这样的:指标数量在一万以内、不需要集群化部署、团队没有专职运维时,SQLite加Graphite的组合性价比极高;如果指标基数达到十万级以上,或者有长期高可用要求,就应该直接上Prometheus加Thanos或者InfluxDB集群方案。技术选型没有银弹,理解每种存储引擎的底层机制,才能在具体场景中做出正确判断。