Access .mdb 老库迁移到 MySQL:从单机文件到生产库的完整流程
场景:一个跑了十几年的 Access(.mdb)单机系统要退役,新系统是 Spring Boot + MyBatis + MySQL。
老库里近万条历史业务数据,直接丢掉业务方不认,必须原样搬进新库,而且统计报表要能对得上。
这篇按真实项目梳理完整流程:读取 → 字段映射 → 一拆多表 → 幂等写入 → 校验,附可直接套用的 Python 代码。
一、先想清楚:迁移不是"导表"
很多人第一步就打开 Access 的"导出"功能,把表 dump 成 CSV 再 LOAD DATA INFILE。
绝大多数情况会失败,原因有三个:
- 新旧表结构不是一一对应的。老库是一张大宽表,新系统按范式拆成了 6 张表(主表 / 基本信息 / 详情 / 明细条目 / 费用 / 字典关系)。
- 字典值对不上 。老库是自由文本或自定义编号,新库是带
id的配置表。 - 老库有脏数据。占位日期、空标题、重复编号、超长文本......直接导会在某些列上炸掉。
所以正确姿势是:写一个迁移脚本,把老库读成内存对象,按业务规则拆解、映射、清洗后,用事务写进 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 件事
-
老主键要不要沿用?
沿用最省事,关联不用做映射;但要确认新库自增区间不冲突。冲突就必须建映射表,工作量翻倍。
-
脏数据是修还是留?
建议原样保留 + 统计层过滤。占位日期、空标题、重复编号,业务方可能就是想看到原貌;擅自"洗干净"会导致和老系统对不上账。
-
老库的人员要不要能登录?
要就得批量建账号、配角色、给初始密码、要求首次登录改密。这步经常被漏,上线后才发现历史单没人认领。
-
多租户怎么隔离?
脚本里的租户 id 必须来自配置,绝不能写死在 SQL 里;清理数据同样要带租户条件,否则会删到别人的库。
-
多大算一次提交?
整体一个事务 + 内部分批最简单;到十万级以上改成每 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 的技术门槛不高,真正决定成败的是三件事:
- 映射表写清楚,和业务方逐字段确认;
- 干跑 + 事务 + 幂等清理,让脚本可以放心重跑;
- 校验 + 报表对齐,数字能对上才算迁完。
老库的历史数据是业务资产,迁一次就要迁干净。觉得有帮助欢迎点赞收藏。
原创,转载请注明出处。