100 亿条数据如何快速写入 MySQL?.NET 10 + SqlSugar 实战:空间降维、IO 降维与工程兜底
性能参考 :逐条
INSERT约 115~231 天 (磁盘与网络更差可达数百天量级,更好约 2 个月);整套方案(多分片、多实例并行)落地,SSD 环境下总吞吐可达 3.5 小时量级 ------这是多实例并行写入的总吞吐目标 ,约需 3~20 台 SSD 实例(取决于单机吞吐与分片均匀度),以压测为准。这不是 ORM 调参技巧,而是先判断"该不该进 MySQL",再改变问题形态。单机单分片的实际吞吐多在数万到数十万行/秒量级。版本声明 :本文全部 C# 代码已在 SqlSugarCore 5.1.4.221 + MySqlConnector 2.6.2(.NET 10.0.300) 下编译验证通过(API 存在性与语法正确性);运行时行为(尤其
Fastest().BulkCopy底层、Storageable/BulkMerge的 SQL 生成)仍需在你实际部署的 MySQL 上做一次真实数据压测确认。
〇、核心思想与方法论
核心思想:先看清成本结构,再决定是摊薄成本,还是换掉承担成本的那个系统。
方法论:空间降维 → IO 降维 → 工程兜底。

三者关系:空间降维解决天花板,IO 降维把每一块分片压榨到极限,工程兜底守住数据正确性的底线。
一、先算账:该不该进 MySQL、进来有多大
1.1 先选战场:100 亿条该不该进 MySQL
这是最容易被跳过、收益却最大的一步。100 亿行如果是时序上报、日志、审计流水,天然具备"写多读少、按时间范围扫、极少更新"的特征------这不是 OLTP 的活。

让合适的工具做合适的事,是架构层面的答案;后面的调参只是"必须用 MySQL"这个既定约束下的极限挤压。
本文的讨论前提是:这批数据必须落 MySQL、需要事务与幂等、后续要按分片键检索。如果答案是"不必",先换存储,再谈写入。
1.2 容量账
假设一行设备上报记录约 500 字节,100 亿行 ≈ 4.5TiB / 5TB 原始数据;加主键、二级索引、页填充率和导入期 binlog,落盘轻松奔 6~8TB。导入期还会产生等量 binlog 和数 TB 临时文件。结论:这不是"一台机器一张表"能扛的量,一开始就不是。
1.3 B+ 树账:为什么"单表 2000 万"是条经验线
按 InnoDB 默认 16KB 页、1KB 行、8B 主键估算:
- 叶子页约存 16 行/页;
- 非叶子节点约存 1170 个主键指针;
- 三层 B+ 树 :1170 × 1170 × 16 ≈ 2190 万行;
- 四层 B+ 树 :1170³ × 16 ≈ 256 亿行。
"单表不要超过 2000 万"这条江湖经验,科学来源就在这里:三层 B+ 树刚好把索引前两层塞进内存,第四层开始每次查询多一次磁盘 IO 。100 亿已经踩进第四层甚至第五层。行宽越小,单页行数越多,阈值会随之上移;但数量级结论不变:100 亿必须分片,单表压到 500 万~1000 万行。
1.4 时间账:不优化,就是 115~231 天
单条 INSERT 自动提交,每个事务一次 fsync,约 0.5ms,折合 500~1000 行/秒 (0.5ms 对应约 2000 次/秒的理论 fsync 上限,再叠加网络 RTT、SQL 解析、二级索引维护后落到 500~1000,属保守量级):按 500 行/秒,100 亿 ÷ 500 = 2×10⁷ 秒 ≈ 231 天 ;按 1000 行/秒 ≈ 116 天 。这是 SSD 适中场景的量级------磁盘与网络更差(如机械盘)可达数百天,顶级 SSD + 快 fsync 可压到约 2 个月。
反过来看目标:3.5 小时 = 12600 秒 ,100 亿 ÷ 12600 ≈ 79.4 万行/秒 。注意:这是多分片、多实例并行写入的总吞吐目标 ,不是单机单分片的成绩------它约需 3~20 台 SSD 实例 并行(取决于单机吞吐与分片均匀度),规模不到就达不到 3.5 小时。后面所有优化,都是在这个基数上砍数量级。
耗时敏感性:同一 100 亿行,吞吐差 1000 倍,耗时从 2.8 小时到 3 年。下表把"吞吐 → 100 亿耗时"列成对照,方便你对着自己的硬件预估。

