#!/usr/bin/env python3 """ db_tools — SQLite 数据库备份、导出、导入工具 用法: python db_tools.py backup # 安全备份数据库(使用 sqlite3 backup API) python db_tools.py export # 导出所有表数据为 JSON python db_tools.py import # 从 JSON 导入数据 python db_tools.py vacuum # 执行 VACUUM 压缩数据库 python db_tools.py wal-checkpoint # 强制 WAL 检查点(确保数据写入主库) python db_tools.py info # 显示数据库信息和表统计 """ import json import os import shutil import sys import sqlite3 from datetime import datetime, timezone DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db") # 从 sqlite:///path 提取文件路径 def _get_db_path() -> str: if DATABASE_URL.startswith("sqlite:////"): return DATABASE_URL[10:] # 绝对路径 sqlite:////app/data/xxx.db elif DATABASE_URL.startswith("sqlite:///"): return DATABASE_URL[9:] # 相对路径 else: return "database.db" def _connect() -> sqlite3.Connection: """创建数据库连接(配置 PRAGMA)。""" conn = sqlite3.connect(_get_db_path()) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.row_factory = sqlite3.Row return conn def backup(output_path: str): """ 使用 SQLite 官方 backup API 进行在线热备份(不需要停机)。 这是最安全的备份方式,支持读写并发。 """ src = _get_db_path() print(f"[backup] 正在备份数据库: {src} -> {output_path}") if not os.path.exists(src): print(f"[backup] 错误: 数据库文件不存在: {src}", file=sys.stderr) sys.exit(1) start = datetime.now(timezone.utc) # 使用 sqlite3 backup API(支持在线备份) src_conn = sqlite3.connect(src) dst_conn = sqlite3.connect(output_path) try: src_conn.backup(dst_conn) dst_conn.commit() finally: dst_conn.close() src_conn.close() size_mb = os.path.getsize(output_path) / (1024 * 1024) elapsed = (datetime.now(timezone.utc) - start).total_seconds() print(f"[backup] 备份完成: {output_path} ({size_mb:.2f} MB, 耗时 {elapsed:.2f}s)") def export_json(output_path: str): """导出所有表数据为 JSON 格式(便于跨平台传输/迁移)。""" print(f"[export] 正在导出数据到: {output_path}") conn = _connect() result = { "_meta": { "exported_at": datetime.now(timezone.utc).isoformat(), "db_path": _get_db_path(), "sqlite_version": sqlite3.sqlite_version, }, "tables": {} } try: # 获取所有用户表 tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" ).fetchall() for (table_name,) in tables: # 获取列信息 columns = [desc[1] for desc in conn.execute(f"PRAGMA table_info({table_name})").fetchall()] # 获取行数 count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] # 获取所有数据 rows = conn.execute(f"SELECT * FROM [{table_name}]").fetchall() data = [dict(row) for row in rows] result["tables"][table_name] = { "columns": columns, "row_count": count, "data": data, } print(f"[export] 表 {table_name}: {count} 行") # 同时导出 schema schema = [] for (table_name,) in tables: ddl = conn.execute( "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,) ).fetchone()[0] if ddl: schema.append(ddl + ";") result["_meta"]["schema"] = schema finally: conn.close() with open(output_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2, default=str) size_mb = os.path.getsize(output_path) / (1024 * 1024) print(f"[export] 导出完成: {output_path} ({size_mb:.2f} MB)") def import_json(input_path: str): """从 JSON 文件导入数据(注意:会清空现有数据!)。""" print(f"[import] 正在从 {input_path} 导入数据") if not os.path.exists(input_path): print(f"[import] 错误: 文件不存在: {input_path}", file=sys.stderr) sys.exit(1) with open(input_path, "r", encoding="utf-8") as f: data = json.load(f) conn = _connect() try: # 是否清空现有表(需要确认) tables = data.get("tables", {}) if tables: print(f"[import] 警告: 即将导入 {len(tables)} 个表,现有数据将被覆盖!") # 为安全起见,不自动清空,由用户先备份 # 按外键依赖顺序导入(先主表后子表,这里简单按名字排序) for table_name in sorted(tables.keys()): table_data = tables[table_name] rows = table_data.get("data", []) columns = table_data.get("columns", []) if not rows or not columns: print(f"[import] 跳表 {table_name}: 无数据或列定义") continue # 先清空表 conn.execute(f"DELETE FROM [{table_name}]") # 批量插入 placeholders = ", ".join(["?"] * len(columns)) col_names = ", ".join(f"[{c}]" for c in columns) insert_sql = f"INSERT INTO [{table_name}] ({col_names}) VALUES ({placeholders})" conn.executemany(insert_sql, [tuple(row.get(col) for col in columns) for row in rows]) print(f"[import] 表 {table_name}: 导入 {len(rows)} 行") conn.commit() print("[import] 导入完成") else: print("[import] 没有可导入的表数据") finally: conn.close() def vacuum(): """执行 VACUUM 压缩数据库,回收空间、重建索引。""" print("[vacuum] 正在执行 VACUUM...") db_path = _get_db_path() before_size = os.path.getsize(db_path) / (1024 * 1024) conn = _connect() try: # WAL 检查点先将所有数据写入主库 conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.execute("VACUUM") conn.execute("ANALYZE") finally: conn.close() after_size = os.path.getsize(db_path) / (1024 * 1024) saved = before_size - after_size print(f"[vacuum] 完成: {before_size:.2f} MB -> {after_size:.2f} MB (节省 {saved:.2f} MB)") def wal_checkpoint(): """强制 WAL 检查点,确保所有写入都持久化到主数据库文件。""" print("[wal-checkpoint] 执行 WAL 检查点...") conn = _connect() try: result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() # busy, log, checkpointed print(f"[wal-checkpoint] 结果: busy={result[0]}, log_pages={result[1]}, checkpointed={result[2]}") finally: conn.close() print("[wal-checkpoint] 完成") def show_info(): """显示数据库信息和表统计。""" db_path = _get_db_path() print("=" * 50) print("SQLite 数据库信息") print("=" * 50) print(f"文件路径: {os.path.abspath(db_path)}") if os.path.exists(db_path): size_mb = os.path.getsize(db_path) / (1024 * 1024) mtime = datetime.fromtimestamp(os.path.getmtime(db_path), tz=timezone.utc) print(f"文件大小: {size_mb:.2f} MB") print(f"最后修改: {mtime.isoformat()}") else: print("文件大小: (不存在)") # WAL 文件 wal_path = db_path + "-wal" shm_path = db_path + "-shm" if os.path.exists(wal_path): wal_size = os.path.getsize(wal_path) / 1024 print(f"WAL 文件: {wal_size:.2f} KB") if os.path.exists(shm_path): shm_size = os.path.getsize(shm_path) / 1024 print(f"SHM 文件: {shm_size:.2f} KB") conn = _connect() try: print(f"SQLite 版本: {sqlite3.sqlite_version}") # PRAGMA 信息 journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0] synchronous = conn.execute("PRAGMA synchronous").fetchone()[0] page_size = conn.execute("PRAGMA page_size").fetchone()[0] page_count = conn.execute("PRAGMA page_count").fetchone()[0] free_pages = conn.execute("PRAGMA freelist_count").fetchone()[0] print(f"Journal 模式: {journal_mode}") print(f"同步级别: {synchronous}") print(f"页大小: {page_size} bytes") # 表统计 tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ).fetchall() print(f"\n表统计 ({len(tables)} 个表):") print("-" * 40) total_rows = 0 for (table_name,) in tables: count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0] total_rows += count print(f" {table_name:20s} {count:>8d} 行") print("-" * 40) print(f" {'总计':20s} {total_rows:>8d} 行") finally: conn.close() def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) cmd = sys.argv[1] if cmd == "backup": output = sys.argv[2] if len(sys.argv) > 2 else f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db" backup(output) elif cmd == "export": output = sys.argv[2] if len(sys.argv) > 2 else f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" export_json(output) elif cmd == "import": if len(sys.argv) < 3: print("用法: python db_tools.py import ", file=sys.stderr) sys.exit(1) import_json(sys.argv[2]) elif cmd == "vacuum": vacuum() elif cmd == "wal-checkpoint": wal_checkpoint() elif cmd == "info": show_info() else: print(f"未知命令: {cmd}", file=sys.stderr) print(__doc__) sys.exit(1) if __name__ == "__main__": main()