文章

在SaaS多商户模式下,商户配置如何流畅切换

AI 摘要

项目设计之初是以单商户形式进行开发,后续因为需求的变更,无奈转为多商户模式,但在多租户(B2B2C)SaaS 系统里,数据隔离是最麻烦的,特别是商户频繁更换微信支付配置——更新 API V3 密钥、替换支付证书,甚至直接重组公司切换收款主体(MchID)。

这表面上看只是改改字段的 CRUD 操作。但在 TPS 上千的高并发系统里直接覆盖内存配置,必然出事。一旦更换流程不对,新订单大面积报错,老订单退款也会报错,返佣渠道等都会出现严重问题,对于财务来说流水账单更是无法对账。

配置混乱的根源

一个订单的支付动作在 200 毫秒内完成,但它的退款周期长达一年(只要未发货都可以退款)。如果采用传统的粗暴网关设计:后台管理员点击“更新”,系统直接覆盖内存中的旧商户配置实例。当用户对三个月前的一笔老订单发起退款时,系统拿着新商户证书去退旧商户的钱,是无法退款成功的。

核心矛盾在于生命周期严重错位:新单走新配置,老单退款回滚老配置。

引入快照与时间轴

要解决错位,必须切断底层的物理覆盖。支付配置一旦投入线上使用,即被视为不可变(Immutable)。

在数据库操作上,我们彻底干掉了关键配置表的 UPDATE 接口。每次修改,本质上是 INSERT 一条全新的记录,并通过状态位管控流量:

  • is_active = 1:接管所有新流量。
  • is_active = 0:归档,仅作为商户变更历史凭证。

在订单层面,用户下单付款的瞬间,将当时生效的 pay_merchant(商户号)强制冗余入库。这给不可控的旧订单退款,留下了溯源历史。

新老订单分流

微信 SDK Client 初始化因为要提取私钥(getBytes(StandardCharsets.UTF_8))和构建连接池,非常吃 CPU,所以实例肯定得常驻内存。但在分布式环境里,既要保证新单秒级响应,又要兼顾历史老单退款,怎么处理?

其实就是一个简单的按需隔离逻辑:新单走缓存池,老单用临时对象

// 全局单例的实例池:仅存放【活跃状态】的租户 WechatPayService
private static final Map<Long, WechatPayService> TENANT_PAY_SERVICE_POOL = new ConcurrentHashMap<>();
/**
 * 路由获取底层微信支付实例
 */
private WechatPayService getWeChatPayService(****Orders ****Orders, SystemEnums systemEnums) {

    // 1. 获取该租户当前最新(生效中)的配置
    WechatPayMerchantConfig activeConfig = wechatPayMerchantConfigService.selectWechatPayMerchantConfigByUserId(****Orders.getPayUserId());
    // 2. 路由核心:比对订单快照(历史MchID) vs 当前生效(最新MchID)
    if (!****Orders.getPayMerchant().equals(activeConfig.getWxMerchantId())) {

        // 发现 MchID 变更!触发历史溯源,捞出归档的老配置
        WechatPayMerchantConfig oldConfig = wechatPayMerchantConfigService.selectHistoryConfig(****Orders.getPayUserId(), ****Orders.getPayMerchant());
        WeChatProperties props = getWeChatProperties(oldConfig, systemEnums);

        // 【沙箱隔离】拉起一次性实例,仅作为局部变量,绝不放进 TENANT_PAY_SERVICE_POOL
        return WeChatUtil.****WeChatPayService(props);

    } else {

        // 正常情况:两个 MchID 一致。安全走主缓存池
        return TENANT_PAY_SERVICE_POOL.computeIfAbsent(****Orders.getPayUserId(), id -> {
            WeChatProperties props = getWeChatProperties(activeConfig, systemEnums);
            return WeChatUtil.****WeChatPayService(props);
        });
    }
}
  }
}
****RefundReq(order));
}

常规交易直接命中 TENANT_PAY_SERVICE_POOL。借助 ConcurrentHashMap.computeIfAbsent 的特性,在并发流量下同一商户也只会触发一次耗时的 Client 构造。后续流量直接从内存拿现成实例,性能拉满。

老单退款发现主体变更时,单独走 if 分支。系统临时构建出一个 WechatPayService 作为局部变量返回。在退款业务代码执行完后,这个临时对象脱离了引用链,就会被 JVM 的垃圾回收器(GC)静默清理掉。

用 Pub/Sub 冷启动

管理员在后台更新配置写库后,只有当前处理该请求的 Pod 知道状态变化。如果不加干预走惰性加载,第一个发起新单的用户会硬抗高达几百毫秒的冷启动延迟。

为了干掉延迟,我们引入了 Redis Pub/Sub 事件总线,处理逻辑非常直接:

@Component
@Slf4j
public class WechatPayConfigUpdateListener implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        Long payUserId = Long.valueOf(new String(message.getBody()));

        // 1. 将老实例无情剔除,切断引用等待 GC
        WechatPayService oldService = TENANT_PAY_SERVICE_POOL.remove(payUserId);

        // 2. 异步预热新配置
        CompletableFuture.runAsync(() -> {
            WechatPayMerchantConfig newConfig = wechatPayMerchantConfigService.selectWechatPayMerchantConfigByUserId(payUserId);
            WeChatProperties props = getWeChatProperties(newConfig, SystemEnums.ONLINE);

            // 重新压入主池
            TENANT_PAY_SERVICE_POOL.put(payUserId, WeChatUtil.cutsomWeChatPayService(props));
            log.info("用户[{}]微信支付更新成功", payUserId);
        });
    }
}

后台发生修改后向 Redis 广播特定消息。所有支付 Pod 监听到事件后,触发 TENANT_PAY_SERVICE_POOL.remove。被踢出的旧实例在处理完飞行中(in-flight)的请求后自然消亡。 同时,异步线程预加载新 Client 塞入池中。等到 C 端用户真正发起支付时,所有节点都已经加载好。就算有 K8s 动态扩容弹出的新 Pod 错过了广播,也有 computeIfAbsent 兜住。

总结

整个重构方案,其实根本没引入什么高大上的组件。说白了,无非就是靠数据库多存一份历史记录, 和Java 自带的垃圾回收来清理临时对象,再顺手用 Redis 发个广播把各个节点的状态对齐了一下