这些是量级参考,不是精确预测:磁盘类型、fsync 频率、网络 RTT、行宽、实例数任一改变,结果都会跨数量级。上线前请用真实数据样本压测。
二、空间降维:先把单表"瘦"下来
目标:把 100 亿行拆到 1500~2000 张分表 ,让单表回到 500 万~1000 万行的黄金区间。分片数按团队运维能力上限反推,不是越多越好。
2.1 分片键怎么选
三个硬标准,缺一不可:高基数 (能均匀散开,别选性别、状态这种只有几个值的字段);查询高频 (业务 90% 的查询都要带它);不可变(分片键一旦写入就不该改,改了就得跨分片迁移)。
路由策略上:固定取模 (如 device_id % 1500)实现简单、分布均匀,一次性导入首选;一致性哈希适合后续在线弹性扩容,历史导入场景不必优先。

2.2 分片数量规划
100 亿 ÷ 1500 片 ≈ 每片 667 万行 ,落在 500 万~1000 万黄金区间的中位;若按 1000 片,每片 1000 万行已踩在区间上沿,行宽稍大或索引稍多就可能回到四层 B+ 树。建议把分片数上修到 1500~2000 ,例如 20 库 × 100 表(2000 片) 或 24 库 × 80 表(1920 片) ------对应每片 500 万 / 521 万行,都在黄金区间内且留有余量。
分片数不是越多越好:100 个数据库实例的监控、备份、DDL 变更、连接池运维成本会显著上升。生产落地更常见的是几十个实例、每个实例几十张表,按团队能力反推分片数。只要单表保持在 500 万~1000 万,不必刻意逼近 1000 片下限。
2.3 用 SqlSugar 的 SplitTable 落地
csharp
using SqlSugar;
// 设备上报分表实体:按月分表,按设备维度做库级分片。
// 按月分表:自动生成 device_report_202611 等物理表
[SplitTable(SplitType.Month)]
// 表名模板:{year}{month} 为 4 位年 + 2 位月
[SugarTable("device_report_{year}{month}")]
public class DeviceReport
{
// 雪花 ID,非自增;由应用侧生成(见 3.3)
[SugarColumn(IsPrimaryKey = true)]
public long Id { get; set; }
// 分片键:哈希取模决定去哪个库
public long DeviceId { get; set; }
// 分表路由字段;时间分表时必须是 DateTime
[SplitField]
public DateTime ReportTime { get; set; }
// 上报载荷(示例字段,可按需替换)
public int Payload { get; set; }
}
初始化时一次性建好所有分表结构:db.CodeFirst.SplitTables().InitTables<DeviceReport>();
写入时自动路由:await db.Insertable(list).SplitTable().ExecuteCommandAsync();
几个关键细节:SplitField 必须是 DateTime 类型(时间分表时);分片键(DeviceId)和分表字段(ReportTime)是两套维度,别混 ;自动建表适合初始化,不适合高频写入 ------大批量导入时表结构应提前由 DDL 建好,分片任务拿到物理表名后固定表名写入更稳;SplitTable 的自动识别主要覆盖 Insert / Update / Delete,Select 查询需要手动指定表名或用 AsTable 映射。
2.4 列瘦身与冷热分离
只保留在线查询需要的列,大文本/Blob 拆到旁路表或对象存储;数值能定长就不用变长,能 TINYINT 不用 INT,绝对别用 CHAR(36) 的 UUID 主键 ;上线即定归档策略:近 3 个月热数据留 MySQL,历史数据导出 ClickHouse,MySQL 侧按分区 DROP PARTITION 秒级回收空间。
三、IO 降维:把 N 次 IO 压成 1 次
目标:单次网络往返装下尽量多的行,单次刷盘覆盖尽量多的事务,磁盘写尽量顺序化。
3.1 先识别"假批量"
Java 的 rewriteBatchedStatements=true 在 .NET 没有同名对应;.NET 的批量路径分两种:MySqlBulkCopy(多值 INSERT 批量) 与 MySqlBulkLoader(LOAD DATA LOCAL INFILE 流式) 。Insertable(List) 的多值 INSERT 仍经过 SQL 解析层,属于"半批量";真正把 IO 降一个数量级的是 BulkCopy / LOAD DATA 路径。
csharp
// 白银写法:一次提交一批,合并网络往返 + 合并事务。
// 注意:此事务仅包裹多值 INSERT;BulkCopy 路径不要在外层再开大事务(见 3.5 红线)。
db.Ado.BeginTran();
try
{
// 每 2000 行拼一条多值 INSERT
await db.Insertable(list)
.PageSize(2000)
.ExecuteCommandAsync();
db.Ado.CommitTran();
}
catch
{
// 任何异常都回滚,避免事务悬空占用连接
db.Ado.RollbackTran();
throw;
}
3.2 大杀器:Fastest().BulkCopy()
SqlSugar 的 Fastest<T>().BulkCopy() 在 MySQL 上的底层实现取决于当前 SqlSugar / 驱动版本 :常见走 MySqlConnector 的 MySqlBulkCopy(多值 INSERT 批量),部分版本/配置下才走 MySqlBulkLoader(LOAD DATA LOCAL INFILE 流式)。两条路径都能把 N 次逐行 IO 合并成少数几次大操作;具体走哪条路径、是否依赖 local_infile,请以当前版本实测为准 。官方文档确认 PageSize 可与 BulkCopy 一起使用用于分批。
若你当前版本确实走 LOAD DATA LOCAL INFILE 路径,则需要以下两个前提(否则 BulkCopy 可能报错):
① 连接串必须加 AllowLoadLocalInfile=true:
csharp
var db = new SqlSugarClient(new ConnectionConfig
{
DbType = DbType.MySql,
ConnectionString = "Server=10.0.0.10;Port=3306;Database=iot;Uid=importer;Pwd=***;" +
// 走 LOAD DATA LOCAL 时必需
"AllowLoadLocalInfile=true;" +
// 建连超时
"Connection Timeout=600;" +
// 长任务别被默认超时打断(0=不超时)
"Default Command Timeout=0;",
// 每次执行完自动归还连接
IsAutoCloseConnection = true,
// 主键/自增等特性按 Attribute 识别
InitKeyType = InitKeyType.Attribute
});
② 服务端必须开 local_infile:
sql
SHOW VARIABLES LIKE 'local_infile';
SET GLOBAL local_infile = 1;
⚠️ 安全提醒 :
LOCAL INFILE允许客户端读取服务器本地文件,生产环境应使用专用导入账号 ,导入完成后关闭local_infile,而不是常开。local_infile是全局变量,Docker 容器重启后可能被重置成 OFF ,稳妥做法是在导入程序启动时主动执行一次SET GLOBAL local_infile = 1,并做失败告警。
写入主体:
csharp
// 每 10 万行一次批量(分批语义以当前版本实测为准)
await db.Fastest<DeviceReport>()
.PageSize(100_000)
.BulkCopyAsync(batch);
PageSize 的语义:官方文档中 PageSize(100000) 与 BulkCopy 搭配使用,用于控制每批行数。对 BulkCopy 的分批语义以当前 SqlSugar 版本实测为准 ;若当前版本对 BulkCopy 不分批,BulkCopy 本身即流式写入,无需额外 PageSize。5 万~10 万是常见安全区间,太小合并效果不明显,太大则客户端内存、单事务、max_allowed_packet 都可能出问题。
3.3 顺序写:把随机 IO 掰成顺序 IO
InnoDB 是索引组织表 ,数据按主键顺序存放。主键无序(比如 UUID),每插一行都可能引发页分裂 + 随机磁盘写 ;主键有序,写入就变成顺序追加。
普通 Insert 路径可以用 SqlSugar 的雪花 ID 返回:
csharp
// 每个不能共享 SnowFlakeSingle 静态变量的实例必须使用不同的 WorkId(从配置/环境变量读取)
SnowFlakeSingle.WorkId = int.Parse(
Environment.GetEnvironmentVariable("SNOWFLAKE_WORKER_ID") ?? "1");
// 普通 Insertable 路径可直接返回雪花主键(同步返回 long,无需 await)
long id = db.Insertable(entity).SplitTable().ExecuteReturnSnowflakeId();
但 BulkCopy 路径必须由应用侧预生成 ID。 官方明确说明:"大数据写入方式如果用到雪花ID需要手动赋值:SnowFlakeSingle.Instance.NextId()" 。ExecuteReturnSnowflakeId / ExecuteReturnSnowflakeIdList 是 Insertable 的方法,不会自动填充 BulkCopy 的实体列表。
csharp
// BulkCopy 批次:构造时逐条预生成雪花 ID,再按主键排序 → 顺序写
SnowFlakeSingle.WorkId = int.Parse(
Environment.GetEnvironmentVariable("SNOWFLAKE_WORKER_ID") ?? "1");
// 逐行填充雪花 ID,再按主键排序 → 顺序追加
var ordered = batch
.Select(x => { x.Id = SnowFlakeSingle.Instance.NextId(); return x; })
.OrderBy(x => x.Id)
.ToList();
await db.Fastest<DeviceReport>().PageSize(100_000).BulkCopyAsync(ordered);
WorkId 必须从配置/环境变量读取,每个实例唯一,1 只是示例。多机并行 + 分库分表下,数据库自增 ID 会因自增锁串行化成为瓶颈,还会跨库撞号。
3.4 先灌数据,后建索引
每插一行,表上所有二级索引都要更新一次 B+ 树。正确姿势:
sql
-- 导入前:删掉二级索引,让写变成纯顺序追加
ALTER TABLE device_report_202611 DROP INDEX idx_device_time;
-- 导入完成后:在线重建,不锁表、不阻塞读写
ALTER TABLE device_report_202611
ADD INDEX idx_device_time (device_id, report_time),
ALGORITHM=INPLACE, LOCK=NONE;
一次性批量建索引比逐行维护快得多,因为重建时可以排序 + 批量加载。
唯一索引与外键约束 :唯一索引(含主键)不能简单"后建";如果业务依赖唯一键做幂等,要么导入后单独去重,要么接受导入期
unique_checks=0带来的延迟校验成本。恢复unique_checks=1时可能触发唯一索引重建,是阻塞操作。如果表有外键,恢复foreign_key_checks=1前必须先做孤儿行清理,否则恢复语句会失败或留下脏数据。
3.5 导入期参数"超频":拿可靠性换吞吐
这是一次性、可重放、可对账的离线导入窗口,OLTP 的那套"保险"价值下降,可以临时调参。但必须分清 SESSION 和 GLOBAL,并在 try/finally 里成对恢复。
sql
-- 会话级:只影响当前导入连接
SET SESSION unique_checks = 0;
SET SESSION foreign_key_checks = 0;
SET SESSION autocommit = 0;
-- 全局级:需要 DBA 授权,仅限离线导入窗口
SET GLOBAL innodb_flush_log_at_trx_commit = 2;
SET GLOBAL sync_binlog = 0;
SET GLOBAL max_allowed_packet = 67108864;
导入结束后必须逐条恢复:
sql
SET SESSION unique_checks = 1;
SET SESSION foreign_key_checks = 1;
COMMIT;
SET SESSION autocommit = 1;
SET GLOBAL innodb_flush_log_at_trx_commit = 1;
SET GLOBAL sync_binlog = 1;
-- 说明:max_allowed_packet=67108864 恰为 MySQL 8 默认值(64MB),导入后不恢复也安全;
-- 若你原环境将其调小过,请补回 SET GLOBAL max_allowed_packet = <原值>。
几条红线:innodb_flush_log_at_trx_commit=2 崩溃可能丢最近 1 秒事务;unique_checks=0 / foreign_key_checks=0 关掉后一定要恢复 ,否则后续业务插入可能静默破坏唯一性;不要在 BulkCopy 外层再套一个超长事务 ,undo 膨胀反而拖慢,事务边界交给分批提交;这些参数严禁线上常态业务常驻。
一个逐条执行的会话参数恢复封装(避免多语句依赖驱动配置,并带事务回滚兜底):
csharp
public static async Task WithImportSessionAsync(SqlSugarClient db, Func<Task> action)
{
// 逐条执行 SET:避免把多条语句拼成一条命令(降低对驱动"多语句"配置的依赖)
await db.Ado.ExecuteCommandAsync("SET SESSION unique_checks=0");
await db.Ado.ExecuteCommandAsync("SET SESSION foreign_key_checks=0");
try
{
await action();
}
finally
{
// 成对恢复:先回滚 action 内部可能未提交的事务,避免事务悬空占用连接
try { await db.Ado.ExecuteCommandAsync("ROLLBACK"); } catch { /* 无活动事务时忽略 */ }
await db.Ado.ExecuteCommandAsync("SET SESSION unique_checks=1");
await db.Ado.ExecuteCommandAsync("SET SESSION foreign_key_checks=1");
await db.Ado.ExecuteCommandAsync("SET SESSION autocommit=1");
}
}
GLOBAL 参数不建议在业务代码里自动改,应由 DBA 在导入窗口统一执行并恢复。
3.6 并发:必须落在"分片维度"
千万不要在同一张表里乱序并发写 ,那只会放大锁竞争、页分裂、undo 压力。正确姿势:一个分片一个写入流 ,分片之间天然无锁冲突;单机并发度控制在 16~32 (这是常见经验区间,最终以"吞吐-并发"压测曲线的拐点为准,过高会因线程竞争雪崩);每个并行分支用 CopyNew() 或独立 SqlSugarClient,共享连接会把并行退化成串行;生产者和消费者之间用有界 Channel 做背压。
CopyNew()的语义 :它解决的是线程安全/连接隔离 ,不是分库路由。多库分片时,物理库切换应由SqlSugarScope的GetConnectionScope(configId)或路由中间件完成,CopyNew本身不负责切换db_0/db_1。
csharp
public async Task ImportShardAsync(SqlSugarClient rootDb, ShardTask shard, CancellationToken ct)
{
// 线程安全/连接隔离(不是分库路由)
var db = rootDb.CopyNew();
try
{
// 读水位:断点续传起点
var watermark = await LoadWatermarkAsync(db, shard.ShardKey);
// 上次导到的最大主键
long lastId = watermark?.LastId ?? 0;
// 标记分片"处理中",防重复领取
await MarkRunningAsync(db, shard.ShardKey);
// 流式读取数据源,每次取 10 万行一个批次
await foreach (var batch in ReadSourceBatchAsync(shard, lastId, batchSize: 100_000, ct))
{
// 本批:逐条预生成雪花 ID → 按主键排序 → 有序批次
var ordered = batch
.Select(x => { x.Id = SnowFlakeSingle.Instance.NextId(); return x; })
.OrderBy(x => x.Id)
.ToList();
// AS(表名) 部分版本支持,以实测为准;无效则提前把数据路由到目标物理表再 BulkCopy
await db.Fastest<DeviceReport>()
// 固定物理表,避免运行时路由反射开销
.AS(shard.TableName)
.PageSize(100_000)
.BulkCopyAsync(ordered);
// 每批成功后推进水位:last_id 取本批最大主键,rows_written 累加
await UpdateWatermarkAsync(db, shard.ShardKey, ordered[^1].Id, ordered.Count);
}
// 分片全部导完,标记"成功"
await MarkDoneAsync(db, shard.ShardKey);
}
catch
{
// 任一批失败,标记"失败"供重启续传
await MarkFailedAsync(db, shard.ShardKey);
throw;
}
finally { db.Dispose(); }
}
并行入口:
csharp
// 单机 16~32 路,以压测拐点为准
await Parallel.ForEachAsync(
shards,
new ParallelOptions { MaxDegreeOfParallelism = 16 },
async (shard, ct) => await ImportShardAsync(rootDb, shard, ct));
主键统一用雪花算法 / 号段模式 在应用侧生成------多机并行 + 分库分表下,数据库自增 ID 会因自增锁串行化成为瓶颈,还会跨库撞号。
3.7 段位对照表(.NET + SqlSugar 版)

