做微信 API 对接的同学,十有八九会撞上签名算法。尤其是微信支付 API v2 的 HMAC-SHA256 签名,看起来就是一段网上到处能搜到的代码,但一上生产环境、一碰到多线程并发,就开始出幺蛾子:同一份参数,两个请求签名结果不一样;压测一跑 CPU 飙升;偶尔还会报Mac instance not initialized……这些坑,大多数都出在“线程安全”四个字上。这篇文章就把微信 API 调用签名算法(HMAC-SHA256)的线程安全实现讲透,包括根因、代码、实测和踩坑记录,适合正在对接支付接口的后端开发,以及维护老 SDK 的工程师。
我在真实项目里遇到过两次相关事故,一次是并发回调验签大面积失败,一次是新同事把网上“单例 Mac”的代码直接抄进生产,压测半小时后接口超时。所以这篇文章不会只贴一段能跑的代码,而是把为什么并发环境会出问题、怎么验证你的签名器到底安不安全,一次性讲清楚。
1. 为什么微信 API 签名会踩线程安全的坑
1.1 先看这些在并发下会真实发生的故障
我们在生产环境最容易见到的签名故障大概有这么几类:
- 相同入参,在不同线程里得到不同的签名结果,导致下游验签失败;
- 偶发异常,
Mac instance not initialized、IllegalStateException,重启后又自动恢复; - 并发量上去之后签名耗时明显上升,接口整体 TPS 被拖垮;
- 回调验签失败率升高,但同一个报文拿去在线工具验证又能通过,非常迷惑。
这些问题的共同点是“单线程跑没事,多线程一压就炸”。很多人觉得签名算法是纯函数,给同样的参数应该得到同样的结果。这个直觉没错,但它成立的隐含前提是:你用来计算签名的代码没有维护共享的可变状态。一旦代码里出现一个被所有线程共用的 HMAC 对象,这个前提就被打破了。
1.2 根因:共享的 HMAC 实例与可变内部状态
一段粗暴的签名代码通常会这样写:
public class Signer { private static Mac mac; static { try { mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec("your-api-key".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static String sign(String data) { byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return bytesToHex(raw); } }问题在于javax.crypto.Mac是一个有内部状态的引擎类。update()和doFinal()会持续写入内部缓冲区,虽然doFinal()结束后会自动 reset,但多线程同时调用同一个 Mac 对象时,线程 A 写入的数据可能被线程 B 的update()覆盖或混入,最终算出来的摘要根本不是当前入参的摘要。更诡异的是,如果底层 JCE Provider 内部对初始化状态有校验,还可能直接抛异常。
这相当于把一支笔同时交给两个人,让他们在同一张纸上各写各的句子,结果必然是两句话的笔画缠在一起,谁也看不清。所以“单例 Mac + 多线程调用”几乎必然出问题,早出晚出而已。
1.3 为什么单测正常、生产炸锅
很多人写单元测试时只有一个线程,断言固定输入输出,自然全绿。一旦进入 Tomcat、Netty 或者 Vert.x 这种天生多线程的环境,共享状态立刻暴露。签名器这类基础组件,测试一定要主动带上多线程用例。我在后面第 4 章会给一段可以直接放进 CI 的并发验证代码,建议直接抄走。
2. 微信 API 调用签名算法到底在签什么
2.1 待签名字符串的标准化拼接
微信支付 API v2 的 HMAC-SHA256 签名,第一步是对业务参数做标准化拼接。规则看起来简单,但很多人栽在细节上:
- 参数名按 ASCII 码从小到大排序,也就是字典序;
- 键值对格式为
key=value,用&连接; - 参数值为空,或者 key 为
sign本身时,不参与签名; - 拼接结束后,在末尾追加商户 API 密钥,格式是
key=商户密钥。
举个例子,假设参数是这样的:
appId=wx123456 nonceStr=abc123 package=prepay_id=wx10001 signType=HMAC-SHA256 timeStamp=1700000000排序并拼接后的待签名字符串就是:
appId=wx123456&nonceStr=abc123&package=prepay_id=wx10001&signType=HMAC-SHA256&timeStamp=1700000000&key=your_api_key注意package的值里可能本身包含=号,比如prepay_id=wx10001,这里不需要转义,也不需要做 URLEncoder。网上有人自作聪明把参数值做了一次表单编码,结果签名对不上,就是这个原因。
2.2 HMAC-SHA256 的运算本质
HMAC 全称是 Hash-based Message Authentication Code,即基于哈希的消息认证码。它的公式可以简化为:
HMAC = H( (K0 xor opad) || H( (K0 xor ipad) || message ) )其中K0是经过填充处理的密钥,opad和ipad是两个固定的填充字节块。可以看到整个计算过程包含两次内部 SHA-256 哈希,密钥会被加工成固定长度的块。这也解释了性能优化的方向:一旦密钥固定,两个内部填充块其实是固定的,后续开销主要来自消息本身的两次哈希运算。所以对签名器做性能优化,重点不是去优化哈希公式,而是减少重复初始化的成本、避免锁竞争。
2.3 编码、大小写与常见误区
微信支付 v2 的apiKey是 32 位字符串,JDK 内置的HmacSHA256算法直接把字符串的 UTF-8 字节作为密钥即可。HMAC-SHA256 输出 32 字节摘要,转十六进制后是 64 位字符串,并且微信要求使用大写。
这里有几个高频误区:
- 使用 Base64 编码摘要,而不是 Hex;
- 输出的十六进制没转大写,服务端验签时对不上;
- 用
String.format("%x", b)逐字节拼接,漏掉%02x前导零,导致长度不足 64 位。
建议把 Hex 转换抽成独立工具类,统一输出大写,方便全局复查。
2.4 和微信支付 v3 的 RSA-SHA256 区分开
这里有必要提一句:微信支付 API v3 的签名用的是商户私钥 +SHA256withRSA,回调验签用的是平台证书公钥,和 HMAC-SHA256 完全不同。本文讨论的 HMAC-SHA256 主要对应微信支付 API v2 的signType=HMAC-SHA256场景。如果你在对接 v3,别套用这里的代码,否则签名格式会完全对不上。
3. 线程安全实现方案对比与选型
3.1 方案A:每次调用创建新 Mac
最直观的方案,每次sign()都执行一次Mac.getInstance()+mac.init(),然后计算。它的优点是无共享状态、无锁、实现不容易出错;缺点是同步的算法实例获取和密钥初始化有一定开销。在高并发下,如果每秒签名上万次,这部分消耗会被放大。
我在本地 JDK 17 环境下用 8 线程并发调用 8000 次做过简单测试,每次 new Mac 的方案平均单次签名大约 0.1ms。这个数字不算高,但对支付网关这类对时延敏感的服务,能优化就优化。
3.2 方案B:ThreadLocal 缓存 Mac
javax.crypto.Mac虽然不能并发使用,但它的doFinal()执行结束后会自动重置内部状态,所以同一个线程可以反复使用同一个 Mac 实例。用 ThreadLocal 给每个线程一份独立实例,既不共享、也不用加锁,还省掉了每次getInstance()的开销。
同样条件下测试,ThreadLocal 方案平均单次签名约 0.04ms,大概只有方案A的三分之一。内存方面,每个线程多保留一个 Mac 对象,对现代服务端来说几乎可以忽略。
3.3 方案C:对象池,以及为什么不推荐
有人会想用对象池来管理 Mac,思路来自数据库连接池。但 Mac 对象本身很轻,创建成本远低于建立一次 TCP 连接。池化带来的借用和归还锁竞争,通常比创建成本还高,而且池里的对象如果被某条线程中途拿走去计算,另一条线程又拿到同一个对象,状态污染风险会重新出现。
除非你使用的是某些需要昂贵密钥协商的底层加密硬件,否则签名计算完全没必要池化。不要为了“看起来高级”而引入无谓的复杂度。
3.4 选型矩阵
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 低并发、可读性优先 | 每次 new | 简单、无共享状态、容易审计 |
| 中高并发、支付回调 | ThreadLocal 缓存 | 性能好、状态可控 |
| 超高并发、对 GC 极敏感 | ThreadLocal + 自定义弱引用管理 | 一般项目到不了这一步 |
4. 可落地的线程安全签名实现
4.1 工具基础:Hex 与字节处理
public final class HexUtil { private static final char[] HEX = "0123456789ABCDEF".toCharArray(); private HexUtil() {} public static String toHexUpper(byte[] bytes) { char[] chars = new char[bytes.length << 1]; for (int i = 0; i < bytes.length; i++) { int v = bytes[i] & 0xFF; chars[i << 1] = HEX[v >>> 4]; chars[(i << 1) | 1] = HEX[v & 0x0F]; } return new String(chars); } }这段代码很简单,但很多老项目里还在用String.format("%02x", b)逐字节拼接,量大的时候性能很差。手写查表法转换是最快的方式之一。
4.2 ThreadLocal 签名器实现
public final class HmacSha256Signer { private final ThreadLocal<Mac> macCache; public HmacSha256Signer(String apiKey) { this.macCache = ThreadLocal.withInitial(() -> createMac(apiKey)); } private static Mac createMac(String key) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return mac; } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException("HmacSHA256 init failed", e); } } public String sign(String data) { Mac mac = macCache.get(); byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return HexUtil.toHexUpper(raw); } }这里有个关键点需要强调:doFinal()调用完成后,Mac 内部会自动重置状态,所以同一个实例可以在同一个线程里被反复安全使用。不要画蛇添足地手动调用mac.reset()然后再doFinal(),那样反而会多一次状态写入。只要保证“一个线程独享一个实例”,并且在线程内串行调用,就没有问题。
关于 ThreadLocal 的内存泄漏担忧,在这个场景其实不需要过度紧张。签名器通常跟随业务对象存活,线程池里的线程也是长期存在的。如果实在不放心,可以在线程任务结束时不调用remove(),因为 ThreadLocal 的值会在线程池线程销毁时一并清理,而且一个 Mac 对象撑死几 KB,基本不会造成 OOM。
注意:
HmacSha256Signer持有的是不可变的apiKey,并且实例本身没有可变字段,所以多个线程共享同一个HmacSha256Signer实例是安全的。真正的状态隔离发生在 ThreadLocal 内部的 Mac 上。
4.3 上层签名服务与参数排序
把参数拼接与 HMAC 计算分开,职责更清晰:
public class WechatPayV2Signer { private final String apiKey; private final HmacSha256Signer signer; public WechatPayV2Signer(String apiKey) { this.apiKey = apiKey; this.signer = new HmacSha256Signer(apiKey); } public String sign(Map<String, String> params) { String source = buildSignSource(params); return signer.sign(source); } private String buildSignSource(Map<String, String> params) { Map<String, String> sorted = new TreeMap<>(params); StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : sorted.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); if (value != null && !value.isEmpty() && !"sign".equals(key)) { sb.append(key).append('=').append(value).append('&'); } } sb.append("key=").append(apiKey); return sb.toString(); } }这里用TreeMap做排序,可以保证多线程和多次调用下拼接顺序始终稳定。有些人喜欢把参数转成字符串数组再Arrays.sort,也可以,但要注意空串和 null 的过滤逻辑必须和微信官方文档一致。我曾经看到有人把空串过滤掉,却把 null 写进待签名字符串,结果签名怎么也对不上。
4.4 nonce 与 timestamp 的并发安全生成
timeStamp直接用System.currentTimeMillis() / 1000即可,不存在线程安全问题。nonceStr建议用SecureRandom生成:
public final class NonceGenerator { private static final SecureRandom RANDOM = new SecureRandom(); private NonceGenerator() {} public static String nextNonce() { byte[] bytes = new byte[16]; RANDOM.nextBytes(bytes); return HexUtil.toHexUpper(bytes); } }SecureRandom本身是线程安全的,直接做成静态字段没问题。不要用普通的Random,一方面它的随机性不足以应对安全场景,另一方面并发下种子更新可能产生重复序列,导致 nonce 冲突。nonce 长度微信没有强限制,一般建议 16 到 32 位字符串。
4.5 并发压测代码,证明它是真的线程安全
光说没用,给一段可以放进 CI 的并发验证逻辑:
public class SignerConcurrencyTest { private static final String KEY = "0123456789abcdef0123456789abcdef"; public void testConcurrentSigning() throws Exception { WechatPayV2Signer signer = new WechatPayV2Signer(KEY); Map<String, String> testParams = new LinkedHashMap<>(); testParams.put("appId", "wx000000000000"); testParams.put("timeStamp", "1700000000"); testParams.put("nonceStr", "abc123"); testParams.put("package", "prepay_id=wx10001"); String expected = signer.sign(testParams); int threads = 8; int roundsPerThread = 1000; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threads); AtomicInteger failCount = new AtomicInteger(); for (int i = 0; i < threads; i++) { pool.submit(() -> { ready.countDown(); try { start.await(); for (int r = 0; r < roundsPerThread; r++) { String actual = signer.sign(testParams); if (!expected.equals(actual)) { failCount.incrementAndGet(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }); } ready.await(); start.countDown(); done.await(); pool.shutdown(); if (failCount.get() != 0) { throw new AssertionError("并发签名结果不一致,失败次数=" + failCount.get()); } } }这段测试把单线程期望值先算好,再让 8 个线程各算 1000 次,只要有一次不一致就计数,最终断言必须为 0。跑在 CI 上,能有效防止以后有人把实现改回单例共享 Mac 而无人察觉。
5. 常见问题排查与经验速查
5.1 并发下签名结果不一致,先检查这三个点
第一,是不是共享了同一个 Mac 或 Cipher 实例?如果是,改成 ThreadLocal 或每次 new。
第二,是不是在多个线程间复用了同一个ByteBuffer、StringBuilder或ByteArrayOutputStream?这类可变容器对象同样会被线程间数据交叉污染。我在排查某次线上问题时,发现命中的就是这种情况:一个工具类把ByteArrayOutputStream做成了静态字段,不同线程写二进制数据时互相残留,最终输出的摘要自然不对。
第三,apiKey是不是在运行期被重新赋值了?如果某个线程读到了新 key,另一个线程读到旧 key,结果必然不一致。建议把apiKey定义成final,用构造器注入,不提供 setter,从根上杜绝中途更换。
5.2 同样代码,JDK 8 和 JDK 17 性能差异很大
HMAC 在 JDK 9 之后有底层哈希实现优化,ThreadLocal.withInitial的初始化成本在不同版本下也不一样。如果你对比测试发现“换了 JDK 快了或者慢了”,不要急着怀疑代码,先用 JMH 做一次基线测试。签名器是纯计算组件,没有 IO,非常适合用 JMH 压测。
一个最简的 JMH 基准方法大致长这样:
@Benchmark @Threads(8) public void testThreadLocalSigner() { signer.sign(source); }跑基准时要注意防止死代码消除,把sign()的返回值累加到一个 Blackhole 里。
5.3 非线程安全隐患清单
除了 Mac,签名链路里还容易出现这些坑:
SimpleDateFormat做成静态字段,多个线程格式化同一时间戳时抛异常;ByteArrayOutputStream被静态复用,导致输出残留;- 普通
StringBuilder被静态复用,轻则拼接错乱,重则抛同步异常; - 自定义缓存 Map 没有加锁,验签时同步统计 nonce 出现
ConcurrentModificationException。
这些坑的共性是“可变对象 + 多线程共享”,排查时按这个思路去找,效率会高很多。
5.4 排查决策表
| 症状 | 最可能原因 | 建议动作 |
|---|---|---|
| 相同参数签名结果不同 | 共享 Mac/Cipher | 改为 ThreadLocal 或每次 new |
| 偶发 IllegalStateException | Mac 被多线程交叉使用 | 加并发测试复现,替换实现 |
| 高并发时耗时增加 | 锁竞争或 getInstance 频繁 | 用 ThreadLocal 缓存 |
| 回调验签失败但工具类能通过 | 待签名串拼接不一致 | 打印 source 字符串逐字符比对 |
| 服务重启后验签失败 | 密钥编码或配置错误 | 核对 apiKey 长度与字符集 |
6. 其他语言的线程安全快速参考
6.1 Go:放心大胆直接用
Go 标准库的crypto/hmac中hmac.New每次返回全新实例,天然线程安全:
func HmacSha256Sign(data, apiKey string) string { mac := hmac.New(sha256.New, []byte(apiKey)) mac.Write([]byte(data)) return strings.ToUpper(hex.EncodeToString(mac.Sum(nil))) }唯一要注意的是不要把创建好的hmac.Hash对象传给其他 goroutine 共用。Go 的hash.Hash接口是可变的,多个 goroutine 同时往同一个对象里写数据,一样会计算出错误摘要。但每次调用hmac.New都是新对象,所以按上面的写法来就是绝对安全的。
6.2 Python:每次 new 就好
Python 的标准库hmac.new每次创建独立对象,线程安全:
import hashlib import hmac def sign(data: str, api_key: str) -> str: h = hmac.new(api_key.encode("utf-8"), data.encode("utf-8"), hashlib.sha256) return h.hexdigest().upper()这里有一个典型坑:hexdigest()返回的是小写十六进制字符串,而微信要求大写,所以必须调用.upper()。此外,不要把全局共享的hashlib.sha256对象传进hmac.new的第三个参数并在多线程里复用,hashlib的哈希对象同样不是线程安全的,每次调用重新创建即可。
6.3 Kotlin/Android 场景
Android 上如果只在主线程调用,不需要过度设计。但如果收发请求分散在协程的多个线程,同一个签名器对象被多个线程调用,依然建议用 ThreadLocal。另外,Android 的 JCE Provider 在不同系统版本上差异较大,显式指定"HmacSHA256"即可,不要依赖默认 Provider 的性能表现。
7. 多商户号与密钥轮换的设计建议
很多系统不是只服务一个商户号,而是聚合支付平台,需要管理多个商户密钥。这时候不要在全局静态变量里存单个签名器,更不要用高并发读的静态字段动态切换 key。我推荐的做法是用一个ConcurrentHashMap做商户维度缓存:
private static final ConcurrentHashMap<String, WechatPayV2Signer> SIGNER_CACHE = new ConcurrentHashMap<>(); public static WechatPayV2Signer getSigner(String merchantId, String apiKey) { return SIGNER_CACHE.computeIfAbsent(merchantId, id -> new WechatPayV2Signer(apiKey)); }computeIfAbsent在并发下只会初始化一次,指定的 key 对应的签名器实例一旦创建,内部状态就完全不可变了。如果遇到密钥轮换,需要生成一个新的apiKey并替换缓存中的实例,而不是去修改已有实例内部的值。这样既能保证线程安全,又能灵活支持多商户。
8. 我在实际项目里的选择与建议
最后分享一点个人经验。在我自己维护过的支付网关服务里,一开始用的是每次 new Mac,理由很简单:出问题概率最低,代码任何人一看就懂。后来回调量上来了,单机每秒峰值签名次数接近几万次,我才改成 ThreadLocal 方案,同时把签名器封装成不可变对象,构造完就不允许任何修改。那次改动上线后,签名平均耗时降了一半多,而且没有再出现因签名计算导致的线程级告警。
如果让我给一个排序的组合拳:先用最朴素的每次 new 实现,配合并发测试用例保证正确性;等压测明确显示热点后,再切到 ThreadLocal 缓存。不要一上来就上对象池,更不要觉得“网上抄个单例 Mac 挺省事”。签名算法正确性比性能重要,而线程安全是实现正确性的底线。希望这篇文章能帮你少踩几个坑。