基于 .ibd 文件恢复 MySQL 数据全流程

.ibd 文件是 InnoDB 存储引擎的核心数据文件,存储表的行数据、索引及元信息。在仅有 .ibd 文件的情况下,可通过以下流程恢复数据。

一、.ibd 文件基础认知

  1. 表空间模式
    • 独立表空间 (MySQL 5.6+ 默认):每张表对应独立 .ibd 文件(如 user.ibd),包含表的完整数据、索引及元数据,默认存储于 datadir/数据库名/ 目录。
    • 系统表空间 (旧模式):所有表数据集中存储于 ibdata1 等共享文件,无独立 .ibd 文件。
  1. 核心特性
    • 二进制格式,需通过 MySQL 命令或专用工具解析;
    • 依赖 redo 日志保障事务安全,通过 undo 日志支持 MVCC;
    • 最小 I/O 单位为 16KB 页,由区(64 页)和段(索引对应的区集合)组成。

二、恢复工具与流程

使用开源工具 ibd2sql 解析 .ibd 文件,支持提取表结构(DDL)和数据(SQL)。

1. 工具准备
ruby 复制代码
# 下载工具
wget https://github.com/ddcw/ibd2sql/archive/refs/heads/ibd2sql-v2.x.zip
unzip ibd2sql-v2.x.zip
cd ibd2sql-ibd2sql-v2.x/

# 确认 Python3 环境
python --version  # 需 Python 3.x
2. 单文件解析
css 复制代码
python3 main.py your_file.ibd --sql --ddl

执行后生成包含表结构和数据的 SQL 文件。

三、批量处理脚本

当存在大量 .ibd 文件时,使用以下脚本批量解析:

python 复制代码
import os
import sys
import subprocess
from datetime import datetime
from pathlib import Path

# ===================== 配置参数(按需修改)=====================
ROOT_DIR = Path(__file__).resolve().parent
SOURCE_IBD_DIR = Path(os.path.expanduser("./input_ibd")).resolve()  # 源 ibd 目录
OUTPUT_SQL_DIR = Path(os.path.expanduser("./output_sql")).resolve()  # 输出 SQL 目录
PYTHON_CMD = sys.executable or "python3"  # 使用当前 python
MAIN_SCRIPT = ROOT_DIR / "main.py"
# ===============================================================

OUTPUT_SQL_DIR.mkdir(parents=True, exist_ok=True)

# 日志文件(macOS 下默认编码为 UTF-8,无需额外设置)
LOG_FILE = OUTPUT_SQL_DIR / f"parse_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"

def log(info, level="INFO"):
    """日志输出(控制台 + 文件)"""
    msg = f"[{level}] [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {info}"
    print(msg)
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(msg + "\n")

def parse_single_ibd(ibd_file_path: Path) -> bool:
    """调用官方 main.py 解析单个 ibd,并将 stdout 写入同名 .sql 文件"""
    table_name = ibd_file_path.stem
    sql_file = OUTPUT_SQL_DIR / f"{table_name}.sql"
    log(f"开始解析表:{table_name}(文件:{ibd_file_path.name})")

    if not MAIN_SCRIPT.exists():
        log(f"❌ 未找到 main.py,期望路径:{MAIN_SCRIPT}", level="ERROR")
        return False

    cmd = [
        PYTHON_CMD,
        str(MAIN_SCRIPT),
        str(ibd_file_path),
        "--sql",
        "--ddl",
    ]

    try:
        with open(sql_file, "w", encoding="utf-8") as sql_fp:
            proc = subprocess.run(
                cmd,
                stdout=sql_fp,
                stderr=subprocess.PIPE,
                text=True,
                cwd=ROOT_DIR,
                check=False,
            )
        if proc.returncode != 0:
            log(f"❌ 解析失败(退出码 {proc.returncode}):{proc.stderr.strip()}", level="ERROR")
            if sql_file.exists():
                sql_file.unlink()
            return False
        if proc.stderr.strip():
            log(f"⚠️  命令警告:{proc.stderr.strip()}", level="WARNING")
        log(f"✅ SQL 生成成功:{sql_file}")
        return True
    except FileNotFoundError as exc:
        log(f"❌ 命令执行失败:{exc}", level="ERROR")
    except Exception as exc:
        log(f"❌ 未知异常:{exc}", level="ERROR")
    return False

