没做过多租户系统测试的人很难理解为什么一个看似普通的 pytest 测试套件跑到企业级多租户项目上会这么难搞。表面上只是“并发跑测试”这一个需求拆开之后其实是租户隔离、数据生命周期、连接池管理、共享状态治理一整串问题。这篇文章记录了我最近在一个企业级 B 端项目中把 pytest 自动化测试从全量串行升级到并发执行的完整过程包括隔离方案选型、xdist 落地步骤、踩坑实录和最终效果数据。适合正在做多租户系统测试、或者想给已有 pytest 套件引入并发但不知道从哪里下手的团队参考。1. 多租户项目到底难在哪儿1.1 先搞清楚租户隔离的三种玩法所谓多租户就是一套系统服务多家客户但每家客户的数据在逻辑上必须互不可见。做测试的人如果只把多租户理解成“表里多一个 tenant_id 字段”后面一定会踩大跟头因为隔离逻辑的影响面比想象中大得多。常见的租户隔离方案有三种共享库共享表、共享库独立 Schema、独立数据库。我把它们的核心差异列成了一张表隔离级别数据存储方式隔离强度测试复杂度共享库共享表每行数据带 tenant_id 字段弱低共享库独立 Schema每个租户一套表结构中中独立数据库每个租户一套独立库强高我们项目用的是混合方案核心业务表共享通过 tenant_id 隔离少数合规要求高的模块走独立 Schema。这种混合模式在企业里很常见因为它兼顾了成本和控制力但对测试来说意味着要同时处理两种隔离语义复杂度直接翻倍。另一个容易被忽略的点是多租户的隔离逻辑不只在 SQL 的 WHERE 条件里。权限校验要看租户角色、缓存 key 要拼租户 ID、消息队列 topic 要按租户划分、文件存储路径要带租户前缀、日志链路 trace_id 也要包含租户维度。测试里你创建了一条订单数据如果没带上正确的租户上下文这条数据可能落到别的租户名下或者干脆查不到然后用例报出让人摸不着头脑的错误。1.2 串行时代的两大痛点在动手改并发之前我们先把串行跑测试时最大的两个痛点梳理清楚因为这两个痛点直接决定了后面的并发方案怎么设计。第一个痛点是慢。全量用例跑完接近 38 分钟而且越往后越慢。原因不难猜每个用例都要初始化租户、准备数据、清理数据这些操作大部分是重复的。38 分钟对 CI 来说太长了代码提交之后要等将近一节课的时间才能拿到反馈开发同学怨声载道下班前根本不敢提交代码。第二个痛点是数据残留。早期我们用“用例结束删数据”的方式清理但只要用例中途断言失败清理逻辑就执行不到脏数据就留在库里。脏数据多了以后后面的用例开始互相影响——查订单总数对不上、唯一约束冲突、分页结果不对。整个测试套件变得越来越脆今天能过明天不能过没人敢保证自己改的代码是好的。这两个痛点凑在一起结论就很明确必须引入并发执行而且必须把数据隔离做到让每个用例“看起来是在一个干净的世界里运行”。串行时代的清理策略在并发下不单是慢的问题是直接会搞出偶发失败。2. 隔离方案选型并发的前提是“谁也不认识谁”2.1 租户上下文传递用 ContextVar 而不是线程局部变量要让并发用例各跑各的租户第一步是解决租户上下文在代码链路里的传递问题。我们的服务端代码原来用 ThreadLocal 存当前租户 ID启动时从登录态解析出来塞进去。但这个方案在并发测试下有个致命问题pytest-xdist 的每个 worker 是一个独立进程进程内如果存在多线程复用同一个 ThreadLocal租户信息就会互相覆盖A 用例刚设置的租户B 用例一读就变成了自己的。我们后来把上下文存储从 ThreadLocal 换成了 Python 3.7 的 ContextVar。这个机制设计出来就是为了解决异步和并发场景下的上下文隔离问题每个独立的执行上下文都有自己的副本天然不会串。改造的成本不算高服务端接口挂一个中间件从请求头解析租户 ID设置到 ContextVar 里测试端模拟同样的动作测试启动时先设置租户上下文再调业务接口整条链路上传的租户信息就保持一致了。这里有一个细节值得提醒ContextVar 的 set 返回 token用完之后要 reset。如果只 set 不 reset上下文会越积越多内存都会跟着涨。我们在 fixture 的 teardown 里统一做 reset保证每个用例结束后上下文回到初始状态。2.2 测试数据生命周期事务回滚为主定向清理兜底关于测试数据清理业界有两派做法一派是每个用例包在事务里测试结束直接 rollback数据自动消失另一派是用例结束执行 DELETE 或 TRUNCATE。事务回滚本来是理想方案但放在多租户场景里有个坑如果被测代码里有“提交事务后异步发消息”的逻辑事务回滚会把数据撤销但消息已经发出去了消费端拿到一个不存在的数据对账就会出问题。我们最终用的是混合策略——纯接口层的用例走事务回滚涉及异步链路的用例走定向清理。定向清理不是简单 DELETE。我们约定每个测试租户用统一命名前缀比如t_auto_清理任务按前缀扫表把该租户名下所有测试数据清掉。这样即使用例异常退出只要启动下一轮测试前执行一次全局清理就能把残留数据兜底清掉。我们还写了个校验脚本清理结束之后检查每个测试租户的表行数是否为 0不为 0 直接告警把问题暴露在下一轮测试开始之前。2.3 动态租户池每个 worker 都有专属租户并发改造里最重要的一步是给每个 pytest worker 分配一个独立的测试租户。如果一个 worker 里的不同用例随机用不同租户租户创建和销毁的开销会非常大如果所有 worker 共用一个租户数据又会串。我们做了个“租户池”的 session 级 fixture逻辑如下import contextvars import uuid import pytest from tenant_client import TenantAPIClient tenant_var contextvars.ContextVar(current_tenant, defaultNone) pytest.fixture(scopesession) def test_tenant(worker_id): 按 worker 动态分配专属租户。 worker_id 是 pytest-xdist 自动注入的形如 gw0、gw1... tenant_name ft_auto_{worker_id or master}_{uuid.uuid4().hex[:8]} client TenantAPIClient() client.create_tenant(tenant_name) yield {name: tenant_name, client: client} client.delete_tenant(tenant_name) pytest.fixture(autouseTrue) def _apply_tenant(test_tenant): 每个用例执行前把当前租户设置到 ContextVar token tenant_var.set(test_tenant) yield tenant_var.reset(token)这个方案有几个细节值得展开。session 作用域意味着一个 worker 整个测试过程只创建一次租户创建开销平摊到几十个用例上非常划算。worker_id 保证不同 worker 的租户名不冲突末尾那串 uuid 是为了防止多轮测试之间残留数据导致的命名碰撞。autouse 的_apply_tenantfixture 保证任何用例进来时 ContextVar 里已有正确租户。这里有个易错点fixture 的依赖关系必须显式写出来。我们早期没让_apply_tenant依赖test_tenant结果某些用例的租户还没创建就去读 ContextVar拿到 None报错位置千奇百怪。后来统一加上依赖关系这个问题就消失了。3. pytest 并发改造从串行到并行3.1 并发插件选型xdist 依然是最稳的选择pytest 生态里做并发的插件主要有三个pytest-xdist、pytest-parallel、pytest-concurrency。我直接说结论企业级项目选 pytest-xdist。pytest-parallel 已经不维护了对新版 pytest 兼容性差我们试过一个版本直接收集阶段就崩pytest-concurrency 用线程池实现对 IO 密集型用例有效但有全局解释器锁的问题计算密集的断言反而更慢。pytest-xdist 基于进程每个 worker 是独立的 Python 进程隔离性最好脏数据问题天然少一层而且它由 pytest 官方团队维护兼容性跟得紧。它还有个加分项是--dist loadgroup模式。我们可以给存在依赖关系的用例打标记让它们落在同一个 worker 上执行避免跨 worker 的数据依赖。这个能力在我们处理异步链路用例时是救命级别的。安装和基本用法pip install pytest-xdist pytest -n auto-n auto会按 CPU 核数自动决定 worker 数量。但我们不建议无脑 auto因为连接类资源的上限往往不是 CPU而是数据库和 Redis。这个坑在 3.4 节会详细讲。3.2 用例分组哪些能并行哪些必须串行不是所有用例都适合无脑并行。我们花了两个版本迭代才把用例分类搞明白。分类标准只有一条有没有跨用例的共享状态。第一类是纯接口用例各自创建数据、各自断言这类无脑并行收益最大。第二类是涉及异步消息的用例。消息队列是天然的共享资源两个用例同时消费同一批消息先后顺序不可控结果必然不稳定。我们的做法是给这类用例打async_group标记配合--dist loadgroup让同一批异步用例落在一个 worker 上串行执行。这样消息的消费顺序可控不会出现两个用例互相抢数据的局面。pytest.mark.async_group(order_flow) def test_order_async_flow(): ...第三类是涉及文件上传下载的用例。这类用例我们直接标记排除在常规流水线之外只在 nightly 回归里跑。因为文件存储路径虽然是租户维度生成的不会串数据但并发下对象存储的限流和磁盘 IO 竞争会导致偶发超时没必要让它们在每次提交时都跑一遍。分组之后执行命令变成pytest -n 4 --dist loadgroup -m not slow --maxfail3这里-m not slow意思是排除所有带slow标记的用例把文件类、异步全链路类用例留给 nightly。日常提交只跑接口层和业务层反馈速度最快。3.3 fixture 作用域重设计session 级复用 function 级清理并发改造中 fixture 作用域的设计是最容易翻车的地方我单独拿出来说。并发之前我们大量使用 module 级和 class 级 fixture目的无非是减少重复初始化。但并发之后发现module 级 fixture 在不同 worker 里各自执行没问题可一旦 fixture 内部维护了可变状态——比如一个模块级列表缓存——同一个 worker 内多个用例串行使用时上一个用例的残留状态就会被下一个用例读到偶发失败就来了。我们的调整原则是能复用的只有纯资源租户、数据库连接、Redis 连接、配置对象业务状态一律放 function 级。代码示例pytest.fixture(scopesession) def db_session(test_tenant): 每个 worker 一个独立的数据库连接注意 scope 是 session engine create_engine( config.db_url.replace({tenant}, test_tenant[name]), pool_size3, max_overflow1, pool_pre_pingTrue, ) Session sessionmaker(bindengine) yield Session engine.dispose() pytest.fixture(scopefunction, autouseTrue) def clean_test_data(db_session, test_tenant): 每个用例结束清理当前租户下的测试数据 yield cleanup_client.clean_tenant_data(test_tenant[name])这里有个反直觉的点db_session是 session 级但它连接的是“当前 worker 专属租户”的库。因为租户 fixture 是 session 级且先于db_session执行所以此时test_tenant[name]已经是确定值。每个 worker 连接自己的租户库互不干扰。这就是动态租户池的威力——session 级资源 租户维度隔离两全其美。事务回滚的用例还需要注意一个细节如果用例内部开启了自己的事务而外层又有 autouse 的事务控制嵌套事务的行为在 Python 的 DB-API 里很容易踩坑。我们统一约定走事务回滚的用例代码里不允许显式 commit所有数据库操作挂在同一个事务上下文里测试结束统一 rollback。这需要在团队规范里写清楚否则总会有人偷偷 commit。3.4 数据库连接池参数要跟着 worker 数量走这个坑我们付出了不小的代价。改成 4 个 worker 并发后第一次全量跑跑到一半数据库直接报连接数耗尽。查监控发现连接数顶到了配置上限。原因很清晰原来单进程时连接池大小 10 够用现在 4 个进程每个进程按配置又建了 10 个连接瞬间 40 个连接打过去业务表本身还有慢查询连接全部被占住其他 worker 全部排队等连接。解决方式是控制两件事worker 数量和每个 worker 的连接池大小。我们把-n固定为 4连接池调成pool_size3, max_overflow1并加pool_pre_pingTrue检测失效连接。测试环境数据库实例的配置也按这个模型统一评估了一轮确认不会把测试库打挂。如果你们的机器核数多千万别直接-n auto。先确认数据库和 Redis 能扛住多少个并发连接再回头定 worker 数。这个顺序千万别搞反。4. 并发下的坑问题排查与修复实录这个章节全是实战中真实踩过的坑。我按“现象—根因—修复”整理成速查表方便直接对照现象根因修复方案部分用例偶发断言失败重跑就过共享全局变量被多用例改写改用 ContextVar排查所有模块级可变对象数据库连接数飙升打满每 worker 独立连接池总数超限控制 worker 数调小 pool_size加 pool_pre_ping唯一约束冲突租户名重复多轮测试残留数据未清干净租户名加 uuid 后缀启动前全局清理Redis 缓存串租户缓存 key 未拼租户 ID统一缓存 key 生成函数强制带租户维度异步消息消费重复同一批消息被多个 worker 的用例消费异步用例加 group 标记强制同 worker 串行文件上传偶发超时对象存储限流 并发上传文件类用例排除出常规流水线4.1 全局变量污染最隐蔽的一个坑有一阵子我们的订单查询用例时不时失败规律很诡异单独跑一定过全量并发跑就偶发挂。我一度以为是数据库慢查询导致超时加了各种等待还是复现不了。后来通过打印每个 worker 的 PID 和用例执行顺序发现失败集中在同一个 worker 上而且和另一个“登录态管理”用例的执行顺序强相关。那个用例维护了一个模块级字典缓存登录 token虽然每次用完清了 key但字典对象本身是模块级共享的。并发下用例执行顺序不可控前面的用例把 token 写进去后面的用例读到了旧 token自然查不到订单。修复方式就是前面说的把模块级可变状态全部改造成 ContextVar或者收进 function 级 fixture。这个坑给我们的教训是多租户项目里任何“根据当前用户或租户变化”的状态都不允许放模块级。这条规则后来写进了团队测试代码规范之后再没出现过这类问题。4.2 连接 URL 占位符没替换不报错的最坑错误再分享一个配置层面的坑。我们的数据库连接 URL 里有个{tenant}占位符单租户时代这个占位符永远被替换成默认租户。并发改造后db_sessionfixture 里用test_tenant[name]替换占位符结果有个模块级的常量在 import 时就把连接 URL 算好了导致 worker 启动后实际连的还是默认租户库。这类问题最隐蔽因为不报错。两个 worker 的数据混在一个租户里测试结果还经常是对的因为数据量小碰不上冲突。直到跑大数据量用例时才炸出来。排查手段是给每个测试租户的库写入一行“身份标记”数据测试断言时顺带校验当前连接绑定的租户 ID 是不是自己 worker 的。这一步非常值得做相当于给测试环境加了个自检任何连接串租户都能在下一条用例的断言里暴露出来。4.3 xdist 的 maxfail 语义和你想的不一样还有一个经常被忽略的细节--maxfail3搭配 xdist 时不是“全局最多失败 3 个就停”而是“每个 worker 各自最多失败 3 个就停”。如果开了 4 个 worker全局最多可能看到 12 个失败才会停。想全局控制需要写自定义 hook或者结合--max-worker-restart一起规划。这个语义差异直接影响 CI 流水线上的失败通知和重试策略。我们按 4 worker 的规模把 CI 的失败阈值从 3 调成了 10避免“设置了 3 却报了 8 个失败”造成的一脸懵。5. 效率数据与长期维护5.1 改造前后的数据对比我们用全量 1200 条用例做了一次对比机器配置是 4 核 8G数据库和 Redis 是独立测试环境。指标改造前串行改造后4 worker 并发全量执行时间38 分钟11 分钟首次失败发现时间平均 20 分钟平均 4 分钟偶发失败率约 6%数据残留导致约 1.5%主要是异步时序数据库连接峰值约 10 个约 18 个执行时间从 38 分钟压到 11 分钟CI 反馈周期从“倒杯水回来还没跑完”变成“处理两三个消息就能看到结果”。偶发失败率大幅下降主要归功于租户隔离和数据清理而不是并发本身——这也侧面说明串行时代的很多 flaky 用例本来就是数据污染导致的。5.2 CI 集成与稳定性护栏并发改造完成只是开始长期维护才是难点。我们建立了三条护栏保证它不退化。第一CI 流水线里加了“冒烟层”。每次提交只跑全量用例里标记为smoke的 200 条核心用例4 个 worker 并行 3 分钟内跑完不通过直接拦截合并。全量回归放到 nightly 执行避免每次都等 11 分钟。第二失败的用例自动重跑一次如果重跑通过标记为flaky并单独统计。这个数据很重要如果 flaky 比例超过 5%说明并发隔离又出问题了该回头检查租户分配或者共享状态治理。第三每轮全量测试前自动执行一次全局清理任务把前缀为t_auto_的租户数据全部清掉。这是“兜底中的兜底”保证即使上一轮有异常退出导致的残留数据也不会带到下一轮。6. 一些个人体会最后说点操作层面之外的东西。多租户系统的并发测试本质上是在和“共享”作斗争。共享数据库、共享缓存、共享消息队列、共享进程——每一个共享点都可能变成测试不稳定性的来源。pytest-xdist 只是把问题暴露出来的工具真正解决问题的是隔离策略。所以如果你准备开始做这件事我建议先花两周时间梳理系统的所有共享资源列成清单再动手改测试框架。顺序反了的话你会被各种偶发失败淹没分不清是代码 bug 还是测试环境问题。另外测试租户的命名和生命周期管理最好做成内部公共库不要每个项目各搞一套。我们后来把租户创建、数据清理、连接池管理抽成了一个内部测试工具包新项目接入并发测试的时间从两周缩短到两天。这种基础设施越早抽象越划算。如果你也在做类似的事情希望这份记录能帮你少踩几个坑尤其是 ContextVar 和连接池那两段真的值得多看两遍。