【SQLite → MySQL 数据迁移踩坑实录】
·
SQLite → MySQL 数据迁移踩坑实录
SQLite → MySQL 数据迁移踩坑实录
一、背景与目标
1. 业务场景
前阵子为了回测一个策略,临时将数据都下载到了 SQLite中,主要是方便快捷,但是随着要求的不断增加,还是感觉到诸多不便,为了后续更好的策略分析,准备将数据迁移到 MySQL中,本文仅针对测试数据将存量股票数据从 SQLite 迁移至 MySQL,解决 SQLite 主键约束失效、数据重复、查询性能差的问题,为后续业务提供稳定可靠的结构化数据源。
2. 核心目标
- ✅ 保证数据完整性:无丢失、无冗余
- ✅ 保证数据一致性:SQLite 清洗后与 MySQL 行数完全对齐
- ✅ 保证迁移可重复性:支持多次执行不破坏数据
- ✅ 输出可复用的迁移脚本与文档,方便他人参考
二、迁移方案设计
1. 技术选型
- 源库:SQLite(存量脏数据,无严格主键约束)
- 目标库:MySQL(支持严格主键、事务安全)
- 开发语言:Python(
pandas处理数据、pymysql操作 MySQL) - 核心策略:
- 先清洗 SQLite 重复数据(按
code+date去重) - 分批插入 MySQL,避免单批次数据过大导致超时
- 采用
REPLACE INTO/INSERT保证幂等性 - 最终校验脚本验证全表一致性
- 先清洗 SQLite 重复数据(按
2. 关键 SQL 清洗逻辑
-- SQLite 去重获取有效数据(核心!)
SELECT * FROM daily_k GROUP BY code, date;
三、踩坑实录与解决方案
🚩 坑1:SQLite 主键形同虚设,重复数据泛滥
- 现象:SQLite 总数据 20774 行,MySQL 迁移后仅 15579 行,行数对不上
- 根因:SQLite 未真正 enforce 主键约束,允许同一
code+date重复插入多条数据 - 解决方案:
- 编写重复数据检测脚本,统计重复行数
- 迁移时通过
GROUP BY code, date去重,只保留唯一有效数据 - 最终确认:有效数据为 15579 行,与 MySQL 迁移后行数完全对齐
🚩 坑2:REPLACE INTO 导致“假成功”日志
- 现象:迁移脚本日志显示写入 20774 行,但 MySQL 实际行数不变
- 根因:
REPLACE INTO遇到主键冲突时会覆盖旧数据,不会新增缺失行,无法补全历史数据 - 解决方案:
- 先
TRUNCATE TABLE daily_k清空目标表(仅首次迁移) - 改用纯
INSERT插入去重后数据,保证所有有效行都写入 - 迁移脚本改为“先清空、再插入”模式,确保数据完整
- 先
🚩 坑3:SQL 语法错误(中文符号/表情混入代码)
- 现象:执行迁移脚本时报
near "✅": syntax error - 根因:手滑将中文注释、表情符号写入 SQL 语句中,导致 SQLite 语法解析失败
- 解决方案:
- 迁移脚本中 SQL 部分纯英文,不添加任何中文注释、表情
- 注释统一放在 Python 代码中,避免污染 SQL 语句
- 上线前先在测试环境验证 SQL 语法正确性
🚩 坑4:数字字段空字符串导致类型错误
- 现象:迁移时报
Incorrect integer value: '' for column 'volume' - 根因:SQLite 中
volume/amount等数字字段存在空字符串'',MySQL 不允许插入空字符串到整数/浮点列 - 解决方案:
- 数据清洗时将空字符串
''替换为0 - 用
pd.to_numeric强制转换为数字类型,填充NaN为0 - 保证所有数字字段在插入 MySQL 前为合法数值
- 数据清洗时将空字符串
🚩 坑5:校验脚本配置混乱,多次读取不同配置文件
- 现象:校验脚本有时读
config.py,有时写死配置,导致结果不一致 - 根因:迁移与校验脚本未统一配置来源,代码冗余且易出错
- 解决方案:
- 新建独立配置文件
db_config_migrate.py,仅用于迁移场景 - 所有脚本统一从该配置文件读取 SQLite/MySQL 连接信息
- 避免与项目其他配置文件冲突,保证迁移流程隔离
- 新建独立配置文件
四、核心脚本交付
1. 独立配置文件 db_config_migrate.py
# 仅用于迁移场景,与项目其他配置隔离
SQLITE_DB_PATH = "a_stock_final.db"
MYSQL_CONFIG = {
"host": "localhost",
"user": "root",
"password": "你的MySQL密码",
"database": "a_stock",
"charset": "utf8mb4"
}
2. 全表迁移脚本 migrate_final_all_in_one.py
import sqlite3
import pymysql
import pandas as pd
from db_config_migrate import MYSQL_CONFIG, SQLITE_DB_PATH
BATCH_SIZE = 2000
ALL_TABLE_MAPPING = {
"stock_list": {
"old": "code,code_name,tradeStatus",
"new": "code,code_name,trade_status"
},
"stock_basic": {
"old": "code,code_name,ipoDate,outDate,type,status",
"new": "code,code_name,ipo_date,out_date,type,status"
},
"daily_k": {
"old": "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,pbMRQ,psTTM,pcfNcfTTM,isST",
"new": "trade_date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,trade_status,pct_chg,pe_ttm,pb_mrq,ps_ttm,pcf_ncf_ttm,is_st"
},
"weekly_k": {
"old": "date,code,open,high,low,close,volume,amount,adjustflag,turn,pctChg",
"new": "trade_date,code,open,high,low,close,volume,amount,adjustflag,turn,pct_chg"
},
"monthly_k": {
"old": "date,code,open,high,low,close,volume,amount,adjustflag,turn,pctChg",
"new": "trade_date,code,open,high,low,close,volume,amount,adjustflag,turn,pct_chg"
},
"stock_industry": {
"old": "code,code_name,industry,industryClassification,updateDate",
"new": "code,code_name,industry,industry_classification,update_date"
},
"market_daily": {
"old": "date,up_cnt,down_cnt,limit_up,limit_down,sh_open,sh_pct,sz_open,sz_pct,vol",
"new": "trade_date,up_cnt,down_cnt,limit_up,limit_down,sh_open,sh_pct,sz_open,sz_pct,vol"
}
}
NUM_FIELDS = {
"volume", "amount", "turn", "preclose",
"pct_chg", "pe_ttm", "pb_mrq", "ps_ttm", "pcf_ncf_ttm",
"up_cnt", "down_cnt", "limit_up", "limit_down", "vol"
}
def clean_data(df):
for col in df.columns:
if col in NUM_FIELDS:
df[col] = df[col].replace("", 0)
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
return df
def migrate_table(table, conn_lite, cursor):
print(f"\n=== 迁移 {table} ===")
cfg = ALL_TABLE_MAPPING[table]
old_cols = cfg["old"]
new_cols = cfg["new"]
if table == "daily_k":
sql_read = f"SELECT {old_cols} FROM {table} GROUP BY code, date"
else:
sql_read = f"SELECT {old_cols} FROM {table}"
df = pd.read_sql(sql_read, conn_lite)
df.columns = new_cols.split(",")
df = clean_data(df)
total = len(df)
print(f"读取总数: {total}")
placeholders = ",".join(["%s"] * len(df.columns))
sql = f"REPLACE INTO {table} ({new_cols}) VALUES ({placeholders})"
for i in range(0, total, BATCH_SIZE):
batch = df.iloc[i:i+BATCH_SIZE].values.tolist()
cursor.executemany(sql, batch)
cursor.connection.commit()
print(f" 完成批次: {i} ~ {i+len(batch)}")
print(f"✅ {table} 同步完成!")
def migrate_all():
conn_lite = sqlite3.connect(SQLITE_DB_PATH)
conn_mysql = pymysql.connect(**MYSQL_CONFIG)
cursor = conn_mysql.cursor()
cursor.execute("SET FOREIGN_KEY_CHECKS=0")
cursor.execute("SET UNIQUE_CHECKS=0")
for table in ALL_TABLE_MAPPING:
migrate_table(table, conn_lite, cursor)
cursor.execute("SET FOREIGN_KEY_CHECKS=1")
cursor.execute("SET UNIQUE_CHECKS=1")
conn_lite.close()
conn_mysql.close()
print("\n🎉 所有表同步完成!")
if __name__ == "__main__":
migrate_all()
3. 最终验收报告脚本 final_verify_report.py
import sqlite3
import pymysql
from db_config_migrate import MYSQL_CONFIG, SQLITE_DB_PATH
TABLES = {
"stock_list": "股票列表",
"stock_basic": "股票基本信息",
"daily_k": "日K线数据(去重后)",
"weekly_k": "周K线数据",
"monthly_k": "月K线数据",
"stock_industry": "股票行业",
"market_daily": "市场每日统计"
}
def count_sqlite(table):
conn = sqlite3.connect(SQLITE_DB_PATH)
cur = conn.cursor()
if table == "daily_k":
cur.execute("""
SELECT COUNT(*) FROM (
SELECT 1 FROM daily_k GROUP BY code, date
)
""")
else:
cur.execute(f"SELECT COUNT(*) FROM {table}")
res = cur.fetchone()[0]
conn.close()
return res
def count_mysql(table):
conn = pymysql.connect(**MYSQL_CONFIG)
cur = conn.cursor()
cur.execute(f"SELECT COUNT(*) FROM {table}")
res = cur.fetchone()[0]
conn.close()
return res
def main():
print("=" * 60)
print("📊 数据迁移最终验收报告")
print("=" * 60)
all_pass = True
for table, name in TABLES.items():
s = count_sqlite(table)
m = count_mysql(table)
ok = "✅ 一致" if s == m else "❌ 不一致"
if s != m:
all_pass = False
print(f"{name:<12} | {table:<16} | SQLite:{s:>6} | MySQL:{m:>6} | {ok}")
print("=" * 60)
if all_pass:
print("🎉 最终结论:全部表 100% 一致!迁移圆满成功!")
else:
print("⚠️ 结论:存在不一致表")
print("=" * 60)
if __name__ == "__main__":
main()
五、迁移结果验证
最终验收报告
============================================================
📊 数据迁移最终验收报告
============================================================
股票列表 | stock_list | SQLite: 5193 | MySQL: 5193 | ✅ 一致
股票基本信息 | stock_basic | SQLite: 5193 | MySQL: 5193 | ✅ 一致
日K线数据(去重后)| daily_k | SQLite: 15579 | MySQL: 15579 | ✅ 一致
周K线数据 | weekly_k | SQLite: 0 | MySQL: 0 | ✅ 一致
月K线数据 | monthly_k | SQLite: 0 | MySQL: 0 | ✅ 一致
股票行业 | stock_industry | SQLite: 5101 | MySQL: 5101 | ✅ 一致
市场每日统计 | market_daily | SQLite: 1 | MySQL: 1 | ✅ 一致
============================================================
🎉 最终结论:全部表 100% 一致!迁移圆满成功!
============================================================
六、经验总结
- 源数据校验优先:迁移前务必检查源数据质量,尤其是主键约束、重复数据
- 幂等性设计:迁移脚本需支持多次执行,避免单次失败后数据混乱
- 配置统一管理:避免多套配置文件混用,保证脚本行为一致
- 分步验证:迁移后必须执行全表校验,确保数据无丢失、无冗余
- 日志清晰可追溯:迁移脚本输出详细日志,便于排查问题
更多推荐




所有评论(0)