做霸王餐CPS系统时,最让我头疼的其实不是优惠券核销、佣金计算这类业务规则,而是“如何把一条订单消息安全、完整、可追溯地送达对方系统”。这个过程中,自定义接口协议和Java序列化方案几乎决定了整个平台的对接质量和故障率。这篇博客就聊聊我在这个项目里对协议设计的完整思考,以及Java序列化的选型、安全防护和性能实战,适合正在做本地生活、CPS结算类系统的Java工程师参考。
霸王餐CPS本身是个不错的商业闭环:商家拿出免费或特价套餐,用户吃完后产出真实评价和社交传播,平台再按有效订单给渠道方结算佣金。模式不复杂,复杂的是多方系统的数据交换怎么约定、怎么保证不扯皮。你在开发中遇到的大部分接口报错、数据对不上、核销不成功问题,根子往往都在协议没设计好、序列化没选对。下面我按从业务到代码的顺序,把整套思路逐步摊开。
1. 为什么霸王餐CPS系统需要自定义接口协议
1.1 先捋清楚业务链路,再谈协议
霸王餐CPS系统里至少有四个角色:平台后端、商家端系统、用户端小程序、渠道方(团长、达人、推广群主)。一次完整的业务流是:渠道方推广出去 → 用户报名 → 商家审核 → 用户到店出示核销码 → 商家确认核销 → 平台给渠道结算佣金。
这条链路里有两个非常敏感的点。第一是核销,一个核销码用一次,不能多核销也不能被伪造;第二是返佣,渠道方贡献了订单,平台必须准确记录并结算,每一笔钱都能追溯到具体的渠道、具体的订单。
因为涉及钱和资源,外界一个普通的HTTP回调我都不能轻易信任。如果只是自己平台内部模块之间调用,用内部RPC就完了,但商家端可能是PHP、可能是Java、甚至可能只有一个别人写好的接口给我们调。各家系统水平参差不齐,所以平台必须拿出一套统一的、有签名的、可验证的自定义接口协议,让所有参与方按同一套规则说话。
1.2 现成协议为什么不能直接套用
有同学会问:市面上开放平台那套OAuth2、API网关方案不是很成熟吗?直接搬过来不就行了?
理论上可以,但实际跑一圈后你会发现很多问题。开放平台级的协议往往非常重,授权、刷新、多环境、复杂的签名规则,对一个核心开发只有两三个人的项目来说,前期接入成本高得吓人,商家技术负责人一看文档就跑路了。
而完全裸奔的方案,比如大家约定一个JSON地址、POST过去就完事,又扛不住安全和可追溯要求。用户手机号泄露、订单被伪造、核销码被批量刷,任何一件都是运营事故。
所以我的判断是:不走开放平台的全套重型方案,也不走裸JSON方案,而是设计一套够用、不过度设计、看一眼能懂的自定义接口协议。它要覆盖三点:
- 所有参与方使用相同的数据结构,字段语义明确;
- 所有请求都经过签名和防重放校验,防止伪造和重复提交;
- 协议本身带版本号,后续功能演进不破坏老的对接方。
这套协议的受众不是外部上万名开发者,而是几十个合作商家和一批渠道方。协议设计得清楚,大家接入快;设计得绕,自己后面维护也痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义接口协议的报文设计与签名机制
2.1 统一请求报文结构
我最终定稿的请求报文是一个JSON对象,所有参与方调平台接口时,都按这个结构来封装业务参数:
json复制{
"version": "1.0",
"appId": "mch_10001",
"timestamp": 1716000000,
"nonce": "8f2a9e01c3d44b5fa77c9e2e1a6b0d12",
"sign": "5b8f4c7e1a9d24e6f0ab3c9d8e7f6a5b4c3d2e1f0a1b2c3d4e5f60718293a4b5c",
"payload": {
"orderNo": "CPS20240518001",
"verifyCode": "6A9F2B",
"verifyTime": 1716000060
}
}
每个字段为什么存在,设计时都是有明确考虑的:
- version:协议版本号。将来字段结构变化时,老版本对接方可以继续用旧协议,新对接方用新协议,不用同一套结构打死所有人。
- appId:标识调用方身份,商家是mch_开头,渠道是promo_开头。服务端根据appId查到这个合作方的密钥。
- timestamp:请求发起时间,单位是秒。签名校验时会检查它和服务器当前时间是否在5分钟窗口内,超过就拒绝,这是防重放的第一道门槛。
- nonce:随机字符串,服务端在5分钟窗口内对nonce做去重,同一个nonce只能成功用一次。timestamp负责限制时间窗,nonce负责在窗口内防重复。
- sign:对整个签名串计算出来的签名值。
- payload:真正的业务参数。为什么业务参数要单独包一层?因为签名和业务字段解耦,后续payload内部怎么变化,签名结构和校验逻辑不用跟着频繁改。
有的团队会把业务参数直接平铺在报文外层,签名字段也混在一起,结果加一个字段就要重新验签,改起来非常痛苦。把协议元信息和业务数据分层,是这套设计的核心思路。
2.2 统一响应报文与错误码设计
请求有了规范,响应也得规范。我的响应报文结构如下:
json复制{
"code": 0,
"msg": "ok",
"traceId": "8f3b7c2e9a4d1e6f",
"data": {
"orderStatus": "VERIFIED",
"commission": "12.50"
}
}
吐槽一下,我看到过很多系统响应里只有code和msg,没有traceId。线上出问题时,商家拿着报错信息来找你,你根本不知道对应哪一条日志,查无可查。从第一期设计就加上traceId,后面联调会省掉大量时间。
错误码这块,我采用的是分段设计,不搞一个几千行的码表:
| 码段 | 含义 | 示例 |
|---|---|---|
| 0 | 成功 | 0: ok |
| 1xxx | 参数错误 | 1001: 缺少必填字段;1002: payload格式错误 |
| 2xxx | 业务状态错误 | 2001: 订单不存在;2002: 核销码已使用;2003: 订单已返佣 |
| 3xxx | 权限与签名错误 | 3001: appId不存在;3002: 签名校验失败;3003: 请求已过期 |
| 5xxx | 服务端异常 | 5000: 系统繁忙;5001: 数据访问异常 |
实际经验是:错误码一定不要定义太细,否则接入方要写大量错误码映射代码,很容易在网上抱怨你们平台难用;但也不能太粗,5000一把梭,排查问题全靠猜。按上面的分段控制在30个码以内是最舒服的。
2.3 签名算法选型,别一上来就上RSA
签名算法我选了HMAC-SHA256,而不是RSA。很多人一谈接口安全就要上RSA,我理解,但也要看场景。RSA是非对称签名,适合对完全不可信的第三方开放场景;而在霸王餐CPS这种联盟生态里,合作方是逐个签约的,平台给每个合作方发一个appSecret(对称密钥),双方各持一份,安全性足够。
对称签名的优点是计算快、实现简单,各个语言都有内建库,对方写代码对接时几乎没有门槛。
签名串的拼接规则如下:
code复制signStr = appId + "\n" + timestamp + "\n" + nonce + "\n" + Base64(payloadJson)
sign = Hex(HMAC_SHA256(signStr, appSecret))
这里有个特别容易踩的坑:为什么签名对象是Base64(payloadJson),而不是直接用原始payloadJson?因为JSON的字段顺序在不同语言里可能不一样。PHP端组装成的JSON可能是{"verifyTime":1716000060,"orderNo":"..."},Java端解析时重排成了{"orderNo":"...","verifyTime":1716000060},如果直接对原始JSON文本签名,两边签名必然对不上。
我在第一次联调时就踩过这个坑,对方后端把字段顺序调了个个儿,我这边验签死活过不去。后来统一改成对payload的Base64值签名,Base64的输入是标准化的UTF-8字节,这样就把字段顺序差异给隔离掉了。
服务端验签逻辑要做三步,缺一不可:
- 取出appId,查到合作方对应的appSecret;
- 用相同规则重算签名,用MessageDigest.isEqual做常量时间比较,避免时间侧信道攻击;
- 校验timestamp时间窗口和nonce是否已使用,再进入业务处理。
2.4 防重放与幂等,两个层次不能混淆
防重放是协议层的,幂等是业务层的。有同学以为加了timestamp+nonce就万事大吉,其实还差一层。
协议层防重放防的是“同一个请求原样再发一次”,我通过时间戳窗口+nonce的Redis去重解决:
java复制// 伪代码:redis 做 nonce 去重
boolean firstUse = redisTemplate.opsForValue()
.setIfAbsent("protocol:nonce:" + nonce, "1", Duration.ofMinutes(5));
if (!firstUse) {
throw new ProtocolException("重复请求");
}
但业务层还存在另一种情况:用户不小心连续点击两次核销按钮,生成了两个不同的请求,nonce不同,timestamp不同,但业务上处理的是同一个核销码。这时候协议层拦不住,必须在业务层做幂等。
我的做法是约定一个bizId放在payload里,比如核销场景bizId = 订单号 + "#" + 核销码。数据库里给bizId建唯一索引,第一次插入成功,第二次插入直接命中唯一键冲突,返回“已处理”。注意,这里返回给调用方的响应应该和第一次一致,不能报错,否则对方会认为核销失败不断重试,最终引发更多问题。
2.5 敏感字段加密怎么做
接口走HTTPS之后,是不是payload就不用加密了?大部分场景可以,但有两个例外:手机号和核销凭证。手机号属于用户隐私,核销凭证一旦被截获就能被冒用。
我的建议是不要在协议层整体加密,那样排障时抓包全看到的是一堆密文,没法快速定位问题。更实用的是对敏感字段单独加密:约定payload里的加密字段统一包一层结构,使用AES-GCM对称加密,密钥与appSecret分开管理。
json复制{
"encryptedFields": {
"encAlg": "AES-128-GCM",
"ciphertext": "Base64密文"
}
}
AES-GCM是认证加密模式,自带完整性校验,比AES-CBC更安全。这个方案在商家端接入时也就多几行代码,但显著降低了数据泄露风险。
3. Java序列化方案选型与实战
3.1 序列化在CPS系统里分布在哪些环节
聊完协议,再说序列化。这两个词经常一起出现,是因为协议报文本质上是对象转JSON的过程,而Java的序列化还牵扯更多场景。
在我的霸王餐CPS系统里,序列化至少出现在四个地方:
- HTTP接口层:业务对象转JSON响应,接收方JSON转DTO;
- Redis缓存:订单对象、核销码、渠道归因关系都要进缓存;
- MQ消息:订单状态变更后投递一条消息给渠道方,通知它佣金结算进展;
- 日志文件:打印请求响应快照方便排查。
不同场景对序列化要求不同,接口层要可读性好、跨语言方便;Redis缓存要体积小、读写快;MQ消息要稳定、兼容性好。所以选型不能一把梭。
3.2 主流序列化方案对比,别盲目追求高性能
我直接给一个对比表,是我在这个项目里实际评估过的方案:
| 方案 | 格式 | 跨语言 | 性能 | 安全性 | 适用场景 |
|---|---|---|---|---|---|
| Java原生序列化 | 二进制 | 差 | 差 | 差(有历史漏洞) | 尽量避免 |
| JSON(Jackson/Gson) | 文本 | 好 | 中 | 较好(需正确配置) | 接口、缓存、日志 |
| Protobuf | 二进制 | 好 | 高 | 好 | 高性能内部服务、海量数据 |
| Kryo | 二进制 | 中 | 高 | 一般 | 缓存、RPC内部传输 |
| Fastjson | 文本 | 好 | 高 | 历史上问题较多 | 不建议新项目使用 |
我最终在协议层和Redis层都选择了JSON序列化,配合Jackson作为核心工具。为什么不是Protobuf?因为霸王餐CPS系统的单笔报文只有几百字节到几KB,JSON转成二进制后的体积节省和性能提升在绝大多数业务接口上根本感知不到。而JSON的可读性带来的排障效率提升是实实在在的。Protobuf的IDL文件维护成本,对只有几个人的团队来说也是不小的负担。
但要注意,这不是说二进制序列化没用。如果后续要做渠道点击流数据上报,每天上百万PV的埋点数据走消息管道,那时候用Kryo或Protobuf压缩后再传输,收益就非常明显了。
3.3 Jackson的日常用法,以及Long精度丢失这个坑
Jackson是目前Java后端最主流的JSON库之一,但默认配置直接用在生产环境会踩不少坑。
第一个坑是处理不了LocalDateTime。实体里有LocalDateTime字段,反序列化直接报InvalidDefinitionException。这个问题的原因很简单:Jackson老版本默认不支持Java 8时间类型。解决办法是注册JavaTimeModule并关闭时间戳输出:
java复制ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
第二个坑是Long精度丢失。这是个隐蔽的大坑。CPS系统的订单号我用的雪花算法生成,是一个long数字,19位。JSON序列化后前端JS解析时,超过Number.MAX_SAFE_INTEGER(2^53 - 1)的整数精度会失真。本地生活平台的订单号一旦丢精度,后面所有基于订单号的查询都会失灵,还特别难排查,因为肉眼看数字差不多。
佣金金额我也用long类型按“分”存储(避免double精度问题),但它本身也有同样风险。解决办法是把Long序列化成字符串:
java复制SimpleModule longModule = new SimpleModule();
longModule.addSerializer(Long.class, ToStringSerializer.instance);
longModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(longModule);
这样前端拿到的就是字符串"1712386293849572352",不会丢精度。后端自己处理时再转换成Long。
第三个坑是反序列化未知字段。定义DTO时,老接口升级加了字段,新调用方传了多余的字段,默认Jackson会抛UnrecognizedPropertyException,导致整单失败。我建议全局配置关闭这个报错:
java复制objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
原则是“新增字段向后兼容”,老服务忽略未知字段,新服务多收字段没问题,否则接口升级一次,所有老对接方都要跟着改代码。
3.4 Redis序列化配置,别让默认JDK序列化坑一辈子
Spring Boot项目里,如果你直接往RedisTemplate里塞一个对象,不自定义序列化方案,默认用的是JdkSerializationRedisSerializer。往Redis里看一眼,key和value是一堆像\xAC\xED\x00\x05t\x00...这样的二进制乱码。
这是Java原生序列化的输出。它在业务系统里的问题是多方面的:体积通常是JSON的3到5倍,可读性几乎为零,通过redis-cli排查数据时完全没法看,而且Java原生序列化的跨版本兼容性和安全性都不行。我在项目里第一件事就是重定义RedisTemplate的序列化方案:
java复制@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// key 用 String 序列化
StringRedisSerializer keySerializer = new StringRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
// value 用 Jackson JSON 序列化
Jackson2JsonRedisSerializer<Object> valueSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
valueSerializer.setObjectMapper(buildObjectMapper());
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
这里要特别提醒:Jackson2JsonRedisSerializer反序列化时,如果泛型类型是Object.class,它并不知道目标对象具体是什么类型。这时候有两个选择:一是用RedisTemplate时显式传入目标类型,比如opsForValue().get("order:" + orderNo, Order.class);二是在ObjectMapper上激活DefaultTyping,序列化时把类名也写进JSON。
第二个方案看着省事,但会带来安全风险,下面安全部分会详细讲。我的建议是:缓存业务对象时,显式指定类型优先,宁可多写一行代码,也不要为图方便打开全局DefaultTyping。
3.5 时间字段在序列化里的统一约定
接口协议和Redis缓存里的时间字段,我建议统一用两种形式之一:标准ISO-8601字符串,或者long型的epochSecond。比如"verifyTime": 1716000060就表示秒级时间戳。
为什么不建议用LocalDateTime对象直接传?因为LocalDateTime不带时区,跨语言、跨时区解析时容易出偏差。商家在GMT+8核销,如果对方系统部署在UTC时区,字符串解析就可能差8小时。
在Java内部处理时用什么类型无所谓,但序列化到外部或放进消息队列时,统一转成long或ISO字符串,能避免无数凌晨被叫起来排查“时间为什么对不上”的问题。我在协议里甚至直接写死一条约定:所有时间字段,接口协议层用秒级时间戳,日志和追踪场沿用ISO-8601字符串。
4. 序列化安全:反序列化漏洞与系统加固实践
4.1 反序列化漏洞的原理,别当玄学
聊到序列化,绕不开安全问题。最近的网络热词里反序列化攻击、Fastjson漏洞出现频率很高,我在这也专门讲讲。
反序列化漏洞本质上是:反序列化过程会根据字节流或JSON中的类描述信息,去加载并且实例化一个类,同时调用对象里的某些回调方法。如果攻击者能控制“反序列化出什么类”,就可以构造一个恶意的对象图,在对象创建、属性赋值、readObject或getter/setter调用的过程中,触发危险操作。Java生态里那些名为CommonsCollections、CommonsBeanutils的gadget链,就是利用这个机制实现远程代码执行的。
Fastjson的漏洞就更直接了。为了支持多态,Fastjson允许在JSON里用@type字段指明具体类名,反序列化时直接根据这个类名加载类。如果服务端没有做类型校验,攻击者写一个恶意类名进去,服务端就可能在反序列化时执行攻击者构造的命令。
这个问题的核心不是“Fastjson不能用”,而是“反序列化时信任了外部传入的类名”。
4.2 霸王餐CPS系统里最容易出问题的序列化入口
盘点一下我们系统的所有序列化入口,可能很让大家意外:风险最高的是对外接口的payload解析,其次是Redis如果配置不当,再是MQ消息。
| 风险入口 | 风险等级 | 我的防护措施 |
|---|---|---|
| 外部接口接收payload | 高 | 只映射DTO,不接收@type/class字段;签名失败直接拒绝 |
| Redis反序列化 | 中高 | 不用JDK序列化;不全局开启DefaultTyping;用固定类反序列化 |
| MQ消息消费 | 中 | 消息体只放稳定字段;消费端不动态加载类 |
| 日志打印对象 | 中 | 对敏感字段脱敏;不打印整个User对象 |
对外接口这一块是最需要小心的。我处理的方式非常朴素:进入Controller的payload一律只映射成一个不带任何逻辑的DTO,DTO里只有String、Long、Integer、List这些基础类型,不包含任何自定义的反序列化钩子。业务对象不允许直接作为接口入参。
4.3 序列化安全加固的具体做法
首先,Jackson这边如果确实需要多态,要用白名单方式限定可反序列化的包名,不要放任任意类型:
java复制ObjectMapper objectMapper = JsonMapper.builder()
.polymorphicTypeValidator(
BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.cps.order.model.")
.allowIfSubType("com.cps.promo.model.")
.build()
)
.build();
注意:有些老文章会让你用activeteDefaultTyping(basePackage),这个其实仍然很危险,一旦白名单内的类存在可利用漏洞就会被绕过。我在项目里直接选择不在协议层启用任何形式的多态。
其次,Fastjson项目里的遗留代码要升级到最新安全版本,并且显式关闭AutoType:
java复制ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
不过我的最终建议是:新项目直接用Jackson或Gson替代Fastjson,旧项目逐步迁移,不要在一个新项目里还背着历史包袱。
第三,反序列化之前做报文大小校验。恶意或者异常的大报文会直接把JVM内存打满,触发OutOfMemoryError。我在网关层做了限制,超过1MB的请求体直接拒绝,不让它进入反序列化环节。
4.4 协议层的纵深防御措施
序列化安全只是协议安全的一部分,不能单点依赖。我在整个链路里还加了几层措施:
- 签名验证必须在业务反序列化之前执行。验签失败直接抛异常,不进入业务对象转换阶段,避免攻击者通过构造不同payload探测内部类信息。
- 网关层做IP白名单、限流。商家和渠道方的服务端IP在签约后登记,非白名单IP拒绝访问。限流主要防风暴式刷单。
- 日志脱敏。手机号打码处理,比如保留前3位和后4位;核销码在日志里只显示后2位。
- 定期轮换appSecret,尤其是渠道方人员流动之后。
这一套组合下来,比单纯依靠某一个安全组件要可靠得多。安全设计永远是基于“不信任一切外部输入”这个前提做的。
5. 实操复现:一个典型的核销订单同步接口
5.1 协议工具类代码落地
前面讲了一堆设计原则,这里把最终的代码骨架贴出来,是我在项目里实际使用的协议工具类,大家可以作为基础模板使用。完整代码很长,我保留了最核心的签名和验签部分:
java复制public class ProtocolUtils {
private static final Charset CHARSET = StandardCharsets.UTF_8;
private static final long EXPIRED_MILLIS = 5 * 60 * 1000L;
/**
* 生成签名。
* 签名串:appId + 换行 + timestamp + 换行 + nonce + 换行 + Base64(payloadJson)
*/
public static String buildSign(String appId, long timestamp, String nonce,
String payloadJson, String appSecret) {
try {
String payloadBase64 = Base64.getEncoder()
.encodeToString(payloadJson.getBytes(CHARSET));
String signStr = appId + "\n" + timestamp + "\n" + nonce + "\n" + payloadBase64;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(CHARSET), "HmacSHA256"));
byte[] raw = mac.doFinal(signStr.getBytes(CHARSET));
return HexFormat.of().formatHex(raw);
} catch (Exception e) {
throw new IllegalStateException("签名生成失败", e);
}
}
/**
* 验签 + 时间窗口校验。
* 注意:nonce 去重由调用方在 Redis 中处理,本方法只负责签名与时效验证。
*/
public static boolean verifySign(String appId, long timestamp, String nonce,
String payloadJson, String sign, String appSecret) {
if (appId == null || nonce == null || sign == null || payloadJson == null) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) * 1000L > EXPIRED_MILLIS) {
return false;
}
String expect = buildSign(appId, timestamp, nonce, payloadJson, appSecret);
return MessageDigest.isEqual(
expect.getBytes(CHARSET),
sign.getBytes(CHARSET));
}
}
这里提醒一点:上面示例里MessageDigest.isEqual是比较字节数组的常数时间方法,不要用String.equals去比较签名,equals在遇到不同内容时会提前退出,存在时间侧信道风险,虽然业务场景被利用的概率极低,但规范的写法能少留一个隐患。
5.2 核销接口的业务层处理流程
核销接口是我在协议和序列化设计上反复打磨最多的一个接口,整个处理链路可以拆成六步:
- 网关层拿到请求体,超过1MB直接拒绝;
- 反序列化成ProtocolRequest对象,取出version、appId、timestamp、nonce、sign、payload;
- 验签,查appSecret,调用ProtocolUtils.verifySign;失败抛3002;
- Redis对nonce做setIfAbsent去重,防止重放;
- 反序列化payload得到VerifyRequestDTO(这里用了Java的record,简洁且安全);
- 用Redis + Lua脚本原子校验核销码,再异步落库,同时发送一条MQ消息通知渠道方佣金结算进展。
第6步为什么不直接用数据库唯一索引?因为核销码的校验和更新需要原子操作,在高并发下直接更新数据库会放大锁竞争。我用Redis里的核销码状态字段加Lua脚本,校验、更新状态、设置过期一步完成。数据库落库是最终一致性兜底。
lua复制-- 核销码校验 Lua 脚本
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], 'USED', 'EX', ARGV[2])
return 1
else
return 0
end
这段脚本的意思是:核销码key的当前值是未使用状态,才把它置为已使用;否则返回0,拒绝核销。
5.3 联调踩坑实录:验签失败、InvocationTargetException和数据对不齐
把我在联调过程中遇到的典型问题整理成一个速查表,全是拿真金白银换来的经验:
| 问题现象 | 可能原因 | 排查方向和解法 |
|---|---|---|
| 验签一直失败 | 签名串拼接顺序不一致,或者拼了原始JSON而非Base64 | 先确认对手方是否按文档用Base64(payload)参与签名;再打印双方各字段比对 |
| 请求到了但业务参数为空 | 字段命名不一致,XML里定义的是order_no,Java DTO里是orderNo | 在协议里固定命名风格,统一用camelCase或snake_case,不要混用 |
| 反序列化报InvalidDefinitionException | LocalDateTime没注册JavaTimeModule | 检查ObjectMapper是否注册了JavaTimeModule,并把时间序列化为long或字符串 |
| 前端拿到的订单号最后两位变成00 | Long超过2^53,JS精度丢失 | Long序列化为字符串,前端无感知,后端按字符串接收再自行转Long |
| 核销码偶发重复核销 | 分布式环境下并发请求 | Redis + Lua原子脚本校验并更新状态,数据库加唯一索引兜底 |
| Redis里全是乱码 | 使用了JDK默认序列化 | 自定义RedisTemplate,key用String,value用Jackson2JsonRedisSerializer |
| 响应速度突然变慢 | 日志里打印了超大对象 | 日志脱敏、精简打印内容,不打印整个业务对象 |
5.4 一次典型的Jackson版本升级事故,说清楚为什么版本要统一
这里我想单独复盘一次线上事故,很多公司都踩过同类坑。某次为了修复漏洞,把Jackson升级到大版本,上线后核销接口大面积报错,日志里全是InvalidDefinitionException,说无法构造LocalDateTime实例。
排查后发现,项目里存在两个ObjectMapper实例:一个是我们全局配置的,注册了JavaTimeModule;另一个是某个老模块自己new的默认ObjectMapper,没有注册JavaTimeModule。升级后这个老模块被调用得更频繁,就炸了。
处理办法是:项目内只保留一个全局ObjectMapper的Spring Bean,所有模块统一注入使用,不允许各自new。同时代码审查时把这个规则写进团队规范。序列化相关的配置分散在多个地方,是大型Java项目最隐蔽的技术债之一。
另外还有一个LazyInitializationException的坑。实体类用了Hibernate懒加载,序列化时如果Session已经关闭,获取不到的字段会抛出LazyInitializationException。我的规范是:接口出参和入参一律使用DTO,不直接序列化Entity。这样既隔离了数据库字段变化对协议的影响,也规避了懒加载序列化异常。
5.5 序列化性能优化的三个简单手段
最后聊下性能。霸王餐CPS的并发量通常不会特别恐怖,但核销时段会有明显脉冲,一场活动下来可能集中产生大量请求。我做过的有效优化,总结下来就是三件事。
第一,序列化只保留必要字段。核销接口的DTO只设置三个字段:订单号、核销码、核销时间。不要图省事把整个订单实体丢出去序列化,字段越多,序列化时间越长,线上数据流转也越大。
第二,用异步日志。接口日志打印放在一个单独的线程池或者直接交给logback的AsyncAppender,不阻塞核心业务线程。这个优化对接口响应时间影响很直接,压测数据能差出20%以上。
第三,尽量避免大对象在业务线程内反复多次序列化。比如同一个订单对象既要写Redis又要发MQ,可以在业务逻辑里只序列化一次,得到JSON字符串后复用,而不是每次都调一次objectMapper.writeValueAsString。这里看似不起眼,订单量上来之后差距很明显。
写在最后
做霸王餐CPS这套系统,我最大的体会是:协议和序列化这两个词经常被当成底层工具一笔带过,但实际项目里80%的联调故障都出在这两层。协议字段没定义清楚,接多少个渠道就产生多少种兼容补丁;序列化方案拍脑袋选型,后面就是无穷无尽的安全补丁和性能优化。
我个人在实际项目里养成了一个习惯:协议文档必须先把字段语义、签名规则、错误码固定下来,再让前后端并行开发;序列化方案先把ObjectMapper的全局配置定死,再开始写业务代码。这个顺序一颠倒,后面全是补丁。最后再分享一个小技巧:每接入一个新渠道方,先把官方文档里“签名示例”跑通,再开始联调业务,排障成本能减少起码一半。
