.mdb Access_MDB历史数据文件迁移MySQL实战教程-可直接投喂给ai参考这个帮你完成迁移

Access .mdb 老库迁移到 MySQL:从单机文件到生产库的完整流程

场景:一个跑了十几年的 Access(.mdb)单机系统要退役,新系统是 Spring Boot + MyBatis + MySQL。

老库里近万条历史业务数据,直接丢掉业务方不认,必须原样搬进新库,而且统计报表要能对得上。

这篇按真实项目梳理完整流程:读取 → 字段映射 → 一拆多表 → 幂等写入 → 校验,附可直接套用的 Python 代码。


一、先想清楚:迁移不是"导表"

很多人第一步就打开 Access 的"导出"功能,把表 dump 成 CSV 再 LOAD DATA INFILE。

绝大多数情况会失败,原因有三个:

  1. 新旧表结构不是一一对应的。老库是一张大宽表,新系统按范式拆成了 6 张表(主表 / 基本信息 / 详情 / 明细条目 / 费用 / 字典关系)。
  2. 字典值对不上 。老库是自由文本或自定义编号,新库是带 id 的配置表。
  3. 老库有脏数据。占位日期、空标题、重复编号、超长文本......直接导会在某些列上炸掉。

所以正确姿势是:写一个迁移脚本,把老库读成内存对象,按业务规则拆解、映射、清洗后,用事务写进 MySQL。

复制代码
Access .mdb
   │  pyodbc 只读连接
   ▼
内存 rows(dict 列表)
   │  字段映射 + 清洗 + 字典翻译
   ▼
6 个列表:主表 / 基本信息 / 详情 / 明细 / 费用 / 关系
   │  分批 executemany(事务包裹)
   ▼
MySQL 多张表  ──►  count(*) 校验 + 报表抽样比对

二、环境准备(Windows 坑最多的地方)

powershell 复制代码
pip install pyodbc pymysql

只装 Python 包还不够,必须装 Microsoft Access Database Engine ODBC 驱动,否则会报:

复制代码
pyodbc.Error: ('IM002', '[IM002] [Microsoft][ODBC 驱动程序管理器] 未发现数据源名称并且未指定默认驱动程序')

搜索下载 "Microsoft Access Database Engine 2016 Redistributable",安装时注意 位数要和 Python 一致。

powershell 复制代码
python -c "import struct; print(struct.calcsize('P') * 8)"   # 64 或 32
  • 64 位 Python 装 x64 驱动,32 位 Python 装 x86。
  • 机器上装过 Office 的话通常自带一个版本,版本打架会报 Clsid not registered。
  • 装完先验证连通性:
python 复制代码
import pyodbc
conn = pyodbc.connect(
    "DRIVER={Microsoft Access Driver (*.mdb, *.accdb)};DBQ=D:\\path\\to\\legacy.mdb;"
)
print(conn.cursor().execute("SELECT COUNT(*) FROM [form]").fetchone())
conn.close()

Access ODBC 驱动只有 Windows 版,迁移脚本在开发机上跑,不要想着部署到 Linux 服务器。

怎么确认这个 .mdb 是什么来头

文件头就能看出来,不用装任何工具:

python 复制代码
with open(r"D:\data\legacy.mdb", "rb") as f:
    head = f.read(21)
print(head[4:20])        # b'Standard Jet DB'  → Jet 引擎
print("版本字节:", head[20])   # 00 = Jet 3.x(Access 97) / 01 = Jet 4.0(Access 2000 及以后)

只要是 Standard Jet DB + 版本字节 01,就是 Access 2000~2016 生成的 .mdb(Jet 4.0,页大小 4096,Unicode 存储);.accdb 则是另一套 ACE 引擎。搞清楚版本能少走很多弯路------Jet 3 的文本是单字节的,处理中文的方式完全不同。


三、读取 .mdb:全部拉进内存

文件型数据库,数据量不到一万条时一把读完就行:

python 复制代码
def load_mdb(mdb_path):
    conn = pyodbc.connect(
        "DRIVER={Microsoft Access Driver (*.mdb, *.accdb)};DBQ=" + mdb_path + ";"
    )
    cur = conn.cursor()

    # 主业务表按主键排序,保证多次执行顺序一致
    cur.execute("SELECT * FROM [form] ORDER BY fid")
    cols = [d[0] for d in cur.description]      # 字段名
    rows = [dict(zip(cols, r)) for r in cur.fetchall()]

    # 字典表:编号 → 名称
    cur.execute("SELECT code, cname FROM [dict]")
    dicts = {str(c).strip(): str(n).strip() for c, n in cur.fetchall()}

    conn.close()
    return rows, dicts

