数据库、消息与并发测试:状态和时序失败怎样被确定复现
用真实隔离级别、消息重复乱序、并发屏障和确定见证复现丢失更新、重复提交与时序错误。
PostgreSQL 的 Transaction Isolation 说明隔离级别可见性;Kafka 的 Design 解释投递、位点和幂等边界。
数据测试必须使用目标方言和真实约束
H2 或内存替身适合快速 DAO 形状检查,却不能证明 PostgreSQL/MySQL 的锁、隔离、索引、JSON、时区和错误码。关键仓储测试连接与生产同族版本,运行真实 migration,验证唯一键、外键、非空、条件更新和查询计划入口。测试数据绕过 migration 手工建表会让绿色失去意义。
事务隔离实验至少用两条独立连接。单连接内读写无法制造不可重复读、幻读或写冲突;同一 Spring 测试事务更会把所有动作包在一起。每条连接记录 begin、read version、barrier、write、commit 与异常,最终从第三条连接读取权威状态。
屏障让竞争窗口可重复
Thread.sleep 只是提高相撞概率。CountDownLatch、CyclicBarrier 或 Phaser 把两个执行者停在“已读旧值、尚未写入”,再同时释放,稳定复现 lost update。正确实现使用版本条件、数据库锁或原子 SQL,断言一个写成功、另一个得到冲突并进入明确重试。
并发集合或锁测试需要见证:最终计数、版本单调、临界区同时进入最大值、重复 token 和历史操作序列。只看线程没有抛异常不能证明线性化。重复数千次可以扩大搜索,但每次失败必须保存 seed、调度点和操作历史以便单次重放。
消息测试覆盖提交与确认的四个断点
生产端在业务提交前崩溃、提交后未发布、发布成功但未标记、重复发布;消费端在执行业务前崩溃、业务提交后未 ack、ack 后进程退出、死信重放。每个断点断言 Outbox/Inbox、业务版本、offset/ack 和最终收敛,不把“队列为空”当业务正确。
顺序测试按 partition/aggregate key 说明范围。跨 key 没有全局顺序时,测试应验证版本拒绝或合并,而不是偶然到达顺序。延迟和重试使用虚拟时钟或可控调度器,避免测试真的等待分钟。
清理和观测也是实验的一部分
每个测试使用独立 schema、数据库、Topic 前缀或唯一 runId;并行执行时不能 truncate 其他用例数据。失败保留容器日志、SQL、消息 Header、线程 dump 与见证历史,成功则按水位清理。清理失败要显式报错,不能让污染进入下一用例。
容量边界与压测不同:测试一千条消息或几十个线程用于触发批量、队列和锁分支,不据此宣称生产吞吐。性能结论交给受控基准和压测,正确性测试负责让小规模反例稳定出现。
数据库断言必须越过 ORM 缓存
写入后 flush 触发 SQL 和数据库约束,clear 清掉一级缓存,再用独立查询读取;否则 find 返回同一个内存实体,列映射错误也可能绿色。批量更新绕过持久化上下文时,测试要证明旧实体不会继续覆盖数据库。事务结束后验证 after-commit、副本读取或 CDC 的场景必须真正 commit。
唯一冲突、死锁、序列化失败和锁超时使用目标数据库错误码分类,断言应用转换为稳定领域错误或有限重试。不能依赖异常消息文本,也不能把所有 SQLException 当瞬时错误。重试测试保存同一 operationId 并验证业务只提交一次。
并发历史要能交给人审查
记录每个操作 invocation、response、读取版本、写入版本和线程,形成短历史。线性一致对象可尝试找到满足实时顺序的合法串行化;余额守恒则检查总和与每次转账原子性。见证越小越好,失败后通过 delta debugging 或固定调度缩成两三个操作,而不是只留下百万次压力日志。
虚拟线程降低线程创建成本但不消除共享状态竞态;并发测试不要因为使用大量线程就误称高并发验证。固定 barrier 控制关键交错,循环用于搜索其他交错,JCStress 等专用工具适合 JMM 级别测试,业务集成测试仍需数据库/消息权威状态。
消费测试要观察 ack 之外的副作用
消费者失败后 Broker 是否重投、重投是否换线程、Header attempt 是否增加、死信是否保留原 eventId 和异常分类都要断言。Inbox 唯一记录与业务写在同一事务,第二次投递命中已存结果;只 verify handler 被调用一次会误判至少一次投递系统。
多消费者并行时按 aggregate key 保序,构造两个 key 证明可并行、同 key 证明版本不回退。Rebalance 期间暂停与恢复也可能重复处理,测试使用可控 consumer lifecycle,而不是假定单进程顺序。
先写证明对象,再选择测试层
测试替身越多,速度越快但与真实运行的距离越大;真实依赖越多,协议证据越强但状态和资源更难隔离。组合策略应让同一高风险结论至少由两种不同失败模式的证据支持,例如单元验证版本条件,真实数据库验证条件更新确实只影响一行。
失败必须保存成可重放状态
失败包至少包含测试 id、代码提交、依赖版本、seed、时钟、Locale、执行顺序、资源标识、容器日志、线程信息和业务见证。结果未知与产品断言失败分开统计;基础设施掉线不能自动归为产品缺陷,产品超时也不能全部甩给 CI。
可维护测试也需要性能、数据和安全边界
套件容量模型包含用例数、上下文/容器启动、数据初始化、并发 worker、日志与制品体积。并行度超过数据库连接、CPU 或容器资源只会增加尾延迟和 flaky;先量测各阶段临界路径,再决定共享、分片或下沉。
测试数据使用合成或脱敏内容,秘密通过测试身份短期注入,不复制生产 token、数据库快照和个人字段。失败日志、HTTP journal、SQL 与快照同样需要脱敏和保留期。测试环境拥有创建容器、Schema 和 Topic 的权限,但不应因此获得生产网络或管理凭证。
演进遵循“读取端先兼容、写入端后切换、旧证据归零再删除”。框架升级先固定当前发现数、执行顺序、上下文 key 和报告格式,再迁移版本;不能把升级造成的少跑测试误当提速。
用两个 Java 17 模型固定测试见证
模型不依赖测试框架或外部服务,只把边界输入、状态转换和期望见证压成确定输出;真实项目再用 JUnit、Mockito、Spring、容器或 WireMock 驱动同一不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/testing/database-mq-concurrency-tests/LostUpdateWitnessDemo.java examples/backend-development/testing/database-mq-concurrency-tests/MessageDeliveryDemo.java
java -cp examples/backend-development/testing/database-mq-concurrency-tests LostUpdateWitnessDemo
java -cp examples/backend-development/testing/database-mq-concurrency-tests MessageDeliveryDemoinitial=10 writerA=11 writerB=11 finalWithoutVersion=11 expected=12 lostUpdate=true
eventId=evt-8 deliveryAttempts=2 inboxWrites=1 projectionVersion=8 duplicateAbsorbed=true实际测试若改变输出,必须解释是业务规格、测试层、Fixture、调度还是门禁发生变化;禁止用放宽断言或无限重试吸收差异。
从失败现场回推测试体系缺口
并发测试必须控制相对时序并以权威终态证明异常或正确性。围绕这条不变量记录测试 owner、独特证据、运行成本、失败率和最近一次真实发现。长期从不失败且与其他用例完全重复的测试需要合并;只在生产才失败的关键路径需要升级替身或增加真实边界。测试资产和生产代码一样接受删除、重构与版本治理。
团队门禁拒绝没有证据的绿色
代码评审要求说明为何选这一层、替身与真实系统差异、数据如何隔离、失败如何重放和哪个发布风险被覆盖。CI 报告展示未运行、跳过、隔离与重试,不允许只汇总“最终通过”。
测试资产的容量、升级与恢复
围绕“并发测试必须控制相对时序并以权威终态证明异常或正确性”建立资产台账:用例 owner、被保护规则、依赖资源、平均/p95 耗时、失败率、独特证据和最后发现缺陷。套件增长时按临界路径和资源饱和定位,而不是只看用例总数。共享容器降低启动成本但提高污染半径,并行 worker 提升吞吐但会争用数据库、CPU 和端口;每次提速都用隔离失败率与反馈时间共同验收。
最终验收不以一次全绿结束:在相同 seed、调度、版本和资源边界下重复关键反例,确认失败能稳定出现、修复能稳定消除,且无关重构不会破坏测试。