if __name__ == "__main__":
    log("=" * 60)
    log("ibd2sql 批量解析任务(macOS 版)启动")
    log(f"源 ibd 目录:{SOURCE_IBD_DIR}")
    log(f"输出 SQL 目录:{OUTPUT_SQL_DIR}")
    log("=" * 60 + "\n")

    # 遍历所有 .ibd 文件(自动过滤非 ibd 文件)
    if not SOURCE_IBD_DIR.is_dir():
        log("⚠️  源目录不存在,请检查配置!", level="ERROR")
        sys.exit(1)

    ibd_files = sorted(SOURCE_IBD_DIR.glob("*.ibd"))
    total_count = len(ibd_files)
    success_count = 0
    fail_count = 0
    fail_tables = []

    if total_count == 0:
        log("⚠️  未发现 ibd 文件,请检查源目录是否正确!", level="WARNING")
        sys.exit(1)

    log(f"发现 {total_count} 个 ibd 文件,开始批量解析...\n")

    for ibd_path in ibd_files:
        if parse_single_ibd(ibd_path):
            success_count += 1
        else:
            fail_count += 1
            fail_tables.append(ibd_path.stem)
        log("-" * 40 + "\n")

    # 输出统计结果
    log("=" * 60)
    log("批量解析任务结束")
    log(f"总文件数:{total_count}")
    log(f"成功解析:{success_count} 个")
    log(f"解析失败:{fail_count} 个")
    if fail_tables:
        log(f"失败表名:{','.join(fail_tables)}", level="ERROR")
    log(f"日志文件:{LOG_FILE}")
    log("=" * 60)

四、SQL 文件修正

解析后的 SQL 可能存在格式问题,需进一步处理:

1. 移除 JSON 字段的字符集声明

MySQL JSON 类型为二进制存储,无需指定字符集/排序规则,脚本如下:

python 复制代码
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
import re

# 默认输出目录,与 batch_ibd2sql.py 保持一致
DEFAULT_OUTPUT_SQL_DIR = Path(__file__).resolve().parent / "output_sql"

JSON_KEYWORD = re.compile(r"\bjson\b", re.IGNORECASE)
CHARSET_PATTERN = re.compile(r"\s+CHARACTER\s+SET\s+\w+", re.IGNORECASE)
COLLATE_PATTERN = re.compile(r"\s+COLLATE\s+\w+", re.IGNORECASE)
COLUMN_NAME_PATTERN = re.compile(r"`([^`]+)`")


@dataclass
class ProcessResult:
    file_path: Path
    columns: list[str]
    modified: bool


def clean_line(line: str) -> tuple[str, str | None]:
    """
    若该行定义 JSON 字段且指定了字符集/排序规则,则移除相关声明。
    返回 (新行内容, 字段名或 None)。
    """
    if not JSON_KEYWORD.search(line):
        return line, None

    has_charset = "CHARACTER SET" in line.upper()
    has_collate = "COLLATE" in line.upper()
    if not (has_charset or has_collate):
        return line, None

    new_line = CHARSET_PATTERN.sub("", line)
    new_line = COLLATE_PATTERN.sub("", new_line)

    if new_line == line:
        return line, None

    match = COLUMN_NAME_PATTERN.search(line)
    column_name = match.group(1) if match else "<unknown>"
    return new_line, column_name


def process_sql_file(file_path: Path, dry_run: bool = False) -> ProcessResult:
    original = file_path.read_text(encoding="utf-8")
    lines = original.splitlines()
    trailing_newline = original.endswith("\n")

    processed_lines = []
    touched_columns: list[str] = []

    for line in lines:
        new_line, column = clean_line(line)
        processed_lines.append(new_line)
        if column is not None:
            touched_columns.append(column)

    modified = bool(touched_columns)
    if modified and not dry_run:
        new_content = "\n".join(processed_lines)
        if trailing_newline:
            new_content += "\n"
        file_path.write_text(new_content, encoding="utf-8")

    return ProcessResult(file_path=file_path, columns=touched_columns, modified=modified)


def iter_sql_files(root_dir: Path):
    for path in sorted(root_dir.rglob("*.sql")):
        if path.is_file():
            yield path