要点:

  • Access 保留字要用 [] 包起来([new]、[user]、[order] 都是保留字)。
  • dict(zip(cols, r)) 转字典,后面按名字取值,比按下标健壮得多。
  • 先看字段清单再 SELECT *:老库字段名常常拼错或带缩写,照抄原名即可,别自作主张改。

编码与脏字符

ODBC 读出来的内容偶尔是 bytes 或带不可见字符,统一洗一遍:

python 复制代码
def clean(v):
    if v is None:
        return ""
    if isinstance(v, bytes):
        v = v.decode("utf-8", "ignore")
    s = str(v)
    s = s.replace("\x00", "").replace("\r\n", "\n").replace("\r", "\n")
    s = s.replace("\u3000", " ")        # 全角空格
    return s.strip()

超长文本必须截断 ,否则 MySQL 报 Data too long,整批回滚:

python 复制代码
def trunc(v, n):
    s = clean(v)
    return s[:n] if s else ""

日期也要单独处理,老库缺失值常落成 1900 年前后的占位日期,甚至是"基准日期":

python 复制代码
def parse_dt(v):
    if isinstance(v, datetime.datetime):
        return v
    s = clean(v)
    m = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?", s)
    if not m:
        return None
    try:
        return datetime.datetime(*[int(x) for x in m.groups() if x is not None])
    except ValueError:
        return None

处理原则:原样保留,统计层过滤。 迁移时偷偷改数据,最后会和老系统报表对不上。

sql 复制代码
SELECT YEAR(create_time) AS y, COUNT(*)
FROM biz_entrust_main
WHERE tenant_id = #{tenantId}
  AND create_time > '1901-01-01'
GROUP BY y ORDER BY y;

四、字段映射:老宽表 → 新多表

最花时间、也最容易出错的一步。先做一张映射表,和业务方逐行确认签字:

老字段(示例) 新表.字段 处理方式
fid biz_entrust_main.id 主键沿用,避免关联错乱
pname biz_project_info.title 清洗空字符、截断
ptype biz_project_info.priority_id 字典翻译成配置表 id
note biz_tech_note.detail 长文本,截断后写入
summary biz_tech_note.detail + biz_keyword_point.point 一个字段拆两处
cls biz_entrust_main.biz_type 按单号前缀识别类型
handler_name(姓名文本) biz_entrust_main.handler_id 姓名 → 账号 id 反查
org_name biz_entrust_main.org_name + org_id 保留原文,另写统一机构 id

三种典型映射的写法

1)字典翻译:配置表里没有就补一条

python 复制代码
def ensure_dict(cur, tenant_id, owner_id, names):
    ids = {}
    for name in names:
        cur.execute(
            "SELECT id FROM biz_dict_config WHERE tenant_id=%s AND name=%s LIMIT 1",
            (tenant_id, name))
        r = cur.fetchone()
        if r:
            ids[name] = int(r[0])
            continue
        cur.execute(
            """INSERT INTO biz_dict_config (name, create_time, update_time, create_user_id, tenant_id)
               VALUES (%s, NOW(), NOW(), %s, %s)""",
            (name, owner_id, tenant_id))
        ids[name] = int(cur.lastrowid)
    return ids

2)老字典表 → 新 id:优先按名称对齐,编号兜底

python 复制代码
def fetch_dict_map(cur, legacy_dict, tenant_id):
    cur.execute("SELECT id, name FROM biz_tag_config WHERE tenant_id=%s AND type=1", (tenant_id,))
    by_name = {str(n).strip(): int(i) for i, n in cur.fetchall()}

    code_to_id = {}
    for code, name in legacy_dict.items():
        sid = by_name.get(name)
        if sid is None:
            # 名称对不上时,尝试按"老编号区间"映射到新编号区间
            try:
                cand = int(code) - OFFSET
                if 1 <= cand <= TOTAL:
                    sid = cand
            except ValueError:
                sid = None
        if sid is not None:
            code_to_id[code] = sid
    return code_to_id

名称对齐优先、编号兜底,是因为人工维护的字典经常出现改名、加空格、全半角混用,纯编号映射很容易整体错位。

