ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MiroFish:基于真实库结构的测试数据生成与脱敏实践

MiroFish:基于真实库结构的测试数据生成与脱敏实践 第一次在项目列表里看到 MiroFish 这个名字我盯着它看了十几秒脑子里第一反应是把它拆成两截去看。Miro 显然是 mirror 的口语化缩写Fish 在测试圈里几乎是个约定俗成的梗——数据工厂。这两截拼在一起指向的东西其实相当明确一个以数据库真实结构为准绳的测试数据生成工具而不是又一个拍脑袋手搓 fixture 的脚本集合。这个痛点我太熟了。一个中型业务系统跑到第三年表数量轻松过八十张外键关系绕成一团麻花。写单元测试的时候你想在 orders 表里插一条记录得先造 useruser 又依赖 tenanttenant 又依赖 regionregion 还有个字典表兜底。手工写 fixture 的结局通常是一个 JSON 文件里塞了三百行数据字段名拼错两个跑起来报外键约束错误你花四十分钟找那个错误的 user_id 到底指向哪里。MiroFish 这类工具存在的意义就是把这套「照着依赖关系一层层手搓」的活儿交给工具按真实表结构自动推导一遍。这篇内容我打算从名字拆解开始一路讲到内部流水线的四段设计、本地怎么从零跑通、脱敏边界怎么划、以及我自己踩过的四个最耗时的坑。适合正在被测试数据折磨的后端和测试开发同学也适合那种「听说过数据工厂但一直没动手」的人。全文不聊虚的配置、代码、参数、报错原因我都会给全你可以直接抄。1. Miro 与 Fish 各指什么拆开这个名字背后的两个老问题1.1 Mirror以真实库结构为准而不是以代码里的模型定义为准绝大多数项目的造数需求第一步其实不是「写数据」而是「搞清楚该写哪些字段」。这里有个分岔路你是从 ORM 的模型定义里反推字段还是直接去数据库里把真实的表结构读回来。从模型定义反推看起来很优雅毕竟代码就在手边import 一下就能拿到字段列表。但这条路踩过的人都知道坑在哪——模型和真实库之间永远存在漂移。迁移脚本早上跑了一半模型已经改了库还没改某个字段在模型里用db.Column()定义过但迁移文件里被手写 SQL 改成了别的类型还有些类型转换是数据库层的默认值或者触发器在管模型里根本看不出来。我见过最离谱的一次是某个status字段在模型里是String(16)但在 PG 里实际是varchar(8)造数工具按 16 位生成的测试数据一插进去就截断测试用例挂得莫名其妙。Mirror 这个思路的价值就在这儿直接连库用自省接口把information_schema或者等价的结构信息读回来落到一份快照文件里。以后所有造数动作都以快照为准绳模型漂不漂移跟造数没关系。快照本身还能进版本库迁移前后对比一下 diff等于白送你一个「库结构变更审计」的小功能。1.2 Fish数据工厂为什么总是写着写着就变成一坨脚本数据工厂这个概念喊了很多年几乎每家公司都有人写过但活下来的不多。原因不在技术难度而在维护成本。最初版本一般长这样一个factory.py里面写着def make_user()、def make_order()逻辑清晰人见人爱。三个月后需求来了某个测试要造 100 个用户其中 20 个必须是 VIP再过两个月又要造带特定地区属性的用户。于是make_user()的参数列表从 0 个涨到 7 个里面塞满了is_vipFalse, regionNone, extra_overridesNone这类开关。半年后没人敢改这个文件因为一改就有十几个用例红。Fish 这半截想做的事是把「字段默认值怎么定」和「字段之间的依赖怎么排」拆开。默认值交给类型推断和 recipe 配置依赖排序交给图算法工厂函数只负责调用。这样加新表不用改核心逻辑改字段默认值也不用翻工厂函数。听着简单但能把这两件事真正分开的项目其实不多大多数最后都退化成脚本堆。1.3 这个组合适合的边界什么项目值得上什么项目纯属折腾MiroFish 这类工具不是万能药上错项目比不写还累。判断标准我看三条。第一表数量得上 30 张以上外键关系得有真实深度。三张表的项目你手写 fixture 十分钟搞定引入一个造数库光是读文档和写 recipe 就得半天收益为负。第二测试用例数量得足够多多到「造数」这件事重复出现。如果一个模块总共才 5 个测试每个测试精挑细选几条数据就够了那手写反而更可控因为你能一眼看出数据长什么样断言语义清晰。第三也是最容易被忽略的一条数据库里得有真实可用的结构信息。有些项目历史上用的是「全表就一个 JSON 字段」所有业务字段都塞在extra_data里这种情况下自省工具读出来的只有id和一个jsonb造什么数据全靠你手工填工具价值直接归零。反过来如果你的项目是标准的订单 / 用户 / 商品三件套外加一堆字典表表间依赖明确、字段类型规整、测试用例又多那上 MiroFish 这类工具几乎立刻能看到收益。我自己的经验是从第一次跑通到团队里别人开始主动用大概在两到三周之间这个爬坡期能接受。2. 内部流水线一份可复现数据集是怎么被拼出来的2.1 结构快照自省接口的选型与快照文件的形态自省这件事跨数据库的差异比想象中大。我的做法是优先用统一抽象层SQLAlchemy 的Inspector是我最常用的入口因为它把 PG、MySQL、SQLite 的差异抹掉了大半。from sqlalchemy import create_engine, inspect engine create_engine(postgresqlpsycopg://app:app127.0.0.1:5432/appdb) insp inspect(engine) for table in insp.get_table_names(schemapublic): columns insp.get_columns(table, schemapublic) fks insp.get_foreign_keys(table, schemapublic) pk insp.get_pk_constraint(table, schemapublic) uniques insp.get_unique_constraints(table, schemapublic) print(table, len(columns), len(fks), pk.get(constrained_columns))这段代码看着平平无奇但有三个细节值得说。第一个细节是get_columns返回的type是 SQLAlchemy 的类型对象不是字符串。它比裸的information_schema好用能直接判断isinstance(col[type], String)但也会丢掉一些数据库特有的信息比如 PG 的numeric(18,4)里的精度在某些版本下会被压缩。如果你对精度敏感得回到information_schema.columns里捞numeric_precision和numeric_scale补全。第二个细节是自省不代表读数据。整个过程只查元数据表一行业务数据都不会被碰。这一点在合规上极其重要后面第 4 章会专门展开。第三个细节是快照文件用什么格式存。我推荐用一份 YAML 加一份 JSON 的组合YAML 放表名、字段名、可读的注解方便人手工改JSON 放完整类型定义和依赖关系程序读。只用 JSON 的话你手工补一个字段要跟括号搏斗半天。2.2 类型推断哪些字段能自动识别哪些必须人工兜底快照拿到手下一步是给每个字段定一个「默认生成策略」。这一步的自动化程度决定了工具的可用性也决定了你需要改多少配置。能自动推断的部分比想象中多但也比想象中更依赖字段命名。我的经验是分三层来看。第一层是类型层看数据库类型就能定的。boolean直接随机真假integer在合理区间内取date/timestamp在最近一年内取text/varchar生成定长字符串。这一层能覆盖大概六成字段属于闭着眼睛都能做对的部分。第二层是语义层得看字段名的关键词。名字里带email的生成邮箱带phone/mobile的生成号码带name的生成人名带url的生成链接带price/amount的生成两位小数带status的从快照里捞出来的枚举值或者 check 约束里定义的候选里挑。这一层靠关键词匹配准确率大概八成。剩下的两成误判得靠人工兜底常见误判是code这个字段名——它可能是订单号、可能是国家代码、可能是状态码缩写工具没法判断只能默认给个字符串你在 recipe 里覆盖。第三层是业务层这一层工具彻底无能为力。比如order_status必须跟payment_status保持一致性quantity * unit_price必须等于total_amount这种跨字段约束只能写进 recipe 的钩子里。我的个人习惯是让工具把这类字段全部标成NEEDS_REVIEW跑第一次造数的时候直接报错退出逼着你去 recipe 里显式声明。宁可多花二十分钟配置也不要让一批语义错误的数据悄悄流进测试用例里。推断层级依据覆盖率典型误判类型层数据库字段类型约 60%numeric 精度丢失语义层字段名关键词约 25%code、type 类字段含义模糊业务层跨字段约束需人工状态机一致性、金额等式2.3 依赖排序外键图上的拓扑顺序和它的两个例外外键排序是造数工具里最容易写错、也最容易写出死循环的一块。基本模型是个有向图每张表是节点外键指向父表子表依赖父表。插入顺序必须满足所有父表先于子表这就是个标准的拓扑排序。我通常用 Kahn 算法代码不长关键是能把「有环」这个异常情况检测出来而不是死循环from collections import defaultdict, deque def topo_order(tables, deps): indeg {t: 0 for t in tables} graph defaultdict(set) for child, parents in deps.items(): for p in parents: if p child or p not in indeg: continue if child not in graph[p]: graph[p].add(child) indeg[child] 1 queue deque(sorted(t for t in tables if indeg[t] 0)) order [] while queue: cur queue.popleft() order.append(cur) for nxt in sorted(graph[cur]): indeg[nxt] - 1 if indeg[nxt] 0: queue.append(nxt) if len(order) ! len(tables): raise RuntimeError(f检测到循环依赖: {set(tables) - set(order)}) return order这段代码里p child那一行是关键它把自引用外键从依赖图里剔出去了。自引用是第一个例外典型场景是categories.parent_id、employees.manager_id、comments.reply_to_id。这类表理论上可以自身依赖自身但实际插入时的处理办法是先把所有行的自引用字段留空插进去插入完成后再跑一轮UPDATE回填父引用。这样既避开了循环也不用担心排序问题。第二个例外是交叉依赖也就是 A 依赖 B、B 又依赖 A。这在设计良好的库里应该不存在但真实项目里偶尔会因为历史遗留出现——通常是某张表后来加了个外键而两边都允许为空。处理方式是把其中一个可空外键从图的边里剔除插入时置空插完之后再回填。如果你看到检测到循环依赖这个报错先别急着改算法去数据库里查一下information_schema把这几个表的外键梳理一遍大概率能找到那条可空边。2.4 随机源分离让同一个 seed 稳定吐出同一批数据造数工具最容易被忽视、但出问题时最让人抓狂的一个点是随机源。测试用例里最忌讳的就是「同样的输入跑两次结果不一样」。如果你的造数工具直接用全局的random模块和 Faker 的默认实例那么并行的测试进程之间会互相污染随机状态同一个 seed 在不同测试里的输出可能完全不同。表现出来就是单跑这个用例是绿的跑全量就红重跑一次又绿了。这类间歇性失败排查起来能耗掉你一整天。我的做法是两层隔离。第一层每个造数会话创建一个独立的random.Random(seed)实例所有跟随机相关的操作都走这个实例不碰全局。第二层Faker 那边用seed_instance而不是Faker.seed()因为前者只影响你手上这个实例后者会改全局状态。import random from faker import Faker def build_context(seed: int): rnd random.Random(seed) fake Faker(zh_CN) fake.seed_instance(seed) return rnd, fake这里还有个容易忽略的坑字典遍历顺序。Python 3.7 之后 dict 保序但set不保序。如果你在遍历表的外键集合时用了set直接迭代那同一批数据的生成顺序在不同进程里可能不一致最终导致生成结果的差异。所以上面拓扑排序那段代码里我特意用了sorted()包一层代价微乎其微换来的是可复现性。实测下来这套隔离做到位之后同一个 seed 在单进程、多进程、不同机器上生成的字节级输出应该完全一致。你可以写一个测试来锁住这个行为拿固定 seed 生成一批数据算个 SHA256跟预期值比对。这个测试能帮你挡住大部分「莫名其妙就红了」的回归。3. 本地跑通一遍从零到第一条可用数据的完整过程3.1 依赖选型与环境准备以及为什么不用纯 ORM 方案环境准备这一步我要先说清楚一个选型决定为什么不用纯 ORM 批量session.add_all()的路子。纯 ORM 方案的优点是省事模型现成的直接构造对象往下丢就行。但问题在于你造数的目标库可能根本没有对应的模型代码。测试环境连的是另一套库或者你在做一个数据迁移验证模型层压根不存在。这种情况下 ORM 方案直接卡死。所以我的选型是自省和依赖分析用 SQLAlchemy 的Inspector只读元数据不涉及 ORM 映射数据写入用裸 SQL 加参数绑定批量场景走数据库原生的批量接口。这样整套东西不依赖任何模型定义任何库连上去都能用。依赖清单不长sqlalchemy2.0自省和连接管理faker语义化字段的假数据来源pyyamlrecipe 配置文件的可读格式数据库驱动按你要连的库选PG 用psycopg[binary]MySQL 用pymysql至于为什么不用 ORM 写还有第二个原因性能。第 5 章会详细讲ORM 单条 insert 和批量插入在 5000 行这个量级上的耗时差距能有二十倍以上。在测试套件里每跑一次就多等十几秒累积起来是很烦人的。3.2 从快照生成 recipe再手工补全推断不出来的部分环境装好之后第一步是生成结构快照第二步是从快照吐出 recipe 草稿。命令大概是这样mirofish snapshot \ --dsn postgresqlpsycopg://app:app127.0.0.1:5432/appdb \ --schema public \ --out .mirofish/schema.json mirofish recipe init \ --schema .mirofish/schema.json \ --out .mirofish/recipe.yaml生成的 recipe 草稿会按表分组每个字段给出推断出的策略和一段注释。关键动作在下一步把所有标记为needs_review的字段过一遍。我的一般流程是先跑一次mirofish recipe check它会把所有未确认的字段列出来然后我对着表一个一个改。改的时候有几个经验。字段名带code的你得先确认它在业务上是不是枚举如果是就写死候选列表。金额字段记得加精度避免生成0.30000000000000004这种浮点数尾巴。时间字段别用纯随机考虑用「基准时间加偏移」的写法这一点第 5 章有专门一节讲为什么。3.3 第一次执行必然撞上的三个报错与对应解法不管你配置得多仔细第一次执行几乎一定会报错。我把最常见、也最容易让人困惑的三个列出来。第一个是外键约束失败报错信息里会出现violates foreign key constraint。九成原因是 recipe 里某张表的行数配置跟父表不匹配。比如你配了 100 条订单但只造了 10 个用户而订单的用户 ID 是在 1 到 100 之间随机取的那就会出现指向不存在用户的情况。解法不是加大用户数而是在 recipe 里把订单的用户 ID 取值方式改成「从已生成的用户 ID 池里取样」。这个配置项一般叫sample_from或者pick_from_generated不同实现叫法不一样但思路一样。第二个是唯一约束冲突报错是duplicate key value violates unique constraint。这个通常出现在字段级假数据上你让 10000 条记录都生成邮箱Faker 在这么大的量级上是会碰撞的尤其是用固定 seed 的时候。解法有两种一种是给字段加unique: true标记让工具去重另一种是干脆用「前缀加序号」的确定性生成方式比如user{index}example.test这样永远不会重复也方便你在断言里直接引用。第三个最迷惑语法正确、约束也没问题但插入报column xxx does not exist。这时候去检查快照里的字段名是不是带了引号或者大小写。MySQL 在某些配置下字段名大小写敏感而自省返回的名字可能跟 SQL 里写的不完全一致。遇到这种问题把快照文件和information_schema.columns的原始输出对比一下很快能找到差异。3.4 接进测试夹具和 CI造数下半场才是真正的收益点造数工具在本地跑通只是上半场真正决定它能不能活下来的是下半场怎么接进测试夹具和 CI。我的做法是把造数封装成一个 pytest fixturesession 级别只造一次然后通过事务回滚保证用例之间互不干扰import pytest from mirofish import MiroFishSession pytest.fixture(scopesession) def mf_engine(): session MiroFishSession( dsnpostgresqlpsycopg://app:app127.0.0.1:5432/appdb_test, recipe.mirofish/recipe.yaml, seed20240601, ) session.build() yield session session.teardown() pytest.fixture def mf(mf_engine): with mf_engine.transaction() as tx: yield tx tx.rollback()这里有个关键取舍seed 到底该固定还是该随机。我的建议是 CI 上固定本地开发随机。CI 固定是为了可复现出了问题能一模一样重跑本地随机是为了让数据有变化能提前发现那些「数据太规整导致恰好通过」的脆弱测试。你可以通过环境变量控制这个行为一行代码的事。还有个细节是 CI 上的库要单独建不要跟开发库共用也不要跟其他任务的测试库共用。我吃过这个亏两个 job 并行跑一个在造数一个在清理结果互相踩报了一堆莫名其妙的约束错误。后来给每个 job 加了独立的 database 后缀才彻底解决。4. 字段脱敏结构可以镜像值绝对不能照抄4.1 先划边界结构镜像与数据镜像必须分清前面聊了这么多 Mirror这里必须把话说死镜像的对象是结构不是数据。这两件事在实现上只有一墙之隔但在风险上天差地别。读information_schema拿到的是表名、字段名、类型、约束这些都是代码级别的元信息跟业务内容无关。而一旦你开始SELECT * FROM users LIMIT 1000把真实数据捞出来当样本性质就完全变了。我的原则很简单造数工具在任何情况下都不执行对业务表的SELECT。只查元数据表。如果某个字段的取值分布对测试确实很重要——比如你希望生成的金额分布贴近真实业务——那也应该由业务方提供一个脱敏后的分布描述比如分位数区间而不是直接拉原始数据。这个边界如果一开始不划清后面一定会有人为了方便「加个开关」然后这个开关在某个深夜被打开跑完一次全量本地多出来一份真实数据的副本。这种事一旦发生清理成本极高。4.2 六种脱敏策略的适用场合与取舍对照虽然 MiroFish 本身不读真实数据但在实际项目里有时候你确实需要一份「基于真实数据生成脱敏样本」的能力用来做数据迁移验证或者回归对比。这时候策略选择就很重要了。策略实现方式保留特性适用场景风险直接丢弃字段置空无日志、备注类字段无定长哈希SHA256 截断join 一致性用户标识类外键低格式保留替换逐位映射长度与格式手机号、卡号中映射表需保护词典映射维护替换词典类别分布姓名、地区中词典可能被反推数值扰动加噪声或分桶统计分布金额、年龄低截断覆盖用生成的假数据整体替换无高敏感整表无这里面最值得说的是定长哈希。它的妙处在于同一个原始值哈希结果永远相同所以原来有外键关联的两张表脱敏之后关联关系还在join 依然能跑通。这一点在需要做数据一致性验证的场景里非常关键。但要注意别用加盐的哈希然后每行换盐那样关联就断了。格式保留替换的争议最大。它能保持数据的「形状」让测试环境里的数据看起来跟真的一样但代价是你得维护一张映射表而这张映射表本身就是敏感资产泄露了等于没脱敏。4.3 唯一约束、枚举校验与脱敏规则的正面冲突脱敏规则和数据库约束打架是这类工具最常见的运行期问题。第一类冲突是唯一约束。手机号做了格式保留替换之后两个不同用户可能映射到同一个假号码上然后撞唯一索引。解法是在替换时加一个稳定的偏移量比如按行号错开最后两位但这要保证偏移后的号码依然是合法格式某些号段有校验规则的话还得重新算校验位。我的做法是替换后用工具自带的合法性校验函数过一遍不合格的重新生成直到合法。第二类冲突是枚举校验。某个字段在数据库里有 check 约束只允许有限几个值而脱敏词典里混进了别的值直接插入失败。这类问题最好的解法是让脱敏词典从数据库约束里反向生成确保值域是约束的超集。手工维护词典的话每次业务加枚举值都得同步改迟早会漏。第三类是长度约束。原文用varchar(64)脱敏后生成了 70 个字符的字符串截断或者报错。这个在格式保留替换里特别常见因为有些格式化的号码会变长。解决办法很简单替换前先查快照里的长度限制超了就走短版生成器。4.4 一个真实的反面案例复盘说个我亲眼见过的事故细节我做过处理但问题类型是真的。有个团队为了做一次性能压测需要贴近真实分布的数据。有人提了个方案从正式库导一份全量数据到压测库跑完压测就删。流程上看起来没问题也确实是这么执行的。但漏了一环——压测库的备份策略沿用了默认配置自动快照在数据导入后、删除前那个窗口里跑了一次。结果就是那份真实数据在备份存储里躺了大半年直到一次存储审计才被发现。这个案例里没有恶意每一步单独看都合理问题是几个「合理」叠加起来制造了一个没人负责的盲区。所以我一直坚持一个观点造数工具的设计目标应该是「让你不需要真实数据」而不是「让你能安全地使用真实数据」。前者的风险面小得多因为根本没有那个环节。落在具体做法上我给三条造数工具的连接串权限只给SELECT元数据表不给业务表读权限造数环境跟任何可能有自动备份的存储隔离recipe 文件里禁止出现任何看起来像真实数据的字面量比如具体的邮箱后缀、具体的手机号段。5. 卡了我最久的四个坑以及排查路径5.1 显式 ID 写入之后自增序列没有跟着走这个坑我踩了不止一次而且症状很迷惑造数阶段一切正常插进去的数据也能查到但业务代码一执行插入就报主键冲突。原因是 PostgreSQL 的主键自增serial/identity背后是一个独立的序列对象你在插入时显式指定了 ID 值数据库会老实地用你给的值但序列的当前值不会自动往前跑。等你造完 10000 条数据序列还停在 1下一条正常插入自然撞车。排查路径很直接造数完成后写个校验查询看看序列位置和实际最大 ID 差多少。SELECT pg_get_serial_sequence(orders, id) AS seq, (SELECT last_value FROM orders_id_seq) AS seq_value, (SELECT MAX(id) FROM orders) AS max_id;修复就是把序列拉到最大值SELECT setval( pg_get_serial_sequence(orders, id), (SELECT COALESCE(MAX(id), 1) FROM orders), true );这一步应该做成造数流程的收尾动作所有带自增主键的表都跑一遍。我后来干脆把它写进了工具的 teardown 钩子里再也没想过这个问题。MySQL 那边机制不同AUTO_INCREMENT在插入显式值时会自动抬高到max1不用管但 MariaDB 的某些版本行为有差异跨库场景建议两边都显式校准一次。5.2 时间字段被随机打散按天聚合的断言集体失灵有段时间我负责的报表模块测试用例开始出现奇怪的失败单跑都过全量跑的时候有几条报「按天聚合结果为空」。查了两天才定位到造数工具身上。原来时间字段的策略是「最近一年内均匀随机」一万条数据散在 365 天里平均每天才 27 条。而某个报表断言是「查询某一天的汇总期望大于 0」那个特定日期正好一条数据都没落上断言自然挂了。这个问题的本质是随机均匀分布跟业务的真实时间分布差得太远。真实业务里工作日的数据量可能是周末的好几倍月初月末还会有峰值而均匀随机完全看不出来。测试用例如果对时间分布有任何隐含假设就会随机挂。我的修复方案是把时间字段的策略从「均匀随机」改成「基准时段内加权分布」给一个基准时间点让数据集中落在基准点前后的业务活跃窗口里同时保证每个自然日至少有一条。实现上可以用「日期从固定列表里循环取 当天内时间随机」的组合既保证了分布紧凑又保留了时间维度的多样性。orders: row_count: 3000 fields: created_at: strategy: weighted_time base: 2024-05-01T00:00:00 span_days: 30 weight: weekday_heavy min_per_day: 1改完之后那几条断言就稳定了。这件事给我的教训是造数工具里的「随机」永远要考虑业务语义纯粹数学意义上的随机在测试场景里往往是有害的。5.3 自引用外键导致拓扑排序进死循环前面提过自引用要剔除但我在实际实现里还是栽过一次而且栽得比较隐蔽。那次的情况是categories表有parent_id自引用我按计划把它从依赖图里剔除了。但问题出在剔除的方式上——我是按「父表等于子表」这个条件剔除的结果categories表里还有另一个外键指向category_groups这一条应该保留的边被我一起漏掉了。表现就是造数时categories先于category_groups插入外键约束直接报错。定位过程是这样的先打开依赖图的调试输出把每张表的入边全部打出来肉眼对比。然后发现categories的依赖列表是空的但它明明有一个group_id外键指向category_groups。def debug_deps(tables, deps): for t in sorted(tables): print(f{t:24s} - {sorted(deps.get(t, []))})修复是把剔除条件改成按「列名 目标表」双重判断只剔除真正的自引用边其他边一律保留。这件事之后我给依赖分析模块加了个断言如果某张表在快照里有 N 个外键约束那它进入依赖图的边数加上被剔除的自引用边数必须等于 N。数量对不上就直接报错把问题挡在排序之前。5.4 批量插入的性能拐点到底在哪最后一个坑是关于性能的也是让我改了整个写入层设计的那个。同一台本地 PG同一批 5000 行数据我用三种方式测了一遍。结果如下数字是本地跑三次取中位数仅供参考写入方式5000 行耗时备注ORM 单条 add commit约 11.8s每条一次往返executemany 参数绑定约 1.4s一次往返复用预备语句原生 COPY约 0.35s绕过 SQL 解析层差距的来源很直白。ORM 单条插入是「构造 SQL → 发送 → 等待 → 返回」5000 行就是 5000 次往返网络延迟的累积效应非常明显。executemany把 5000 次往返压成一次但依然走完整的 SQL 解析和执行计划生成。COPY直接跳过 SQL 层走二进制或者文本流协议速度再快一个数量级。那为什么不是无脑上COPY因为它有几个限制一是不能触发BEFORE INSERT触发器和某些约束检查如果你的表上依赖触发器做数据处理COPY会把逻辑绕过去二是出错时定位困难它只会告诉你「第几行有问题」但那行具体哪里有问题得自己去比三是它跟事务回滚的配合不如 SQL 语句自然大数据量下 WAL 压力会集中爆发。所以我的实际选型是分档5000 行以下用executemany足够快且行为可控5000 行以上、表上没有触发器、且允许一次性写入的话换COPY。5000 这个数字不是硬阈值是我在本地测出来的拐点你在自己环境里测一遍大概率在 3000 到 8000 之间差别主要来自网络延迟和表结构复杂度。6. 什么时候该用它什么时候该老老实实手写 fixture6.1 场景对照表造数库与手写夹具的取舍工具用了一段时间之后我对「什么场景该用」这件事有了比较稳定的判断。做成表格会清楚很多。场景推荐方案理由单表查询逻辑的单元测试手写 fixture三五行数据工具成本高于收益跨 5 张以上表的集成测试MiroFish依赖排序人工做极易出错报表聚合类测试MiroFish需要量级和分布手写不现实边界值测试手写 fixture需要精确控制每一个值迁移前后数据一致性验证MiroFish需要完整结构覆盖需要精确断言的金额计算手写 fixture必须完全掌控数值性能压测数据准备MiroFish量级和分布是核心诉求状态机流转测试混合状态值用 fixture 定关联数据用工具造这里面最有意思的是最后一行「混合」模式也是我现在最常用的方式。具体做法是用造数工具生成骨架数据保证外键关系正确、量级充足然后在测试用例里通过一个覆盖接口把当前用例关心的那几个关键字段改成精确值。def test_order_refund(mf): order mf.orders.take_one() mf.patch(order, statuspaid, total_amountDecimal(99.00)) result refund_service(order.id) assert result.status refunded这样做的好处是兼顾了两头不用手搓整套依赖链也不用为了精确断言放弃造数工具。take_one()返回一条已经落库的记录patch()做的是内存和库内的双写更新用起来跟操作普通对象没区别。6.2 再往前一步快照对比与回归基线结构快照这个副产品其实还能干一件挺有价值的事。把快照文件纳入版本管理之后每次数据库迁移前后各生成一份快照然后做 diff。你能立刻看到这次迁移到底改了什么哪个字段类型变了、哪个索引被删了、哪个外键新增了。这比翻迁移文件直观得多因为迁移文件里写的是「意图」快照里是「结果」两者不一致的时候问题就暴露出来了。我自己搭的一个小流程是这样的CI 上跑迁移之后自动生成新快照跟仓库里的旧快照做对比如果出现了非预期的变更就打标记提醒。这个机制帮我抓到过两次「迁移脚本写对了但执行顺序错了导致最终结构不对」的问题收益相当可观。再往前一步快照还可以当造数的回归基线。把某个固定 seed 下的造数结果总体特征行数、各表字段的非空比例、枚举值分布、时间跨度记录下来跟下一次对比。如果差异超过阈值说明有人改了 recipe 或者改了数据结构需要人工确认。这个检查跑起来很快但能挡住大部分「不小心改了配置」的情况。最后分享一个我个人实际用下来最有效的习惯把你项目里那些「造数特别麻烦」的表单独列一个清单放在仓库根目录的文档里每解决一个就划掉一个同时把 recipe 里的对应片段链接过去。这个清单会慢慢变成团队里新人的入门材料也是我自己回头看时最有价值的东西之一。工具本身只是壳子真正沉淀下来的是这份「哪些表难搞、为什么难搞、怎么绕过」的经验。
返回列表