def parse_args():
    parser = argparse.ArgumentParser(
        description="移除 SQL 文件中 JSON 字段的字符集/排序规则声明(MySQL JSON 内置二进制类型无需设定字符集)。"
    )
    parser.add_argument(
        "-t",
        "--target",
        default=str(DEFAULT_OUTPUT_SQL_DIR),
        help="待扫描的 SQL 目录,默认为项目 output_sql",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="仅报告将要修改的内容,不实际写回文件",
    )
    return parser.parse_args()


def main():
    args = parse_args()
    target_dir = Path(args.target).expanduser().resolve()

    if not target_dir.is_dir():
        print(f"[ERROR] 目录不存在:{target_dir}", file=sys.stderr)
        sys.exit(1)

    has_changes = False
    total_files = 0
    total_columns = 0

    for sql_file in iter_sql_files(target_dir):
        total_files += 1
        result = process_sql_file(sql_file, dry_run=args.dry_run)
        if result.modified:
            has_changes = True
            total_columns += len(result.columns)
            state = "DRY-RUN" if args.dry_run else "UPDATED"
            columns_str = ", ".join(result.columns)
            print(f"[{state}] {sql_file} -> {columns_str}")

    if not has_changes:
        print(f"[INFO] 未检测到需要处理的 JSON 字段,扫描文件数:{total_files}")
    else:
        mode = "dry-run" if args.dry_run else "write"
        print(
            f"[SUMMARY] 模式={mode}, 处理文件数={total_files}, 修正字段数={total_columns}"
        )


if __name__ == "__main__":
    main()
2. 修复索引注释格式(可选)