3)历史人员:姓名 → 可登录账号

老库里处理人就是一串中文姓名,新库按 user_id 关联。先按姓名反查,查不到就建历史账号:

python 复制代码
def ensure_hist_users(cur, names, tenant_id, role_id, pwd_hash):
    cur.execute(
        "SELECT user_id, full_name, username FROM sys_user WHERE tenant_id=%s",
        (tenant_id,))
    by_name, usernames = {}, set()
    for uid, full_name, username in cur.fetchall():
        if full_name:
            by_name[str(full_name).strip()] = int(uid)
        if username:
            usernames.add(str(username).strip())

    created = []
    for name in sorted(names):
        if name in by_name:
            continue
        username = make_slug(name)                 # 拼音/工号生成登录名
        while username in usernames:               # 防重
            username += "_2"
        usernames.add(username)
        cur.execute(
            """INSERT INTO sys_user (username, password, status, full_name,
                 tenant_id, create_time, create_user_id, type, sys_type)
               VALUES (%s, %s, 1, %s, %s, NOW(), %s, 1, 1)""",
            (username, pwd_hash, name, tenant_id, owner_id))
        uid = int(cur.lastrowid)
        cur.execute("INSERT INTO sys_user_role (user_id, role_id) VALUES (%s, %s)",
                    (uid, role_id))
        by_name[name] = uid
        created.append((uid, username, name))
    return by_name, created

密码存储务必先摸清现有系统的口径:很多老系统存的不是"明文的 SHA256",而是"前端加密后的密文再哈希"。不搞清楚就批量生成账号,会出现"账号建好了但谁都登不进去"的尴尬。稳妥做法是先用一个测试账号登录验证哈希链路,再批量初始化。


五、一拆多:一条老记录写 6 张表

python 复制代码
def build_data(rows, code_to_id, priority_ids, user_map, tenant_id):
    mains, infos, notes, points, fees, relations = [], [], [], [], [], []

    for row in rows:
        fid = int(row["fid"])
        cls = clean(row.get("cls"))
        # 老库没有"业务类型"字段,按单号前缀识别(虚构示例)
        biz_type = TYPE_MAP.get(cls[:2].upper(), 1)

        create_time = parse_dt(row.get("opentime")) or datetime.datetime(1900, 1, 1)

        mains.append((
            fid, clean(row.get("client")), clean(row.get("linkman")),
            biz_type, create_time, tenant_id,
            trunc(clean(row.get("org_name")), 200),
        ))

        infos.append((
            fid,                                    # 关联主表 id
            trunc(clean(row.get("pname")), 500),
            priority_ids.get(clean(row.get("ptype")), 0),
        ))

        notes.append((
            fid,
            trunc(clean(row.get("note")), 6000),
            trunc(clean(row.get("keyword")), 2000),
        ))

        # 费用:空值按 0,别把 None 写进 DECIMAL 列
        fee = parse_num(row.get("amount")) or 0
        fees.append((fid, fee, fee))

        # 明细条目:老库一格里可能塞了多条,按分隔符拆
        for p in split_points(clean(row.get("summary"))):
            points.append((fid, trunc(p, 2000), create_time))

        # 分类多对多
        for code in split_codes(clean(row.get("tags"))):
            tid = code_to_id.get(code)
            if tid:
                relations.append((fid, tid, create_time))

    return mains, infos, notes, points, fees, relations

六、写入:分批 + 事务 + 幂等

上万条 × 6 张表 = 几万行,分批更稳:

python 复制代码
BATCH = 500

def insert_batches(cur, sql, data):
    for i in range(0, len(data), BATCH):
        cur.executemany(sql, data[i:i + BATCH])

幂等:允许放心重跑

映射逻辑一定会反复改,按主键区间先清理本工具写入的数据,就能随便重跑:

python 复制代码
def cleanup_import(cur, min_id, max_id, tenant_id):
    # 先子表后主表,否则会留下孤儿数据
    for sql in (
        "DELETE FROM biz_entrust_tag     WHERE entrust_id BETWEEN %s AND %s",
        "DELETE FROM biz_keyword_point   WHERE entrust_id BETWEEN %s AND %s",
        "DELETE FROM biz_tech_note       WHERE entrust_id BETWEEN %s AND %s",
        "DELETE FROM biz_project_info    WHERE entrust_id BETWEEN %s AND %s",
        "DELETE FROM biz_fee             WHERE entrust_id BETWEEN %s AND %s",
    ):
        cur.execute(sql, (min_id, max_id))
    # 主表一定要带租户条件,别删到别的租户
    cur.execute(
        "DELETE FROM biz_entrust_main WHERE id BETWEEN %s AND %s AND tenant_id=%s",
        (min_id, max_id, tenant_id))

