一. 現狀·問題
針對現如今高併發場景的業務系統,“併發問題” 終歸是必不可少的一類(佔比接近10%),每次出現問題和事故後,需要耗費大量人力成本排查分析並修復。那如果能在事前儘可能避免豈不是很香?
二. 分析原因
- 當前併發測試多數依賴測試人員進行腳本測試,同時還依賴了研發和產品識別出併發操作的場景用例。
- 對於併發測試,大概兩條路子:
- 所有修改同樣數據的命令式接口都測一遍?【耗費巨大測試成本】
- 保證黃金流程的接口,研發從頭扒代碼。【可能會遺漏,耗費一定研發成本】
🤔自我反思
- 作為研發,是不是在剛開發接口時候,識別到併發場景隨着單元測試階段同時進行併發測試,這樣的成本是最小的,收益是最高效的!
三. 採取措施
併發測試前置
採用CI持續集成機制,依靠行雲流水線,底層利用junit5單元測試框架併發parallel引擎,嵌入同步數據庫的自定義unit test腳本,將每個併發case維護成單元測試,數據自我閉環,可重複執行!
將核心的併發場景進行及時的運行驗證,最早洞察,最早驗證,最小成本,最大保障!
四. 實踐步驟
前提:配置junit-platform.properties
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=20
單接口併發-@RepeatedTest
- ManualCheckAppConcurrentTest 出庫複核併發測試「單接口併發」-> 手動複核 10個線程
👉 核心代碼塊
public class ManualCheckAppConcurrentTest extends ConcurrentTest {
@Resource
ManualCheckAppService manualCheckAppService;
//記錄執行成功的線程數
static int successThreadCount = 0;
///////////////////////////////////////////////////////////////////////////
// 單接口併發
///////////////////////////////////////////////////////////////////////////
@DisplayName("(單接口併發)併發測試【手動確認複核】")
@Description("(10個線程)場景:複核1件,一共5件,應該有5個線程成功,5個線程失敗:沒有查詢到容器明細記錄" +
"使用友好式分佈式鎖防止併發,併發後等待重試,保證順序執行無異常!")
@Execution(CONCURRENT)
@RepeatedTest(value = 10, name = "{displayName}:{totalRepetitions}-{currentRepetition}")
public void testConfirmChecked(TestInfo testInfo) {
manualCheckAppService.confirmChecked(mockConfirmCheckedDto());
successThreadCount++;
}
/**
* 斷言最終結果:數據無問題,線程執行無問題
*/
@AfterAll
public static void assertResult() {
//線程執行成功數期望:一共5件,每個線程複核1件,共有5個線程成功
Assertions.assertEquals(5, successThreadCount);
//數據成功期望:沒有待複核的容器明細了,因為都複核成功了,一共5件
ConfirmCheckedDto confirmCheckedDto = mockConfirmCheckedDto();
List<ContainerDetailPo> containerDetailPos = SpringUtil.getBean(ContainerDetailDao.class).selectUncheckDetailsBySoAndSku(
confirmCheckedDto.getTaskNo(), confirmCheckedDto.getShipmentOrderNo(), confirmCheckedDto.getSku(), confirmCheckedDto.getWarehouseNo());
Assertions.assertTrue(CollectionUtils.isEmpty(containerDetailPos));
}
@Test
@Sql({"/concurrent/manualCheck.sql"})
@Override
void prepareData()
多場景併發-@Execution(CONCURRENT)
- CheckAppConcurrentTest 出庫複核併發測試「多場景併發」-> 手動複核|自動複核
👉 核心代碼塊
public class CheckAppConcurrentTest extends ConcurrentTest {
@Resource
ManualCheckAppService manualCheckAppService;
@Resource
AutoCheckAppService autoCheckAppService;
///////////////////////////////////////////////////////////////////////////
// 多場景併發
///////////////////////////////////////////////////////////////////////////
@DisplayName("(多場景併發)併發測試【自動確認複核】")
@Description("與手動複核發生併發場景,期望可能存在業務異常(自定義鎖衝突發生的消息)")
@Execution(CONCURRENT)
@Test
public void testAutoCheckBySo() {
autoCheckAppService.autoCheckBySo(Lists.newArrayList("SO-6_6_601-1492066800186167296"), mockAutoCheckBySoDto());
}
@DisplayName("(多場景併發)併發測試【手動確認複核】")
@Description("與自動複核發生併發場景,期望可能存在業務異常(自定義鎖衝突發生的消息)")
@Execution(CONCURRENT)
@Test
public void testConfirmChecked() {
manualCheckAppService.confirmChecked(mockConfirmCheckedDto());
}
/**
* 斷言最終結果:數據無問題
*/
@AfterAll
public static void assertResult() {
//數據成功期望:沒有待複核的容器明細了,無論是手動複核還是自動複核,都會全部複核完
ConfirmCheckedDto confirmCheckedDto = mockConfirmCheckedDto();
List<ContainerDetailPo> containerDetailPos = SpringUtil.getBean(ContainerDetailDao.class).selectUncheckDetailsBySoAndSku(
confirmCheckedDto.getTaskNo(), confirmCheckedDto.getShipmentOrderNo(), confirmCheckedDto.getSku(), confirmCheckedDto.getWarehouseNo());
Assertions.assertTrue(CollectionUtils.isEmpty(containerDetailPos));
}
@Test
@Sql({"/concurrent/manualCheck.sql"})
@Override
void prepareData() {}
併發單測基類-@Transactional
ConcurrentTest 建議抽出併發測試基類(主要目的:準備數據、設置路由、數據清除、獨立執行)
@Tag("parallel")分組: 併發測試用例,有助於單獨執行套件!
👉 核心代碼塊
@SpringBootTest(classes = WebApplication.class)
@Tag("parallel")
public abstract class ConcurrentTest {
/**
* 併發測試場景的前提數據準備
* { @Sql 數據腳本配置 }
*/
@Transactional
@Order(0)
@Rollback(false)
abstract void prepareData();
/**
* 設置當前線程數據源
*/
@BeforeTransaction
public void setThreadDataSource() {
DataSourceContextHolder.clearDataSourceKey();
//多數據源,分庫分表
DataSourceContextHolder.setDataSource("ds0");
}
/**
* 清除數據
*/
@Rollback(false)
@AfterAll
public static void clearData(){
new DatabaseSyncTest().execute("wms_check","wms_check_test");
}
數據準備-@Sql
如何準備數據?
=\> 新建一個專門單元測試/併發測試的空數據庫
準備測試場景的前置數據SQL腳本
👉 源腳本
DELETE FROM ck_task;
INSERT INTO ck_task (id, task_no, sku_qty, total_qty, platform_no, status, warehouse_no, create_user,
update_user, create_time, update_time, ts, deleted, suggest_platform, uuid,
parent_task_no, pick_differ_allow, operation_type, picking_flag, task_type,
ext_info,
subtask_qty, tenant_code, current_stream_no, confluence, batch_no, requirements)
VALUES (1492071049884340224, 'T6X6X60122021100000329', 1.0000, 5.0000, '', 0, '6_6_601', 'xiaoyan', 'xiaoyan',
'2022-02-11 17:45:26', '2022-02-11 17:45:26', '2022-02-11 17:45:26', 0, '', 'zyr1228003', '', 0, 0, 0, 0, null,
null, 'TC30020150', 0, 1, 'cj006001', '{"allowBatchCheck": true}');
數據回滾-@ParameterizedTest
CI自動同步數據庫表結構: 測試環境數據庫->單測數據庫
利好:(研發無需被動維護schema,自動與真實數據庫結構同步)
只需要將下面單測copy到代碼中,將fromDb和toDb參數修改成自己數據庫即可!
👉 源代碼
@DisplayName("單元測試MYSQL-DB結構同步")
@SneakyThrows
@ParameterizedTest
@CsvSource("wms_check,wms_check_test")
public void execute(String fromDb, String toDb) {
ResultSet resultSet = null;
Class.forName("com.mysql.jdbc.Driver");
try (
Connection connection = DriverManager.getConnection("***","user", "***");
Statement statement = connection.createStatement()
) {
String initDb = "DROP DATABASE IF EXISTS " + toDb + ";CREATE DATABASE " + toDb + ";";
log.info(initDb);
statement.executeUpdate(initDb);
resultSet = statement.executeQuery("SHOW TABLES FROM " + fromDb + ";");
List<String> tableNames = Lists.newArrayList();
while (resultSet.next()) {
tableNames.add(resultSet.getString("Tables_in_" + fromDb));
}
for (String tableName : tableNames) {
String syncSql = "DROP TABLE IF EXISTS " + toDb + "." + tableName + ";" +
"CREATE TABLE " + toDb + "." + tableName + " LIKE " + fromDb + "." + tableName + ";";
log.info(syncSql);
statement.executeUpdate(syncSql);
}
} finally {
if(resultSet != null){
resultSet.close();
}
}
}
配置CI-@行雲流水線
建議在提測流水線增加,不要再日常dev流水線(集成測試相對耗時)
只執行併發單測用例-Dtest.mode 基於junit5 @Tag
https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering
mvn test -Dtest.mode=parallel
配置IDEA-本地測試
—— 只運行併發測試用例
執行結果
單接口併發單測
多場景併發單測
五. 效能提升
5.1需求交付效率提升
5.1.1降低測試周期階段時長
2022-02月實踐後
因為「併發測試」前置到「研發單元測試」環節,所以「測試階段」時長縮短 (2.5 天 -> 1 天)
2022-Q1
2022-Q2
2022-Q3
2022-Q4
「測試周期」階段停留時長和佔比,呈下降趨勢!
5.1.2縮短需求交付全週期
2022-02月實踐後
因為「測試周期」縮短,研發單元測試成本幾乎不變,所以「需求交付全週期」隨之縮短(55 天 -> 35 天)!
5.2人效提升
5.2.1提升驗證全面性
「case by case」 ,通過單元測試「斷言機制」,最細粒度全方位驗證!
在【開發階段】識別到接口存在併發問題,及時編寫單元測試進行驗證,針對分佈式鎖和樂觀鎖等常用防併發手段,對應不同的assert方式:
- 數據庫樂觀鎖:通過判斷最終數據保證執行無問題
- 分佈式友好鎖:不會報錯,會等待,最終所有請求處理成功
- 分佈式衝突鎖:直接報錯,斷言異常信息
- ......
5.2.2降低測試人力成本
減少花大量時間專項測試N個接口併發測試成本,「最早發現,最早處理,最小成本」!
根據下圖可見,從編碼階段、單元測試階段、接口測試階段、集成測試階段、預發佈階段等軟件生命週期中,越早發現問題,付出成本越小。
5.2.3提升需求吞吐量
2022-02月實踐後
因為減少人力成本,所以會直接提升需求的吞吐量(200個 -> 225個)!
5.3過程質量提升
5.3.1降低問題的發生概率
「併發測試前置」 到研發單元測試環節,可減少缺陷數,降低問題發生概率!
5.3.2減少線上問題數
👉 今年線上問題-併發問題 類別為 0
5.3.2減少Bug數
👉過程質量中併發問題趨勢逐步降低
作者:京東物流 周奕儒
來源:京東雲開發者社區 自猿其説Tech