yeg
2026-09-15 4fff3f4dd9ea9546b8974c4b6970144f691d60fa
fix(merchant): DDL 等锁必须有上限,否则一次加列就能拖垮整个后台

2026-09-15 上午后台全站不可用(所有页面加载到超时)。根因是启动时补列的 ALTER 永久等锁:

conn 201 SHARED_READ (GRANTED) 空闲未提交事务,已持有 280 秒,trx_query = NULL
conn 202 EXCLUSIVE (PENDING) alter table merchant_onboarding_application
add column settlement_card_front_url ...
其余会话 SHARED_READ (PENDING) 全部排队

ALTER 拿不到排他元数据锁就无限期等待,而该表上所有后续查询也跟着排队,
最终借光 Hikari 连接池,健康检查都超时。kill 掉持锁事务后立即恢复,列也补上了。

改动:
- MerchantOnboardingSchemaInitializer 新增 addColumn():先 set session lock_wait_timeout = 5,
拿不到锁就放弃这一列并记日志,让应用照常启动(缺列只影响重试功能,不该赔上整个服务)。
用 SET SESSION 而不是 JDBC 参数,避免依赖连接串是否带 allowMultiQueries。
- 补 V143 迁移,把 settlement_card_front_url 纳入迁移链(开发库由上面的初始化器补)。

注意:迁移里**不能**写 dd column if not exists——那是 MariaDB 扩展,
MySQL 8.0.36 会报 ERROR 1064(本机实测确认),迁移只执行一次本也不需要。

回归:MerchantOnboardingRetryTests / MerchantOnboardingRetryReleaseTests /
MerchantSettlementCallbackIdempotencyTests 共 12 项通过(含 V143 在测试库上执行)。
1个文件已添加
1个文件已修改
50 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V143__merchant_onboarding_settlement_card_front_url.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -14,6 +14,8 @@
@Slf4j
@Component
public class MerchantOnboardingSchemaInitializer implements ApplicationRunner {
    /** DDL 等锁上限:拿不到就放弃这一列,绝不把整个服务拖住。 */
    private static final int DDL_LOCK_WAIT_TIMEOUT_SECONDS = 5;
    private final JdbcTemplate jdbc;
    public MerchantOnboardingSchemaInitializer(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@@ -44,6 +46,10 @@
            columns.put("subject_cert_begin_date", "char(8) default null");
            columns.put("subject_cert_end_date", "char(8) default null");
            columns.put("initial_bank_card_id", "bigint default null");
            // 结算凭证图(对公=开户许可证、对私/个体工商户=银行卡卡号面)。进件时要上报通道,
            // 而进件在通道侧配置失败后需要能**重新提交**;不存这一列就没法复用商户填过的图,
            // 只能让商户把整张表重填一遍——实际就是为了这个原因反复重填过。
            columns.put("settlement_card_front_url", "varchar(512) default null");
            columns.put("provider_huifu_id", "varchar(32) default null");
            columns.put("provider_apply_no", "varchar(32) default null");
            columns.put("provider_response_code", "varchar(32) default null");
@@ -65,7 +71,7 @@
            columns.put("capability_response_message", "varchar(512) default null");
            columns.put("capability_verified_at", "datetime default null");
            for (Map.Entry<String, String> column : columns.entrySet()) {
                if (!hasColumn("merchant_onboarding_application", column.getKey())) jdbc.execute("alter table merchant_onboarding_application add column " + column.getKey() + " " + column.getValue());
                if (!hasColumn("merchant_onboarding_application", column.getKey())) addColumn("merchant_onboarding_application", column.getKey(), column.getValue());
            }
            Map<String, String> bankCardColumns = new LinkedHashMap<>();
            bankCardColumns.put("provider_token_no", "varchar(32) default null");
@@ -76,13 +82,42 @@
            bankCardColumns.put("card_no_encrypted", "varchar(1024) default null");
            bankCardColumns.put("branch_code", "varchar(12) default null");
            for (Map.Entry<String, String> column : bankCardColumns.entrySet()) {
                if (!hasColumn("merchant_bank_card", column.getKey())) jdbc.execute("alter table merchant_bank_card add column " + column.getKey() + " " + column.getValue());
                if (!hasColumn("merchant_bank_card", column.getKey())) addColumn("merchant_bank_card", column.getKey(), column.getValue());
            }
        } catch (Exception exception) {
            log.warn("Merchant onboarding schema initialization failed", exception);
        }
    }
    /**
     * 加列,但**绝不无限期等锁**。
     *
     * <p>`ALTER TABLE` 需要排他元数据锁。只要表上还有一个没提交的事务(哪怕它已经读完、
     * {@code trx_query} 为空),DDL 就会一直 PENDING;而 MySQL 默认 `lock_wait_timeout` 是
     * 数万秒,于是 DDL 会一直挂着,**同时把该表上所有后续查询全部排队**,
     * 最终借光连接池、整个后台不可用。2026-09-15 就是这样瘫掉的:
     *
     * <pre>
     * conn 201  SHARED_READ(GRANTED)  空闲未提交事务,已持有 280 秒
     * conn 202  EXCLUSIVE(PENDING)    alter table ... add column settlement_card_front_url
     * 其余会话  SHARED_READ(PENDING)  全部排队
     * </pre>
     *
     * <p>所以这里显式把等待上限压到几秒:拿不到锁就放弃这一列,记一条日志,
     * 让应用照常启动(缺列只影响重试功能,不影响整个服务)。SQL 里用
     * {@code SET SESSION} 而非驱动参数,避免依赖 JDBC URL 是否带 allowMultiQueries。
     */
    private void addColumn(String tableName, String columnName, String definition) {
        try {
            jdbc.execute("set session lock_wait_timeout = " + DDL_LOCK_WAIT_TIMEOUT_SECONDS);
            jdbc.execute("alter table " + tableName + " add column " + columnName + " " + definition);
            log.info("Merchant onboarding schema: added {}.{}", tableName, columnName);
        } catch (Exception exception) {
            log.warn("Merchant onboarding schema: could not add {}.{} within {}s (table busy); it will be retried on the next restart. cause={}",
                    tableName, columnName, DDL_LOCK_WAIT_TIMEOUT_SECONDS, exception.getClass().getSimpleName());
        }
    }
    private boolean hasColumn(String tableName, String columnName) throws Exception {
        try (Connection connection = jdbc.getDataSource().getConnection(); ResultSet result = connection.getMetaData().getColumns(connection.getCatalog(), null, tableName, columnName)) {
            if (result.next()) return true;
talent-card-server/src/main/resources/db/migration/V143__merchant_onboarding_settlement_card_front_url.sql
New file
@@ -0,0 +1,11 @@
-- 进件申请增加「结算凭证图」列。
--
-- 进件时要向通道上报这张图(对公=开户许可证、对私/个体工商户=银行卡卡号面),
-- 而进件在通道侧配置失败后需要能**重新提交**。原来没有存这一列,
-- 重试时拿不到商户已经上传过的图,只能让商户把整张表重填一遍。
--
-- 注意:不要写 `add column if not exists`——那是 MariaDB 的扩展,
-- MySQL 8.0.36 会报 ERROR 1064(本机实测过)。迁移只执行一次,本来也不需要它。
-- 开发库 spring.flyway.enabled=false,该列由 MerchantOnboardingSchemaInitializer 补建。
alter table merchant_onboarding_application
    add column settlement_card_front_url varchar(512) default null;