执行前务必确认 ID 区间:先查老库主键范围和新库自增水位,两者不能重叠;重叠就得做映射表(工作量翻倍,尽量避免)。区间建议提前用 SQL 查出来写死在配置里,不要靠猜。

事务:要么全成,要么全回滚

python 复制代码
def execute_import(reset=True):
    conn = pymysql.connect(autocommit=False, **DB_CONF)
    try:
        cur = conn.cursor()
        priority_ids = ensure_dict(cur, tenant_id, owner_id, PRIORITY_NAMES)
        user_map     = ensure_hist_users(cur, names, tenant_id, role_id, pwd_hash)
        code_to_id   = fetch_dict_map(cur, legacy_dict, tenant_id)

        mains, infos, notes, points, fees, relations = build_data(
            rows, code_to_id, priority_ids, user_map, tenant_id)

        if reset:
            cleanup_import(cur, MIN_ID, MAX_ID, tenant_id)

        insert_batches(cur, "INSERT INTO biz_entrust_main (...) VALUES (...)", mains)
        insert_batches(cur, "INSERT INTO biz_project_info (...) VALUES (...)", infos)
        insert_batches(cur, "INSERT INTO biz_tech_note (...) VALUES (...)", notes)
        insert_batches(cur, "INSERT INTO biz_keyword_point (...) VALUES (...)", points)
        insert_batches(cur, "INSERT INTO biz_fee (...) VALUES (...)", fees)
        insert_batches(cur, "INSERT IGNORE INTO biz_entrust_tag (...) VALUES (...)", relations)

        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

INSERT IGNORE 只用在关系表上(同一主记录可能被拆出重复关系),业务主表别用它静默吞错。

干跑模式(强烈建议)

python 复制代码
parser = argparse.ArgumentParser()
parser.add_argument("--execute", action="store_true", help="正式写库")
parser.add_argument("--no-reset", action="store_true", help="不清理已导入数据")
args = parser.parse_args()

