zhaojie.tech/me
项目实战复盘 2026-10-02T00:00:00.000Z

单号超长,收银全挂:一次由"预留号段"引发的线上事故

收银台扫码,主扫、被扫,全部失败。渠道只回一句:数据不符合约束要求。

那是 2026-09-12 的云上环境。更荒诞的是:测试环境全绿,单测全过,收银侧代码一行没改——改的是租户发号侧与单号编码器——挂的只有真实租户。

现象与定位

traceId 指到日志,错误链是三层:渠道拒绝 → 我方日志里一条 MySQL 报错先被吞在前面:

Data truncation: Data too long for column 'out_trade_no'

out_trade_no 是我们发给支付渠道的商户单号。微信侧硬限制 32 字符(支付宝是 64,但聚合通道必须取严者),库侧 ss_pos_channel_order.out_trade_no 也只给了 VARCHAR(32)。超长落库直接被 MySQL 拒掉,对外再收敛成一个通用的 9901006 错误码——真因被错误码翻译层藏住了,收银员看到的只有”不符合约束要求”。

超长的来源是一次看似无害的改动。租户 ID 分配改成了”预留段 + Redis INCR”:新租户号从 600_000_000_000 起递增,与存量老租户隔离(Redis 不可用直接抛异常回滚,不降级随机 id)。而旧的单号形制把十进制租户号整段拼进单号:

PC{租户号19位}D{yyyyMMdd 8}N{序列6} = 37 字符   ← 主扫单号
RF{租户号19位}D{yyMMdd 6}N{序列4} = 33 字符     ← 退款单号
PC{租户号19位}T{毫秒13}       = 35 字符        ← 兜底路径

三种形态全部越界。为什么测试环境没炸?旧单测用的是 8 位小租户号(14290031),拼出来 26/24/22 字符,恰好把越界掩盖得严严实实——小数字测不出长约束,这是本次事故最该记住的一课。

方案推演

第一反应是截断单号——立刻否掉,截断就是撞号,支付单号撞号是资损级问题。第二反应是换掉预留段、发短租户号——也否掉,600_000_000_000 起的预留段换来的是与存量数据的严格隔离,这个设计本身没有错。错的是单号格式把”租户身份”用十进制原样背在了身上。

那就压缩编码:租户段改 36 进制。Long.toString(id, 36) 是双射变换,不丢唯一性,而 19 位十进制的雪花量级 ID 编码后最长 13 字符。

实现

public final class ChannelOutNoCodec {
    /** 单号长度上限:微信硬限 32(支付宝 64,取严);与库侧 VARCHAR(32) 同值 */
    public static final int MAX_LEN = 32;

    /** 租户令牌:租户号 36 进制大写(双射),最长 13 字符 */
    public static String tenantToken(long tenantId) {
        return Long.toString(tenantId, 36).toUpperCase(Locale.ROOT);
    }

    /** 长度闸:超限即抛,禁静默截断 */
    public static String requireFits(String outNo) {
        if (outNo == null || outNo.length() > MAX_LEN) {
            throw new IllegalStateException("渠道商户单号超出 " + MAX_LEN + " 字符上限: " + outNo);
        }
        return outNo;
    }
}

新格式:PC{租户≤13}D{yyMMdd 6}N{序列6},最长 29 字符,留 3 字符余量;序列表不可用时的兜底路径 PC{≤13}T{base36(毫秒) ≤9} 只有 25 字符。生成出口统一过 requireFits——在生成侧先炸,日志直接给出单号与上限,而不是让 MySQL 的 truncation 报错再被翻译层糊掉。

兼容性也核过:单号对客户端与渠道都不透明(PC/App 只原样回传,全仓无任何 substring/startsWith 解析依赖);租户隔离由 tenant_id 列承担,不依赖单号里的租户段;老号(纯数字+固定字母)与新号(36 进制含字母)字符集天然不同,uk_out_trade_no 唯一键再兜一层,不会撞号。

踩坑

除了”小数字测不出长约束”,还有一点:错误码收敛层是双刃剑。它让客户端拿到统一语义,也让 Data truncation 这种”一眼定位”的报错变成”数据不符合约束要求”这种”一句废话”。修完后我把这类约束前置到了生成侧——外部硬限制必须变成代码里的常量和闸,超限就地抛异常,永远不让它走到数据库那一层才被发现。

一句原则

外部系统的硬约束(长度、字符集、格式)要前置进代码契约:变成常量、闸在出口、超限即抛——靠”碰巧没超”活着的集成,迟早死在一次不动代码的变更上。

← 全部文章 赵杰 · me.zhaojie.tech