微信几百 G 争论背后:SQLite 到底该不该背锅?

2026-07-01 31 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

一句「为什么微信能聊出几百 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 背不背锅,最终要看边界设计和生命周期管理。数据库只是工具,真正决定磁盘体积的,是工程团队如何对待每一类数据的出生、使用和死亡。


相关推荐