rows, legacy_dict = load_mdb()
if not args.execute:
    data = dry_run(rows, legacy_dict)     # 只读、只统计、打印样例
    json.dump(data, open("import_report.json", "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, default=str)
    print("dry-run report: import_report.json")
else:
    execute_import(reset=not args.no_reset)
    verify()

干跑能提前暴露:字段名拼错、类型转换失败、字典对不上、要新建多少账号。

跑通了再加 --execute。

注意 --no-reset 这种"不清旧数据直接插"的模式,只在明确知道上次结果干净时才用,否则必然产生重复行。


七、校验:数字对不上等于白迁

python 复制代码
def verify():
    conn = pymysql.connect(**DB_CONF)
    cur = conn.cursor()
    checks = {}

    cur.execute("SELECT COUNT(*) FROM biz_entrust_main WHERE tenant_id=%s", (tenant_id,))
    checks["main"] = cur.fetchone()[0]

    for table in ("biz_project_info", "biz_tech_note", "biz_keyword_point", "biz_fee"):
        cur.execute(
            "SELECT COUNT(*) FROM %s WHERE entrust_id BETWEEN %%s AND %%s" % table,
            (MIN_ID, MAX_ID))
        checks[table] = cur.fetchone()[0]

    return checks

再从四个维度抽查,比"总数相等"更早发现问题:

sql 复制代码
-- 1. 分类统计是否合理(有没有全挤到一个类型里)
SELECT biz_type, COUNT(*) FROM biz_entrust_main
WHERE tenant_id = #{tenantId} GROUP BY biz_type;

-- 2. 时间跨度是否覆盖老库
SELECT MIN(create_time), MAX(create_time)
FROM biz_entrust_main WHERE tenant_id = #{tenantId};

-- 3. 关联完整性:主表有、子表没有的"断头"数据
SELECT m.id FROM biz_entrust_main m
LEFT JOIN biz_project_info p ON p.entrust_id = m.id
WHERE p.id IS NULL AND m.tenant_id = #{tenantId};

-- 4. 是否误伤别的租户
SELECT tenant_id, COUNT(*) FROM biz_entrust_main GROUP BY tenant_id;

最后启动应用冒烟:列表能出、详情能开、统计数字和老系统报表对得上。三类页面都点一遍,比任何单元测试都管用。


八、必须提前和业务方确认的 5 件事

  1. 老主键要不要沿用?

    沿用最省事,关联不用做映射;但要确认新库自增区间不冲突。冲突就必须建映射表,工作量翻倍。

  2. 脏数据是修还是留?

    建议原样保留 + 统计层过滤。占位日期、空标题、重复编号,业务方可能就是想看到原貌;擅自"洗干净"会导致和老系统对不上账。

  3. 老库的人员要不要能登录?

    要就得批量建账号、配角色、给初始密码、要求首次登录改密。这步经常被漏,上线后才发现历史单没人认领。

  4. 多租户怎么隔离?

    脚本里的租户 id 必须来自配置,绝不能写死在 SQL 里;清理数据同样要带租户条件,否则会删到别人的库。

  5. 多大算一次提交?

    整体一个事务 + 内部分批最简单;到十万级以上改成每 N 批提交一次,并记录断点位置支持续传。


九、上线 checklist

  • 迁移前全库备份,SQL dump 留档
  • 连接串、账号密码走环境变量或外部配置,不进代码仓库
  • 脚本默认干跑,必须显式 --execute 才写库
  • 幂等清理只删本工具写入的 ID 区间,且带租户条件
  • 删除/批量更新在事务里执行,打印影响行数
  • 校验:主表数、子表数、关联完整性、租户分布
  • 应用冒烟:列表 / 详情 / 统计
  • 统计口径与老系统报表逐项对齐,差异有书面说明
  • 迁移完成后冻结老库(只读),原文件留档备查

十、常见报错速查

报错 原因 解决
IM002 未发现数据源名称 没装 Access ODBC 驱动 / 位数不符 装与 Python 位数一致的驱动
Clsid not registered Office 残留驱动注册表项坏了 重装 Access Database Engine
中文乱码、问号 读到 Jet 3 单字节库,或连接未指定 utf8mb4 确认文件头版本字节;pymysql.connect(charset="utf8mb4")
Data too long for column 老字段比新列长 写入前 trunc() 截断
Incorrect datetime value 占位日期撞上列约束/严格模式 保留原值并放宽列定义,统计层过滤
Duplicate entry ... PRIMARY 老主键与现有数据撞了 提前查 ID 区间,或改用映射表
事务没回滚,库里有半截数据 没关 autocommit 或异常被吞 autocommit=False + except: rollback; raise
重跑出现重复关系行 幂等清理漏了子表 清理按"先子表后主表"全覆盖
账号建好了登不上 密码哈希口径搞错 先用测试账号验证完整哈希链路

小结

Access 迁 MySQL 的技术门槛不高,真正决定成败的是三件事:

  1. 映射表写清楚,和业务方逐字段确认;
  2. 干跑 + 事务 + 幂等清理,让脚本可以放心重跑;
  3. 校验 + 报表对齐,数字能对上才算迁完。

老库的历史数据是业务资产,迁一次就要迁干净。觉得有帮助欢迎点赞收藏。


原创,转载请注明出处。

相关推荐
时间售卖铺1 小时前
MySQL 数据库账户及授权
运维·数据库·mysql·云原生
l1t1 小时前
pgrust v0.4-beta 安装和查询测试
数据库·postgresql·rust·duckdb·pgrust
SelectDB技术团队1 小时前
SelectDB 架构演进与 ClickHouse 迁移指南
大数据·数据库·clickhouse·云原生·架构·数据分析·apache doris
java_nnnn2 小时前
分布式系统进阶:数据库分库分表、微服务与架构演进总结
数据库·redis·微服务·架构
ZENERGY-众壹2 小时前
光伏监控平台实测,三款时序数据库在 50 万测点下的真实表现
数据库·架构·运维开发·时序数据库·光伏运维
2301_32241428048 小时前
基于SpringBoot+Vue的休闲零食超市管理系统的设计与实现59004-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·redis·mysql·决策树·推荐算法
花间相见9 小时前
【计算基础|MySQL13】【工程实战】事务隔离与 MVCC 现象复现
adb
catoop11 小时前
API Key 设计与安全生成方案:结构拆解、数据库设计与 Java 实现
java·数据库·安全