数字前提必须说清楚 :钻石/王者的"80 万~200 万行/秒"是数百个分片分布在数十台数据库实例上并行写入的总吞吐 ,不是单机单分片的 LOAD DATA 吞吐。公开可复现的单机 LOAD DATA 参考多在数万到数十万行/秒量级(例如 SSD + 32GB 内存下约 30 万行/秒,普通 SSD 单路约 2.5 万~4 万行/秒)。硬件天花板先确认:NVMe SSD(IOPS 50 万、延迟 <50μs)相比 7200RPM HDD(IOPS 200、延迟 5~10ms),批量写入差距可达两个数量级。上线前请用真实数据样本压测。
四、工程兜底:让一次跑 3.5 小时的任务敢于失败
前两层决定"能跑多快",这一层决定"能不能放心跑完"。
4.1 幂等:重跑不炸库
关键事实 :BulkCopy 是"只插不管",SqlSugar 原生 BulkCopy 不支持 UPSERT 语义,遇到主键冲突会直接报错,重跑可能整批失败。幂等必须由外层设计保证。
① 换用 BulkMerge()(Upsert)。 官方原理:Oracle/SqlServer 使用 Merge Into + BulkCopy,其他库底层是分页 + BulkCopy + 查询验证 。需要注意:BulkMerge 在事务中可能与 BulkCopy 自带的事务冲突,部分库不支持外部事务。
csharp
// 以当前版本实测确认;存在则更新、不存在则插入
await db.Fastest<DeviceReport>()
.PageSize(100_000)
.BulkMergeAsync(page);
② 多值 Insert 走 ON DUPLICATE KEY UPDATE(适合非 BulkCopy 路径):
csharp
// 多值 INSERT 的 UPSERT:SqlSugar 5.x 由 Storageable 提供,底层生成 INSERT ... ON DUPLICATE KEY UPDATE ...。
// WhereColumns 指定"业务唯一键"作为冲突判定列;若唯一索引是 (device_id, report_time),
// 就换成 .WhereColumns(it => new { it.DeviceId, it.ReportTime })。
// 冲突判定列,需与表上唯一索引一致,否则冲突检测不生效
await db.Storageable(list)
.WhereColumns(it => it.Id)
.ExecuteCommandAsync();
⚠️ 并发死锁风险 :
ON DUPLICATE KEY UPDATE在并发冲突时会对唯一索引加 S 锁,多会话命中同一唯一键可能互相等待形成死锁,RR 隔离级别下尤其明显。建议在 RC 隔离级别下单语句短事务执行,或改用"上游保证唯一 + 水位 + 对账"的 BulkCopy 路线。
③ 上游保证唯一 + 水位表 + 对账兜底 (BulkCopy 场景最省事)。无论哪种方案,前提都是目标表上有以业务唯一键建立的唯一索引,否则冲突检测根本不生效。
4.2 断点续传:水位表
sql
CREATE TABLE import_watermark (
shard_key VARCHAR(64) NOT NULL PRIMARY KEY,
last_id BIGINT NOT NULL DEFAULT 0,
rows_written BIGINT NOT NULL DEFAULT 0,
-- status 枚举:0待处理 1处理中 2成功 3失败
status TINYINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
实体类按列映射即可,此处不重复展开。流程:开始处理分片标记
status=1→ 每批BulkCopy成功后更新last_id和rows_written→ 程序中断重启只捞status=1/3的分片,从水位继续。
恢复粒度 :水位按每 10 万行更新一次,崩溃最多重跑当前批次(10 万行量级),不会从零重跑 100 亿。这是百亿级任务的生命线。
4.3 三层对账
"跑完了"不等于"跑对了"。总量对账 (源端 count vs 目标端 count);抽样对账 (按分片均匀抽 1‰,按主键回查逐字段比对 MD5);指纹对账 (两端按分片算 COUNT(*) + SUM(CRC32(关键字段)))。
sql
-- 指纹对账:两端按分片聚合。
-- 注意:SUM(CRC32(...)) 在百亿量级会超出 BIGINT(约 2×10¹⁹ > 9.2×10¹⁸),
-- 必须 CAST 为 DECIMAL(40,0) 提升精度,否则 MySQL 会报错或失真。
SELECT shard_key,
COUNT(*) AS cnt,
CAST(SUM(CRC32(CONCAT_WS('|', id, device_id, payload))) AS DECIMAL(40,0)) AS fp
FROM device_report_202611
GROUP BY shard_key;
只有对账通过,才允许切换业务流量。
4.4 影子表 + 原子切换
如果这 100 亿是替换旧表 而不是追加,不要在旧表上直接写:数据全部导入影子表 → 对账、在线重建二级索引全部完成 → 业务低峰用一条 RENAME 原子切换:
sql
RENAME TABLE device_report TO device_report_old,
device_report_shadow TO device_report;
RENAME 是元数据操作,毫秒级完成,业务几乎无感;回滚只需反向 RENAME。
4.5 背压限流与主从保护
生产者和消费者速度不匹配时,用有界 Channel 做背压:
csharp
// 有界缓冲:生产者/消费者速度不匹配时,用固定容量 Channel 做背压。
// 缓冲满了就让生产者等待(写入侧消费),形成背压
var channel = Channel.CreateBounded<Batch>(new BoundedChannelOptions(64)
{
FullMode = BoundedChannelFullMode.Wait
});
大批量写入会瞬间打爆主从延迟:导入期限流,给从库留追赶时间;方案评审时明确导入窗口内读流量是否允许走从库;导完如果延迟太大,直接从新主库做一次全量重建,比等它追几 TB binlog 更快。
4.6 可观测性
必看指标:进程内存峰值、各分片吞吐(rows/s)、水位延迟、失败重试次数、目标库 Innodb_rows_inserted、Innodb_log_wait、磁盘 iostat。没有这些指标,你就不知道 3.5 小时是快是慢、卡在哪一环。
五、完整流水线与踩坑清单
5.1 完整流水线
text
数据源(HDFS/Kafka/CSV/设备上报)
↓ 流式读取 + 有界 Channel 背压
[空间降维] 按分片键分组归集,一个分片一个写入任务
↓
[IO 降维] 分片内预生成雪花 ID → 按主键排序 → 有序批次
↓
[IO 降维] Fastest().BulkCopy()
+ 导入前删二级索引
+ 临时关闭 unique_checks / 降低刷盘频率
↓
[工程兜底] 每批更新水位;失败标记分片,等待重试
↓
全部分片完成 → 在线重建二级索引
↓
[工程兜底] 三层对账(count / 抽样 / 指纹)
↓
[工程兜底] 影子表 RENAME 原子切换 → 开放业务流量
六步口诀:一算、二拆、三批量、四调参、五并发、六收尾。
5.2 踩坑清单(.NET + SqlSugar 特有)
- 连接串缺少
AllowLoadLocalInfile=true,走 LOAD DATA 的BulkCopy直接报错; - 服务端
local_infile=OFF,或 Docker 重启后被重置; - 数据没有预先按分片分组,乱序路由导致批量失效;
- 分片内主键无序写入,页分裂严重;
- 导入临时参数忘记恢复,线上库静默产生脏数据;
- 单分片开大量并发
BulkCopy,锁竞争、undo 日志暴涨; - 只看写入日志成功,不做对账,存在静默丢数;
SplitField不是DateTime,或分片字段为空,路由失败;- 批大小设置过大,单事务超时、binlog 爆炸、回滚灾难;
- 用数据库自增 ID 做分片主键,跨库撞号、自增锁串行化;
BulkCopy没有原生 UPSERT,直接重跑会因主键冲突整批失败;BulkCopy路径依赖ExecuteReturnSnowflakeId自动赋 ID ------需要手动SnowFlakeSingle.Instance.NextId()预填充;OnDuplicateKeyUpdate()在 SqlSugar 5.x 已不存在 ------多值 UPSERT 改用Storageable().WhereColumns(...);SplitTable只对 Insert/Update/Delete 自动路由,Select 查询忘了手动指定表名;AS(表名)对Fastest<实体>().BulkCopy的全版本兼容性未确认,应实测或提前路由到目标物理表;- 指纹对账
SUM(CRC32)忘了CAST AS DECIMAL(40,0),百亿表可能溢出报错。
六、架构师视角与面试口述
6.1 三个提醒
1. 不是所有数据都该进 MySQL。 日志/时序 → ClickHouse / Doris;海量 KV → HBase;全文检索 → Elasticsearch;HTAP → TiDB。MySQL 只伺候在线交易的热数据 。100 亿条数据放 MySQL,本身就该先问一句:这些数据真的适合 MySQL 吗?
2. 导入期的参数调优是"拿可靠性换吞吐"。 关掉 unique_checks、刷盘改成每秒、sync_binlog=0------这些操作的潜台词都是"我不在乎这一段时间的持久性"。只允许用在可重跑的离线导入,导完立即恢复双 1 配置。
3. 真正的能力不是背结论。 先反问边界 ;能用 B+ 树算账 解释"为什么单表 2000 万";分层讲方案 ,每层都有取舍逻辑;有真实数字,敢说"3.5 小时"也敢解释"这是多分片总吞吐"。
6.2 30 秒面试口述模板
"100 亿条数据,先算账:容量 4.5TB+,落盘 6~8TB,单表 B+ 树要四层,单机单表装不下 。处理思路九个字------空间降维、IO 降维、工程兜底 。
① 空间降维 :用 SqlSugar 的
SplitTable按分片键拆成 1500+ 张分表,单表压到千万级以内;冷热分离。② IO 降维 :主力是
Fastest().BulkCopy();BulkCopy 场景主键用SnowFlakeSingle.Instance.NextId()预生成,分片内先排序;导入前删二级索引、导完在线重建;参数临时降刷盘、关唯一性校验,导完立刻恢复。③ 并发 :按分片维度并行,每分片独立连接,单机 16~32 路。
④ 工程兜底 :水位表断点续传、幂等、三层对账、影子表原子切流量、主从保护。
最后一句:100 亿这种量级,更该先质疑它是不是该放 MySQL------冷数据去 ClickHouse,热数据才留在这里。"
七、结语
231 天到 3.5 小时,中间并没有任何"黑科技"。所有提速都来自同一件事:
想办法把 N 次操作合并成 1 次;合并不了的,就降维拆到能合并的粒度。
- 空间降维拆的是"数据规模",解决"装得下";
- IO 降维合的是"操作次数",解决"写得快";
- 工程兜底保的是"合并之后不出事",解决"跑得稳"。
这三层叠起来,才是那个从 231 天到 3.5 小时的完整答案。
最后再提醒一句:所有导入期调参都是阶段性取舍,不能把导入模式当成常态业务模式;当业务不需要 MySQL 的事务与检索能力时,优先选择专门的分析型数据库。
免责说明 :文中耗时为量级参考,3.5 小时对应多分片、多实例并行的总吞吐,单机单分片以压测为准。本文 C# 代码基于 SqlSugarCore 5.1.4.221 + MySqlConnector 2.6.2(.NET 10.0.300)编译验证 ;
BulkCopy底层是否走 LOAD DATA、Storageable/BulkMerge/AS(表名)的 SQL 生成,请按你的实际版本与 MySQL 环境实测确认。