
1. 把一张表从 A 库搬到 B 库SQLite 里的第一道难关SQLite 这玩意儿看起来简单——一个文件就是一个库拷来拷去似乎就完事了——但真到要把其中某几张表导入到另一个数据库时很多人第一反应是打开图形工具右键导出、右键导入或者干脆导个 CSV 中转一下。我最早也是这么干的直到遇到一张带 BLOB 字段和长文本的表CSV 走了一圈回来图片二进制变成了乱码文本里边的换行把行结构撑得七零八落才发现这条路根本没想象中稳。这篇东西聊的就是 SQLite 里把数据库的表导入到另一个数据库这件事。它属于典型的一看就会、一动手就出问题的操作表结构完全一致时一条 SQL 就搞定表结构稍有差异或者数据量大一点、约束多一点就会撞上各种报错。写这篇的目标很明确——把 ATTACH 这条主线讲透把命令行和图形工具这两条辅线的适用边界划清楚再把我在实际搬运过程中踩过的坑按排查顺序摊开让刚接触 SQLite 的读者能直接照着做也让已经用过 ATTACH 的人少走几段弯路。需要说明的是下面提到的一些参数取值、性能数量级是基于常见机械盘和普通 SSD 环境下的经验补充不同机器、不同数据分布会有出入你按同样的思路自己测一遍才作数。1.1 单文件数据库的边界到底在哪理解搬运方案之前得先接受一个事实SQLite 没有实例这个概念。一个 .db 文件里装了这个库的全部东西——表结构、数据、索引、触发器、视图甚至连自增序列的状态都存在里面的一张内部表里。你在 MySQL 里可以写db2.tbl直接跨库查是因为服务端进程同时挂着多个库SQLite 里如果你不主动把第二个文件挂进来它对你来说根本不存在写db2.tbl只会得到一个no such table。这个设计带来两个直接后果。第一所有跨库操作都必须先建立挂载关系没有例外。第二所谓导入到另一个数据库本质上就是在一个连接里同时打开两个文件然后让数据在两者之间流动而不是像导数据到 Excel 那样有个中间格式。想清楚这一点后面所有方案的取舍就都有了依据凡是想绕开同时打开两个库的做法CSV、文本脚本必然要付出类型信息丢失的代价。1.2 ATTACH 是跨库操作的唯一正式入口SQLite 提供的挂载语句是ATTACH DATABASE语法很短ATTACH DATABASE /data/target.db AS dst;执行完这一句dst.orders就和本库的main.orders一样可以读写了。这条语句有几个必须记住的特性我在第一次用的时候全踩了一遍路径是相对于进程当前工作目录的不是相对于当前打开的库文件。你sqlite3 /data/source.db打开源库然后 ATTACH 一个target.db它会在你 shell 所在目录下找而不是/data/下找。写绝对路径能省掉九成的困惑。ATTACH 一个不存在的路径不会报错它会静默创建一个空库。这是最阴的一个坑后面第 5 节会专门讲。默认最多挂 10 个库编译期常量可调上限 125日常搬运两个库完全够用。ATTACH 和 DETACH 都不能在事务里执行会报cannot ATTACH database within transaction。所以脚本里的顺序必须是先挂载、再开事务、提交后卸载。跨库事务的原子性在 WAL 模式下会打折扣。主库处于 WAL 模式时涉及多个挂载库的提交不能保证要么全成要么全不成。数据量小无所谓几百万行的大表搬运我会临时把主库切回PRAGMA journal_modeDELETE搬完再切回去或者干脆接受分两步 事后校验。1.3 四条路线的取舍先看表再动手同样是搬一张表走哪条路取决于三件事表结构是否完全一致、数据量多大、是否需要保留索引和触发器。我把常见的四条路线整理成一张对照表你先对号入座再往下看路线典型适用场景主要优势需要付出的代价ATTACH INSERT INTO ... SELECT表同构、数据量大、需要按条件筛一条 SQL 完成、走原生读写、可映射列、可加 WHERE两个库必须能同时打开WAL 下跨库原子性受限.dump 生成脚本再 .read整库或整批表连结构一起搬保留表结构、索引、触发器脚本可存档文本脚本体积膨胀超大库慢同名表会冲突.mode insert 生成 INSERT 语句单表搬运、要保 BLOB 和 NULL类型信息完整字面量可读可改仍然是文本中转超大表生成文件很大CSV 中转跨工具、跨语言、给人看通用性最强类型丢失、BLOB 失真、NULL 与空串混淆我个人的默认选择是第一条。只有在需要连表结构、索引、触发器一起原样复制时才会退到.dump。CSV 我基本只在把数据交给别的系统时用SQLite 到 SQLite 之间几乎不考虑它——原因在第 4 节会讲清楚。2. ATTACH INSERT INTO ... SELECT 的完整落地流程这条路的写法看起来只有两行但要跑得干净前后的准备工作比中间那条 SQL 更重要。我在一个需要定期同步几十万行订单数据的场景里前后改过五六版脚本最后稳定下来的流程大致是这样备份 → 确认结构 → 挂载 → 单事务批量写入 → 重建索引 → 校验 → 卸载。2.1 动手前的两个动作备份 结构确认备份这件事不必展开讲但有个比cp更好的做法——VACUUM INTOVACUUM INTO /data/target_backup.db;它会在目标文件已经被写入的情况下生成一份干净紧凑的快照而且源库在导出期间仍然可读。这个语句需要较新的 SQLite 版本太老的版本会直接报语法错误跑之前先用sqlite3 --version确认一下。结构确认这一步更容易被跳过但恰恰是后面报错的主要来源。要在源库和目标库两边分别看三样东西表结构、字段亲和性、以及目标库当前的编码。命令很简单-- 在源库执行 .schema orders -- 在目标库执行 PRAGMA encoding; PRAGMA page_size; SELECT count(*) FROM orders;编码这一项尤其值得看一眼。PRAGMA encoding只在建库时生效之后改不了。绝大多数库都是 UTF-8但如果源库是老项目留下的 UTF-16 库而目标库是 UTF-8文本字段搬过去虽然不会报错但某些工具读出来会显示成乱码方块。这种问题在搬运当下完全看不出来等到别人打开文件才发现排查成本极高。2.2 建表在前灌数据在后ATTACH 之后INSERT INTO dst.orders SELECT ...能跑通的前提是目标库里已经有这张表。如果目标库是全新的空文件得先把 DDL 拿过去sqlite3 /data/source.db .schema orders | sqlite3 /data/target.db这条管道很实用.schema输出的CREATE TABLE和CREATE INDEX语句直接进目标库执行。注意.schema默认输出会带IF NOT EXISTS重复执行不会报错但也不会更新已存在的表结构——表结构要改得先DROP。结构就位之后正式搬运ATTACH DATABASE /data/target.db AS dst; BEGIN; INSERT INTO dst.orders (id, user_id, amount, created_at, remark) SELECT id, user_id, amount, created_at, remark FROM main.orders WHERE created_at 2025-01-01; COMMIT; DETACH DATABASE dst;这里我把列名全部显式写出来了没有用SELECT *。这不是洁癖是保命习惯。两边表的列顺序只要相差一位SELECT *就会把金额灌到备注里而且不报错等你发现的时候数据已经脏了。显式列名还顺便解决了目标表多列、少列的问题——后面第 3 节会详细说。2.3 把写入速度提上去的几个开关一次插入几十万行如果放着默认参数不管SQLite 会为每一行做一次独立的磁盘同步慢得让人怀疑人生。我实测过的量级是这样的三十万行、十几列、普通 SSD默认参数下大概要两三分钟加上下面这套设置能压到十几秒。这个数字只是量级参考你的表宽、索引数量不同比例会有变化。PRAGMA main.journal_mode MEMORY; -- 主库日志放内存仅导入期间 PRAGMA dst.journal_mode MEMORY; -- 注意要带库名前缀 PRAGMA main.synchronous OFF; PRAGMA dst.synchronous OFF; PRAGMA cache_size -65536; -- 负值表示 KB即 64MB PRAGMA temp_store MEMORY;几个要点。第一journal_mode和synchronous都是按库文件独立设置的挂载进来的库必须写dst.前缀只设主库没用。第二synchronousOFF意味着断电可能损坏数据库只适合数据源还在、坏了重来的场景导完必须改回NORMAL或FULL。第三整个批量插入必须包在一个事务里否则上面这些设置全白搭——自动提交模式下每一行都是一次独立事务。如果单表大到几千万行一次性事务会让日志文件和内存压力变得不可控这时候要改成分批。分批不要用LIMIT ... OFFSET大偏移量会让 SQLite 一行行扫过去越到后面越慢。用主键区间切INSERT INTO dst.orders (id, user_id, amount, created_at) SELECT id, user_id, amount, created_at FROM main.orders WHERE id 0 AND id 100000; -- 下一批 WHERE id 100000 AND id 200000主键如果不是单调递增的整数就用时间字段或rowid切逻辑一样。2.4 收尾索引、统计信息与卸载导入过程中如果目标表原本带索引每插一行都要维护一次 B 树这是很大的一笔开销。做法是导入前先把非唯一索引删掉唯一索引不能删它承担着约束职责导完再重建。DROP INDEX IF EXISTS idx_orders_user; -- ... 导入 ... CREATE INDEX idx_orders_user ON orders(user_id); ANALYZE;ANALYZE这一步经常被忽略。它收集索引统计信息让查询规划器知道该走哪个索引。刚导入完的表如果没有统计信息查询计划可能选得很差表现出来就是数据导进去了但查询突然变慢了。养成导完顺手ANALYZE的习惯能省掉一次莫名其妙的性能排查。最后DETACH。注意它必须在事务外执行前面COMMIT没提交就 DETACH 会直接报错。3. 两边表结构对不上时的对齐策略现实里很少有两次表结构完全一致的情况。源库是几年前的版本目标库加了字段、改了类型、加了约束这时候硬搬必然出问题。我在这一块踩的坑最多下面按问题类型分开讲。3.1 列数不一致显式列名是唯一稳妥解报错信息长这样table orders has 5 columns but 4 values were supplied。这类问题的处理取决于差异方向目标表比源表多列——只要多出来的列允许 NULL 或有默认值显式列出源端存在的列就行其余列自动填默认值。如果多出来的列是NOT NULL且没有默认值插入必然失败这时候有两条路给目标列补一个常量或者临时改表结构。补常量的写法INSERT INTO dst.orders (id, user_id, amount, source_flag) SELECT id, user_id, amount, legacy FROM main.orders;目标表比源表少列——源端多出来的字段直接不选即可但要想清楚这些字段丢掉之后业务上有没有影响。我有一次把一个带deleted_at的表搬进了一张没有软删除标记的表结果所有已删除的记录在目标库里全复活了查出来多了一堆本不该出现的数据。还有一种隐蔽情况两边列名相同但顺序不同。这就是前面强调不用SELECT *的原因显式列名把顺序问题彻底消灭。3.2 约束冲突OR IGNORE 与 OR REPLACE 的边界目标表已经有数据源表的主键或唯一键跟它撞了默认行为是整条语句回滚报错。SQLite 提供了几个冲突处理策略但每一个都有副作用得挑着用策略行为适用与风险默认 ABORT报错并回滚整条语句数据必须严格不重复时用它配合分批能定位到具体冲突行OR IGNORE跳过冲突行继续插入幂等同步首选但它会吞掉所有约束错误包括 NOT NULL 失败OR REPLACE删除冲突行再插入新行适合以源为准的覆盖注意 DELETE 会触发删除类触发器也会破坏外键引用OR FAIL报错但保留已插入的行想保留部分成果时用容易产生搬了一半的中间状态我个人的习惯是INSERT OR IGNORE用于日常增量同步让脚本可以重复执行用OR REPLACE的场景会额外检查目标表上有没有ON DELETE CASCADE的外键有的话宁可先DELETE再INSERT把逻辑显式写出来避免隐式级联删除带走一片数据。3.3 自增主键与内部序列表的隐性坑INTEGER PRIMARY KEY AUTOINCREMENT的表自增状态存在内部表sqlite_sequence里。这里有两个容易误解的点。第一手工指定 id 插入时sqlite_sequence会自动跟着往前推。也就是说你从源库把 id 原样搬过去目标表的自增序列会自动跳到最大值之后不会出现后续插入撞主键的情况。这一条很多人以为要手动UPDATE sqlite_sequence其实不用。第二如果目标表已有数据而你又原样搬 id撞主键的概率很高。这种场景我一般有两种处理整体替换时先DELETE FROM dst.orders清空再搬增量追加时干脆不搬 id让目标库自己分配新 id代价是外键引用关系要跟着重建。顺带说一句sqlite_sequence是内部表理论上可以直接改但强行改小会导致后续插入撞已有的 id除非你确定表是空的否则别碰。3.4 索引、触发器、视图要不要一起搬用.schema取结构时索引和触发器会一起带出来这通常是好事但导入阶段要留个心眼。触发器在跨库 INSERT 时是照常生效的——它在目标表上定义就往目标库的其他表里写数据。如果目标表上挂着几个审计类触发器你搬十万行就会额外产生十万行审计记录导入时间翻倍不说审计表还可能被撑爆。我的做法是导入前把业务触发器临时DROP搬完再按原 DDL 重建。视图不参与数据搬运它只是查询的别名搬不搬都不影响数据。外键则是另一回事PRAGMA foreign_keys默认是关的跨库操作期间如果开着它插入顺序不对就会报FOREIGN KEY constraint failed。稳妥流程是导入期间关掉外键检查导完开回来再跑一次PRAGMA foreign_key_check确认没有孤儿记录。4. 命令行与图形工具的两条捷径不是所有场景都值得写一套 ATTACH 脚本。有时候就是临时搬一张小表用命令行工具几秒钟的事。这一节把几条捷径摊开讲包括它们各自的失效边界。4.1 .dump 加 .read整库搬运最省心的方案sqlite3 /data/source.db .dump orders /tmp/orders.sql sqlite3 /data/target.db /tmp/orders.sql.dump后面跟表名就只导出这张表不跟就导出整库。输出的脚本里包含BEGIN TRANSACTION、CREATE TABLE、一堆INSERT和COMMIT结构数据一起走最省心。它的主要问题有两个。一是同名表冲突脚本里是CREATE TABLE orders目标库已经有这张表会直接报table orders already exists整个脚本中断。解决办法是从生成的 SQL 文件里把建表语句删掉只留 INSERT或者给目标表临时改个名。二是体积文本形式的 INSERT 语句比二进制数据大不少几百万行的库导出来可能几个 GB处理起来很吃磁盘。还有一个实用细节.dump支持管道直连不用落中间文件sqlite3 /data/source.db .dump orders | sqlite3 /data/target.db小表搬运我经常这么干一条命令解决。4.2 .mode insert被低估的单表搬运方式比.dump更精准的是.mode insert它把查询结果直接输出成 INSERT 语句sqlite3 /data/source.db .mode insert orders SELECT * FROM orders WHERE amount 100 /tmp/ins.sql sqlite3 /data/target.db /tmp/ins.sql生成的行长这样INSERT INTO orders VALUES(1,1001,299.0,2025-03-01,备注);。它的价值在于类型信息完整NULL 写成NULLBLOB 写成X89504E47...这样的十六进制字面量浮点数按原精度输出。这一点是 CSV 完全做不到的。生成的语句里表名来自.mode insert的参数所以你可以在源库导出时就把目标表名改好比如.mode insert legacy_orders导入时自动落到另一张表上避免覆盖。文本里的字符串会做转义含单引号的值会变成两个单引号这是 SQLite 的标准转义方式不用额外处理。它的短板也很明显只导数据不导结构表得先建好超大表生成的文本文件很大而且它不像 ATTACH 那样能在一个事务里完成导入中断就是个半成品。4.3 VACUUM INTO 与备份接口整库快照的正确姿势如果需求其实是把整个库复制一份那根本不用导入导出VACUUM INTO一句就够VACUUM INTO /data/copy.db;相比直接cp文件它有三个好处源库在导出期间可以继续读写cp一个正在被写的库会拿到不一致的快照尤其是 WAL 模式下会漏掉还没合并的页产出的文件碎片整理过体积通常更小不会连带复制-wal、-shm这些附属文件。对程序内嵌场景SQLite 还提供了在线备份接口可以在不阻塞读写的情况下把库复制到另一个连接。这个接口对不同语言有不同的封装大体都是打开目标连接、初始化备份、循环拷贝若干页、收尾这几步。它适合需要进度反馈、需要在备份过程中继续服务的大库纯手工搬运反而没必要上这套。4.4 图形工具什么时候用、什么时候别用图形化客户端对临时查看、改几行数据确实方便右键导出表、右键导入数据都有现成菜单。但要提醒一句这类工具的导出通常默认走 CSV 或者它自己的格式涉及 BLOB、长文本、NULL 与空串区分时丢信息的概率不低。我在一个小项目里用图形工具导过一张带图片字段的表回来之后所有图片字段都成了空——工具把 BLOB 当成不可序列化内容直接跳过了界面上也没给任何提示。我的判断标准很简单数据要给别人看、要进 Excel用图形工具数据要在 SQLite 之间完整流转用命令行或脚本。这个界限守住能省掉大量数据看起来在、其实已经坏了的隐性故障。5. 导入过程中的报错与真实排查链路这一节我按实际遇到的顺序写不是按错误码排序。每一条都还原当时的判断过程你能照着走一遍。5.1 database is locked先分清是哪种锁这个错误几乎是跨库操作的头号拦路虎。它至少有三个来源处理方式完全不同。第一种是最常见的自己的连接里有未提交的事务。比如你在交互式 shell 里执行了BEGIN然后忘了COMMIT后面的 ATTACH 或者另开连接就会撞锁。排查方式是执行SELECT * FROM sqlite_master看能不能读如果读得了但写不了八成是这种。第二种是另一个进程持着写锁。SQLite 的写锁是文件级的同一时刻只允许一个写入者。这种情况需要确认目标.db有没有被别的程序打开着顺带看一眼同目录下有没有残留的-journal或-wal文件——如果程序异常退出这些文件可能留在那里让人误以为有活跃连接。第三种是数据库被以独占方式打开或者所在目录没有写权限导致无法创建日志文件。后者报的是attempt to write a readonly database但根因和锁问题一样都是写不进去。工程化的解法是给连接设一个等待超时让它在遇到锁时自动重试一段时间而不是立刻失败PRAGMA busy_timeout 5000;五秒对大多数场景足够。设了这个之后偶发的锁竞争就不会再冒成错误。5.2 no such table先怀疑 ATTACH 的路径看到no such table: dst.orders很多人的第一反应是表名拼错了。实际上跨库场景下更常见的原因是 ATTACH 的路径不对——因为挂载一个不存在的路径会静默创建一个空库然后你在这个空库里找表当然找不到。排查三步走先PRAGMA database_list;看清楚每个别名实际对应哪个文件路径对不对一眼就明白再SELECT name FROM dst.sqlite_master WHERE typetable;确认这个库里到底有哪些表最后PRAGMA dst.database_list之类的信息也可以辅助判断。走完这三步问题基本自动现形。我在一次批量脚本里就吃过这个亏脚本用了相对路径在开发机上跑得好好的换到另一个目录执行ATTACH 创建了一堆空库文件数据一条没进去日志里全是no such table排查了半小时才发现是工作目录变了。5.3 列数或类型不匹配从报错信息反推结构table X has N columns but M values were supplied这条不用多说就是列数对不上回到第 3.1 节用显式列名解决。真正麻烦的是不报错但结果不对的情况。类型亲和性是个典型。SQLite 是动态类型字段声明的类型只是亲和性建议。源库某列存的是整数1目标列声明为TEXT插过去会变成字符串1。看起来没问题但如果目标库里有查询拿这列和整数比较或者做了SUM聚合行为就会和源库不一致。搬运前值得扫一遍SELECT typeof(col), count(*) FROM tbl GROUP BY 1;看看实际存的是什么类型和声明是否一致。另一个隐形杀手是时间格式。有的库存 ISO 字符串2025-03-01 12:00:00有的存 Unix 时间戳整数还有的存带时区的字符串。搬到一起之后按时间范围筛选会悄无声息地少掉一批数据因为字符串比较和数值比较的结果完全不同。这种事只能靠事先确认没法靠事后校验发现——两边行数一致但语义已经错了。5.4 BLOB 与长文本CSV 路线必然失真的地方第 1 节提过 CSV 的问题这里展开说清楚因为它是最容易看起来成功了的一条路。CSV 是纯文本格式它没有类型概念。把 BLOB 导成 CSV要么被转成十六进制字符串导回来是文本不是二进制要么被直接丢弃。含换行符的文本字段会被加上引号读的时候如果解析器不支持跨行字段就会被拆成多行。空字符串和 NULL 在 CSV 里都是空单元格导回来统统变成空串或者 NULL具体是哪个取决于导入工具的默认设置。浮点数经过十进制字符串往返偶尔会掉最后一位精度。所以我的原则是只要表里有 BLOB 字段或者有需要严格区分 NULL 与空串的列就不要走 CSV。改用.mode insert生成 SQL 字面量或者直接 ATTACH类型信息就不会丢。6. 导入之后的验收怎么确认数据真的到齐了脚本跑完没报错不代表数据搬对了。我在早期版本里就吃过零报错但少了一半数据的亏后来固定加了一套验收步骤五分钟的事能省掉后面几小时的扯皮。6.1 行数、区间与抽样三层比对第一层最直接两边各跑一次带同样条件的计数-- 源库 SELECT count(*) FROM main.orders WHERE created_at 2025-01-01; -- 目标库 SELECT count(*) FROM dst.orders WHERE created_at 2025-01-01;条件必须完全一致包括时区处理方式。第二层看主键范围SELECT min(id), max(id) FROM ...如果目标库的最大 id 比源库小说明尾部数据没进去往往是被分批逻辑漏掉了。第三层是抽样随机取几个 id 或者几行把整行内容对着看一遍。抽样不用多五到十行足够重点是覆盖不同类型的记录——有 NULL 的、有长文本的、有 BLOB 的。6.2 用轻量指纹做整表比对行数一致但内容出错的情况真实存在比如字段错位。想抓到这种问题得有个整表级别的指纹。如果你的 SQLite 版本自带摘要函数近几年的 CLI 里可以直接调用md5()、sha3()这类函数可以这样算SELECT count(*), md5(group_concat(id || | || coalesce(amount,) || | || coalesce(created_at,), )) FROM orders;注意这个写法对超大的表不现实——group_concat会把所有内容拼成一个巨大字符串内存扛不住。这时候退一步用分段聚合按主键区间分桶每桶算一次指纹再倒序拼接两边比对每个桶的结果。没有摘要函数的话用几组聚合值也能起到不错的校验效果count(*)、sum(length(cast(col as blob)))、sum(id)、count(distinct user_id)。这几个值同时对上内容出错的概率就非常低了。它们计算量小几百万行也就几秒。6.3 完整性检查与残留清理数据确认到齐还有两步收尾。第一步是跑完整性检查。PRAGMA quick_check;比integrity_check快很多日常验收够用真出问题再上完整的integrity_check。开了外键的表再补一个PRAGMA foreign_key_check;它会列出所有违反外键约束的行孤儿记录一目了然。第二步是清理导入期间用过的临时设置。前面为了提速改过journal_mode、synchronous改完必须还原——这些设置会写进库文件的头部如果留在MEMORY模式下次别人打开这个库就在用一个不适合生产环境的配置而且完全不知情。还原命令PRAGMA journal_mode WAL; -- 或 DELETE跟着目标库原有配置走 PRAGMA synchronous NORMAL; PRAGMA busy_timeout 5000;顺便删掉临时生成的.sql、.csv中间文件尤其是含敏感数据的那种。我见过中间文件被随手丢在/tmp然后出现在别的地方的事故养成清场习惯。7. 我个人固定下来的几条实操习惯搬表的活儿做多了之后会形成一些不写在文档里、但每次都照做的习惯分享几条。整个操作我一律写成.sql脚本而不是在交互式 shell 里手敲。脚本的好处不光是可重复执行更重要的是它把挂载、事务、插入、卸载的顺序固化了不会出现忘记COMMIT或者把DETACH写进事务里的低级错误。脚本开头固定放备份语句和版本检查结尾固定放校验语句中间那段才是真正变的部分。大批量导入之前先用WHERE 10或加一个极小的时间范围跑一次空转把语法、列名、路径全部验证一遍。这一步只花几秒钟却能挡住 90% 的低级错误——真正跑十分钟之后才报no such table才是最让人恼火的。还有一个反直觉的体会把目标表当成只追加比反复合并省心得多。早期我喜欢设计成目标表存在就更新、不存在就插入用OR REPLACE硬扛结果触发器、外键、自增序列各种连带问题层出不穷。后来改成每次导入到一个带日期后缀的新表导完确认无误再原子地重命名替换回滚也简单——旧表还在改个名就退回去了。这套做法在数据量不大、导入频次不高的场景里比任何精细的增量逻辑都稳。最后一点关于性能的提醒别迷信参数调优。synchronousOFF带来的提升确实明显但它把安全边界推到了断电即损坏。如果你的数据能从源库重新导一次放心用如果不能宁可慢一半把synchronous留在NORMAL。这个取舍想清楚了剩下的参数就是顺带手的事。