部分表可能存在索引注释缺少 COMMENT 关键字或引号的问题,可使用以下脚本修复(未完全测试,少量异常表建议手动修改):

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
修复 SQL 文件中 KEY/UNIQUE KEY/PRIMARY KEY 注释缺少 COMMENT 关键字的问题
将类似: KEY `xxx` (`column`) 注释内容
修改为: KEY `xxx` (`column`) COMMENT '注释内容'
同时处理: UNIQUE KEY, KEY, PRIMARY KEY 等所有索引定义
"""

import os
import re
import sys
from pathlib import Path
from datetime import datetime
from typing import List, Tuple, Dict

# ===================== 配置参数 =====================
OUTPUT_SQL_DIR = Path("./output_sql").resolve()  # 输出 SQL 目录
# ===============================================================

# 日志文件
LOG_FILE = OUTPUT_SQL_DIR / f"fix_unique_key_comment_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"

def log(info: str, level: str = "INFO"):
    """日志输出(控制台 + 文件)"""
    msg = f"[{level}] [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {info}"
    print(msg)
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(msg + "\n")


def find_key_without_comment(content: str) -> List[Tuple[int, str, str, str]]:
    """
    查找所有缺少 COMMENT 关键字的 KEY 定义(包括 UNIQUE KEY, KEY, PRIMARY KEY)
    
    返回: [(行号, 原始行, KEY类型, 匹配的注释内容), ...]
    """
    issues = []
    lines = content.split('\n')
    
    for line_num, line in enumerate(lines, 1):
        # 跳过已经有 COMMENT 关键字的行
        if 'COMMENT' in line.upper():
            continue
        
        # 检查是否包含 KEY 定义
        if 'KEY' not in line.upper():
            continue
        
        line_upper = line.upper()
        match = None
        key_type = None
        
        # 按优先级匹配:PRIMARY KEY > UNIQUE KEY > KEY
        if 'PRIMARY KEY' in line_upper:
            match = re.search(
                r'(PRIMARY\s+KEY\s+[^)]+))\s+([^,\n]+?)(?=\s*[,)]|\s*$)',
                line,
                re.IGNORECASE
            )
            if match:
                key_type = 'PRIMARY KEY'
        elif 'UNIQUE KEY' in line_upper:
            match = re.search(
                r'(UNIQUE\s+KEY\s+[^)]+))\s+([^,\n]+?)(?=\s*[,)]|\s*$)',
                line,
                re.IGNORECASE
            )
            if match:
                key_type = 'UNIQUE KEY'
        elif re.search(r'\bKEY\s+', line, re.IGNORECASE):
            # 普通 KEY,确保不是 UNIQUE KEY 或 PRIMARY KEY
            match = re.search(
                r'(\bKEY\s+[^)]+))\s+([^,\n]+?)(?=\s*[,)]|\s*$)',
                line,
                re.IGNORECASE
            )
            if match:
                key_type = 'KEY'
        
        if match and key_type:
            comment_text = match.group(2).strip().rstrip(',').strip()
            
            # 排除空注释
            if comment_text:
                issues.append((line_num, line, key_type, comment_text))
    
    return issues


def fix_key_comment(content: str) -> Tuple[str, List[Dict]]:
    """
    修复 KEY 注释问题(包括 UNIQUE KEY, KEY, PRIMARY KEY)
    
    返回: (修复后的内容, 修复记录列表)
    """
    lines = content.split('\n')
    fixes = []
    new_lines = []
    
    for line_num, line in enumerate(lines, 1):
        original_line = line
        fixed = False
        
        # 跳过已经有 COMMENT 关键字的行
        if 'COMMENT' in line.upper():
            new_lines.append(line)
            continue
        
        # 检查是否包含 KEY 定义
        if 'KEY' not in line.upper():
            new_lines.append(line)
            continue
        
        line_upper = line.upper()
        match = None
        key_type = None
        pattern = None
        
        # 按优先级匹配:PRIMARY KEY > UNIQUE KEY > KEY
        if 'PRIMARY KEY' in line_upper:
            pattern = r'(PRIMARY\s+KEY\s+[^)]+))\s+([^,\n]+?)(?=\s*[,)]|\s*$)'
            match = re.search(pattern, line, re.IGNORECASE)
            if match:
                key_type = 'PRIMARY KEY'
        elif 'UNIQUE KEY' in line_upper:
            pattern = r'(UNIQUE\s+KEY\s+[^)]+))\s+([^,\n]+?)(?=\s*[,)]|\s*$)'
            match = re.search(pattern, line, re.IGNORECASE)
            if match:
                key_type = 'UNIQUE KEY'
        elif re.search(r'\bKEY\s+', line, re.IGNORECASE):
            # 普通 KEY,确保不是 UNIQUE KEY 或 PRIMARY KEY
            pattern = r'(\bKEY\s+[^)]+))\s+([^,\n]+?)(?=\s*[,)]|\s*$)'
            match = re.search(pattern, line, re.IGNORECASE)
            if match:
                key_type = 'KEY'
        
        if match and key_type and pattern:
            key_def = match.group(1)
            comment_text = match.group(2).strip()
            
            # 排除空注释或只是空白字符
            if comment_text and comment_text.strip():
                # 移除末尾的逗号(如果有)
                comment_text = comment_text.rstrip(',').strip()
                
                # 检查注释是否已经用引号包裹
                if not (comment_text.startswith("'") and comment_text.endswith("'")):
                    # 转义单引号
                    escaped_comment = comment_text.replace("'", "''")
                    # 用单引号包裹
                    quoted_comment = f"'{escaped_comment}'"
                else:
                    quoted_comment = comment_text
                
                # 使用正则替换
                # 匹配: KEY ... ) 注释内容 [逗号或行尾]
                # 替换为: KEY ... ) COMMENT '注释内容' [逗号或行尾]
                new_line = re.sub(
                    pattern,
                    rf'\1 COMMENT {quoted_comment}',
                    line,
                    count=1,
                    flags=re.IGNORECASE
                )
                
                # 确保行尾的逗号被保留(如果原来有的话)
                if line.rstrip().endswith(',') and not new_line.rstrip().endswith(','):
                    new_line = new_line.rstrip() + ','
                
                fixes.append({
                    'line': line_num,
                    'key_type': key_type,
                    'original': original_line.strip(),
                    'fixed': new_line.strip(),
                    'comment': quoted_comment
                })
                
                new_lines.append(new_line)
                fixed = True
        
        if not fixed:
            new_lines.append(line)
    
    return '\n'.join(new_lines), fixes


def process_sql_file(sql_file: Path) -> Dict:
    """处理单个 SQL 文件"""
    result = {
        'file': str(sql_file),
        'fixed': False,
        'fixes': []
    }
    
    try:
        with open(sql_file, 'r', encoding='utf-8') as f:
            content = f.read()
        
        # 查找问题
        issues = find_key_without_comment(content)
        
        if issues:
            # 修复问题
            new_content, fixes = fix_key_comment(content)
            
            if fixes:
                # 写回文件
                with open(sql_file, 'w', encoding='utf-8') as f:
                    f.write(new_content)
                
                result['fixed'] = True
                result['fixes'] = fixes
                
                log(f"✅ 修复文件: {sql_file.name}")
                for fix in fixes:
                    key_type = fix.get('key_type', 'KEY')
                    log(f"   行 {fix['line']} ({key_type}): {fix['original']}")
                    log(f"   -> {fix['fixed']}")
            else:
                log(f"⚠️  发现问题但未修复: {sql_file.name}")
        else:
            log(f"✓  无问题: {sql_file.name}")
    
    except Exception as e:
        log(f"❌ 处理文件失败 {sql_file.name}: {str(e)}", level="ERROR")
        result['error'] = str(e)
    
    return result


def main():
    """主函数"""
    log("=" * 60)
    log("KEY/UNIQUE KEY/PRIMARY KEY 注释修复任务启动")
    log(f"扫描目录:{OUTPUT_SQL_DIR}")
    log("=" * 60 + "\n")
    
    if not OUTPUT_SQL_DIR.exists():
        log(f"❌ 目录不存在:{OUTPUT_SQL_DIR}", level="ERROR")
        sys.exit(1)
    
    # 查找所有 SQL 文件
    sql_files = sorted(OUTPUT_SQL_DIR.glob("*.sql"))
    total_count = len(sql_files)
    
    if total_count == 0:
        log("⚠️  未发现 SQL 文件", level="WARNING")
        sys.exit(1)
    
    log(f"发现 {total_count} 个 SQL 文件,开始扫描...\n")
    
    # 统计信息
    fixed_files = []
    total_fixes = 0
    all_fixes_detail = []
    
        # 处理每个文件
    for sql_file in sql_files:
        result = process_sql_file(sql_file)
        if result['fixed']:
            fixed_files.append(result['file'])
            total_fixes += len(result['fixes'])
            # 为每个修复添加文件信息
            for fix in result['fixes']:
                fix['file'] = result['file']
            all_fixes_detail.extend(result['fixes'])
        log("-" * 40 + "\n")
    
    # 输出统计结果
    log("=" * 60)
    log("修复任务结束")
    log(f"总文件数:{total_count}")
    log(f"修复文件数:{len(fixed_files)}")
    log(f"总修复数:{total_fixes}")
    log("=" * 60)
    
    # 输出详细修复信息
    if all_fixes_detail:
        log("\n详细修复信息:")
        log("=" * 60)
        for fix in all_fixes_detail:
            # 从文件路径中提取文件名
            file_name = Path(fix.get('file', '')).name if 'file' in fix else 'unknown'
            key_type = fix.get('key_type', 'KEY')
            log(f"\n文件: {file_name}")
            log(f"行号: {fix['line']}")
            log(f"类型: {key_type}")
            log(f"原始: {fix['original']}")
            log(f"修复: {fix['fixed']}")
            log(f"注释: {fix['comment']}")
        log("=" * 60)
    
    log(f"\n日志文件:{LOG_FILE}")


if __name__ == "__main__":
    main()

总结

通过 ibd2sql 工具结合批量处理脚本,可高效恢复 .ibd 文件中的数据。解析后需注意修正 JSON 字段格式及索引注释问题,少量异常表建议手动调整以确保数据完整性。

相关推荐
2509_940880221 小时前
springboot集成onlyoffice(部署+开发)
java·spring boot·后端
Cache技术分享1 小时前
247. Java 集合 - 为什么要远离 Stack 类?
前端·后端
BingoGo1 小时前
# 9 个步骤教你如何安全地迁移数据库或字段
后端·php
v***91301 小时前
Spring+Quartz实现定时任务的配置方法
android·前端·后端
海奥华21 小时前
分库分表技术详解:从入门到实践
数据库·后端·mysql·golang
油丶酸萝卜别吃2 小时前
GitHub 上查找中国乡镇经纬度范围数据的开源项目
git·github
IUGEI2 小时前
【后端开发笔记】JVM底层原理-内存结构篇
java·jvm·笔记·后端
郭小铭2 小时前
React Suite v6:面向现代化的稳健升级
react.js·前端框架·github
i***39582 小时前
Springboot中SLF4J详解
java·spring boot·后端