一句「为什么微信能聊出几百 G」把中文技术圈点燃了。云风直言微信开发者“不懂该怎么储存数据”,矛头指向 SQLite 的使用方式。这类争论之所以刺痛开发者,是因为它不只是“某个 App 占空间”的吐槽,而是一个很现实的工程问题:本地数据、媒体文件、索引、缓存、同步状态,到底应该怎样组织,才能既快、又稳、还不把用户磁盘吃穿?
这里不试图替任何具体产品下定论。更有价值的问题是:SQLite 适合什么、不适合什么?为什么一个聊天应用可能膨胀到几十甚至几百 GB?如果我们自己做客户端或本地优先应用,应该怎么设计存储边界?
SQLite 不是原罪,滥用边界才是问题
SQLite 是嵌入式数据库,不需要单独服务进程,事务可靠,跨平台,部署成本极低。移动端、桌面端、本地缓存、浏览器、IoT 设备里都有大量成功案例。
它适合做这些事:
- 存结构化数据:消息元信息、会话列表、用户设置、同步游标;
- 支持本地查询:按会话、时间、关键词、状态检索;
- 保证事务一致性:消息写入、状态更新、索引更新一起提交;
- 做小到中等规模的本地数据管理。
但 SQLite 不应该被当成“什么都往里塞的文件夹”。尤其是聊天软件,数据类型很复杂:
- 文本消息;
- 图片、视频、语音、文件;
- 缩略图和转码副本;
- 搜索索引;
- 下载缓存;
- 草稿、撤回、已读状态;
- 多设备同步状态;
- 日志和崩溃诊断信息。
如果把大块媒体内容直接塞进数据库 BLOB,或者让缓存、缩略图、临时文件缺少生命周期管理,SQLite 文件膨胀只是表象,真正的问题是“数据分层”和“清理策略”没有做好。
聊天应用为什么容易长成几百 G
聊天产品有一个很麻烦的特点:用户不觉得自己在“存文件”,但聊天行为本质上一直在产生文件。
一个群里发一段 200 MB 视频,可能会出现多份数据:原始文件、下载副本、转码副本、预览图、播放器缓存、消息数据库记录、全文索引记录。如果还有多账号、多终端、备份恢复、迁移历史,这个链条会更长。
几百 GB 的来源通常不是单一数据库文件,而可能是多类数据叠加:
| 数据类型 | 常见位置 | 主要风险 |
|---|---|---|
| 消息元数据 | SQLite / LevelDB / 自研存储 | 索引膨胀、历史迁移残留 |
| 图片视频语音 | 文件系统 | 缺少去重、过期和引用计数 |
| 缩略图 | 文件系统或数据库 | 多尺寸副本无限增长 |
| 搜索索引 | FTS / 倒排索引 | 删除消息后索引未压缩 |
| 缓存 | cache 目录 | 临时数据永久化 |
| WAL / journal | SQLite 辅助文件 | 长事务或未 checkpoint 导致膨胀 |
所以,“用了 SQLite”不等于“设计错了”;“SQLite 文件很大”也不自动等于“SQLite 不行”。更准确的判断要看:哪些数据进库、哪些数据落文件、是否有引用关系、是否能清理、是否能迁移和压缩。
一个更稳妥的本地存储分层
如果我们做一个聊天客户端,可以这样实践:数据库只存“可查询的事实”,大文件放文件系统,并用引用表维护关系。
推荐思路:
- SQLite 存消息、会话、附件元信息;
- 图片、视频、语音放在 content-addressed storage,例如按 SHA-256 命名;
- 同一个文件出现多次,只存一份;
- 用引用计数或关联表判断文件是否还能删除;
- 缩略图和缓存设置明确 TTL 或体积上限;
- 定期执行 checkpoint、增量 vacuum 或压缩维护。
下面是一个可运行的 Python 小例子,演示“消息入库、附件落盘、按 hash 去重、清理孤儿文件”的基本结构。它不是对任何现有产品实现的还原,只是一个可改造的实践骨架。
#!/usr/bin/env python3
import hashlib
import os
import shutil
import sqlite3
import sys
from pathlib import Path
ROOT = Path("chat_store")
DB = ROOT / "chat.db"
BLOBS = ROOT / "blobs"
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def connect():
ROOT.mkdir(exist_ok=True)
BLOBS.mkdir(exist_ok=True)
conn = sqlite3.connect(DB)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
sender TEXT NOT NULL,
text TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS attachments (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
path TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS message_attachments (
message_id INTEGER NOT NULL,
attachment_hash TEXT NOT NULL,
PRIMARY KEY (message_id, attachment_hash),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (attachment_hash) REFERENCES attachments(hash) ON DELETE CASCADE
);
"""
)
return conn
def add_message(conn, conversation_id: str, sender: str, text: str, files):
cur = conn.execute(
"INSERT INTO messages(conversation_id, sender, text) VALUES (?, ?, ?)",
(conversation_id, sender, text),
)
message_id = cur.lastrowid
for file_name in files:
src = Path(file_name)
digest = sha256_file(src)
suffix = src.suffix.lower()
dst = BLOBS / f"{digest}{suffix}"
if not dst.exists():
shutil.copy2(src, dst)
conn.execute(
"INSERT OR IGNORE INTO attachments(hash, size, path) VALUES (?, ?, ?)",
(digest, dst.stat().st_size, str(dst)),
)
conn.execute(
"INSERT OR IGNORE INTO message_attachments(message_id, attachment_hash) VALUES (?, ?)",
(message_id, digest),
)
conn.commit()
print(f"added message_id={message_id}")
def gc_orphan_blobs(conn):
rows = conn.execute(
"""
SELECT hash, path FROM attachments
WHERE hash NOT IN (SELECT attachment_hash FROM message_attachments)
"""
).fetchall()
for digest, path in rows:
p = Path(path)
if p.exists():
p.unlink()
conn.execute("DELETE FROM attachments WHERE hash = ?", (digest,))
conn.commit()
# 让 WAL 合并回主库,避免辅助文件长期膨胀
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
print(f"removed {len(rows)} orphan blobs")
def stats(conn):
msg_count = conn.execute("SELECT count(*) FROM messages").fetchone()[0]
att_count = conn.execute("SELECT count(*) FROM attachments").fetchone()[0]
att_size = conn.execute("SELECT coalesce(sum(size), 0) FROM attachments").fetchone()[0]
db_size = DB.stat().st_size if DB.exists() else 0
print(f"messages={msg_count}, attachments={att_count}, attachment_bytes={att_size}, db_bytes={db_size}")
if __name__ == "__main__":
conn = connect()
cmd = sys.argv[1] if len(sys.argv) > 1 else "stats"
if cmd == "add":
# 用法:python chat_store_demo.py add ./photo.jpg ./video.mp4
add_message(conn, "group-1", "alice", "hello with files", sys.argv[2:])
elif cmd == "gc":
gc_orphan_blobs(conn)
else:
stats(conn)
运行方式:
python3 chat_store_demo.py stats
python3 chat_store_demo.py add ./some-image.jpg
python3 chat_store_demo.py add ./some-image.jpg # 同一个文件会复用同一份 blob
python3 chat_store_demo.py stats
python3 chat_store_demo.py gc
这个例子体现了一个关键原则:数据库负责“关系”和“查询”,文件系统负责“大对象”。真正上线时还要补上加密、权限、缩略图策略、失败恢复、迁移脚本和配额控制。
SQLite 文件变大时,先查这几件事
如果你的项目也遇到本地库越来越大的问题,不要第一反应就换数据库。先用可观测手段定位。
可以从 SQLite 自身开始:
sqlite3 chat_store/chat.db <<'SQL'
PRAGMA page_size;
PRAGMA page_count;
PRAGMA freelist_count;
PRAGMA journal_mode;
PRAGMA wal_checkpoint(PASSIVE);
SELECT name, type FROM sqlite_master WHERE type IN ('table', 'index') ORDER BY name;
SQL
ls -lh chat_store/
ls -lh chat_store/chat.db*
du -sh chat_store
几个判断点:
chat.db-wal很大:检查是否有长期连接、长事务、未 checkpoint;freelist_count很高:说明删除后空闲页很多,可能需要 vacuum 或 auto_vacuum 策略;- 索引比表还大:检查重复索引、低选择性索引、全文索引维护;
- 目录总体大但 db 不大:真正占空间的可能是媒体、缓存、缩略图;
- 删除聊天记录后空间不降:可能只是逻辑删除,或文件引用仍存在。
在产品层面,还要给用户可理解的管理入口:按会话查看占用、按类型清理、保留最近 N 天、仅删除本地副本、不影响云端或对端消息。这些交互细节比“换一个数据库”更影响用户感知。
争论之外:选型要看约束,不看情绪
云风的批评之所以引发大讨论,是因为它击中了开发者对工程质量的敏感点。但技术选型不是站队题。SQLite 可以是好选择,也可以被用得很糟;自研存储、LevelDB、RocksDB、文件系统索引同样可能设计翻车。
更实用的检查清单是:
- 大文件是否直接进库?如果是,收益是否明确大于代价?
- 删除是否真的释放空间,还是只打标记?
- 缓存有没有 TTL、体积上限和淘汰策略?
- 媒体文件是否去重?缩略图是否可重建?
- WAL、索引、全文检索是否有维护计划?
- 用户能否看懂并控制本地占用?
- 数据迁移失败时,是否会留下双份历史?
聊天应用的本地存储难点,不在于“能不能把数据写进去”,而在于几年之后还能不能解释、清理、迁移和恢复。SQLite 背不背锅,最终要看边界设计和生命周期管理。数据库只是工具,真正决定磁盘体积的,是工程团队如何对待每一类数据的出生、使用和死亡。