MessagePack

Java 序列化、Jackson 数据绑定与 Redis 集成说明

Posted by Ekko on October 4, 2020

这篇笔记的目标是把 MessagePack 放到 Java 实际开发语境里重新梳理一遍:它是什么、为什么会比 JSON 更紧凑,以及在 JacksonObjectMapperRedisTemplate 里应该怎么使用。

重点放在可直接落地的序列化方案和常见误区上,尤其是依赖选择、ObjectMapper 的正确配置、数值类型的精度边界,以及 “看起来用了 MessagePack,实际上还是 JSON 序列化” 这种很容易混淆的场景。

参考资料:

官方资料:MessagePack Official SiteMessagePack Specificationmsgpack-java READMEmsgpack-jackson README

Spring 资料:Spring Data Redis ReferenceRedisSerializer Javadoc

[TOC]


1. MessagePack 是什么

MessagePack 的官方描述很直接:

It’s like JSON, but fast and small.

可以把它概括为一种面向跨语言交换的二进制序列化格式。它和 JSON 一样,都可以表达对象、数组、字符串、数字、布尔值和空值;不同点在于,JSON 是文本格式,而 MessagePack 是二进制格式。

这意味着它在很多场景下会更适合传输和存储:

  • 网络传输时,字节体积通常更小
  • 编码和解码过程通常更直接
  • 跨语言兼容性较好,适合服务间通信
  • 对缓存、消息队列、二进制协议封装更友好

需要注意的是,MessagePack 更小并不是因为它用了 Huffman 编码。更准确地说,它的紧凑性来自规范中定义的一组二进制格式族,例如:

  • 小整数可以直接编码进单字节
  • 短字符串只需要额外的长度前缀
  • 数组、Map、二进制数据都有针对长度区间设计的格式

所以它的优势是“按类型和长度选择更紧凑的二进制表示”,而不是通用压缩算法本身。

把它和 JSON 放到同一个表里看,会更容易把各自的特点区分开:

维度 MessagePack JSON
数据形态 二进制格式 文本格式
可读性 不能直接肉眼阅读 可以直接阅读
体积 通常更小 通常更大
传输与存储 更适合网络传输、缓存、消息载荷 更适合日志、配置、接口调试
调试方式 依赖反序列化工具或程序解析 直接看字符串即可
集成方式 往往通过编解码器或 ObjectMapper 接入 生态最通用,默认支持更多

2. Java 里怎么选依赖

在 Java 生态里,MessagePack 常见有两种使用方式:

  • 直接使用底层库,自己做 pack/unpack
  • 通过 Jackson 扩展,把 MessagePack 当成一种数据格式接入 ObjectMapper

这两种方式的选择可以直接按使用目标区分:

方式 典型 API 更适合的场景 主要代价
底层编解码 MessagePackerMessageUnpacker 协议实现、对字节布局有精细控制的场景 需要自己维护字段读写顺序和类型
Jackson 数据绑定 ObjectMapperMessagePackFactory Java 业务对象、DTO、缓存对象序列化 依赖 Jackson 的映射语义和模块配置

如果只是要接入 Jackson 的对象映射能力,通常直接引入 jackson-dataformat-msgpack 即可,它会依赖 msgpack-core

1
2
3
4
5
<dependency>
    <groupId>org.msgpack</groupId>
    <artifactId>jackson-dataformat-msgpack</artifactId>
    <version>${msgpack.version}</version>
</dependency>

如果项目里需要直接操作底层二进制编解码,也可以显式引入核心库:

1
2
3
4
5
<dependency>
    <groupId>org.msgpack</groupId>
    <artifactId>msgpack-core</artifactId>
    <version>${msgpack.version}</version>
</dependency>

这里有一个容易踩坑的点:很多旧文章里还会写 org.msgpack:msgpack。这个坐标对应的是更早期的旧实现,现代项目更常见的是 msgpack-corejackson-dataformat-msgpack 这一套。

另外,jackson-dataformat-msgpack 在默认的 POJO 序列化/反序列化语义上,与 msgpack-java v0.6 及更早版本并不兼容;如果项目里有非常老的历史数据,需要额外评估兼容策略。

3. 基本用法

3.1 POJO 的序列化与反序列化

最常见的写法,是把 MessagePackFactory 交给 ObjectMapper

1
2
3
4
5
6
7
ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());

ExamplePojo pojo = new ExamplePojo("komamitsu");
byte[] bytes = objectMapper.writeValueAsBytes(pojo);

ExamplePojo deserialized = objectMapper.readValue(bytes, ExamplePojo.class);
System.out.println(deserialized.getName()); // komamitsu

如果项目里已经深度依赖 Jackson,这种方式几乎没有额外学习成本,因为使用方式和 JSON 的 ObjectMapper 很接近,只是底层 Factory 换成了 MessagePack。

有些版本里也可以直接使用更简洁的写法:

1
ObjectMapper objectMapper = new MessagePackMapper();

如果对象里存在 BigIntegerBigDecimal,在支持该 API 的版本里,通常更建议显式使用:

1
2
ObjectMapper objectMapper = new MessagePackMapper()
        .handleBigIntegerAndBigDecimalAsString();

这样做的目的不是把 MessagePack 变成字符串格式,而是在库内部把高精度数值按字符串语义编码,避免因为底层数值类型限制导致精度丢失或序列化异常。

3.2 反序列化时忽略未知字段

在服务升级、字段新增、灰度发布这些场景里,反序列化时经常不希望因为多了一个字段就直接失败。这时可以显式关闭 FAIL_ON_UNKNOWN_PROPERTIES

1
2
ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory())
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

这项配置的含义是:当输入数据中出现目标类没有声明、也没有对应处理逻辑的字段时,反序列化不抛异常,而是忽略这些字段。

3.3 List 的序列化与反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());

List<Object> list = new ArrayList<>();
list.add("Foo");
list.add("Bar");
list.add(42);

byte[] bytes = objectMapper.writeValueAsBytes(list);

List<Object> deserialized = objectMapper.readValue(
        bytes,
        new TypeReference<List<Object>>() {}
);

System.out.println(deserialized); // [Foo, Bar, 42]

3.4 Map 的序列化与反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());

Map<String, Object> map = new HashMap<>();
map.put("name", "komamitsu");
map.put("age", 42);

byte[] bytes = objectMapper.writeValueAsBytes(map);

Map<String, Object> deserialized = objectMapper.readValue(
        bytes,
        new TypeReference<Map<String, Object>>() {}
);

System.out.println(deserialized); // {name=komamitsu, age=42}

3.5 两个常用读取入口

如果已经明确目标类型,通常使用:

1
objectMapper.readValue(bytes, ExamplePojo.class);

如果只是想先读取成树结构,再按需处理字段,可以使用:

1
JsonNode jsonNode = objectMapper.readTree(bytes);

前者适合“有稳定模型类”的场景,后者适合“先观察结构,再决定怎么解析”的场景。

3.6 高精度数值与时间类型的边界

MessagePack 本身对整数、浮点数支持很好,但在 Java 项目里还有两类字段需要额外留意:高精度数值和时间类型。

先看高精度数值。BigInteger 往往还能按整数类型处理,但 BigDecimal 会更敏感,因为 MessagePack 的浮点类型本质上对应 IEEE 754 的 float32 / float64。如果一个 BigDecimal 无法被无损表示成 double,就可能出现两类问题:

  • 精度被截断,序列化后数值发生变化
  • 编码阶段直接抛出异常

所以在金额、指标计算结果、科学计数等对精度敏感的场景里,最好不要把“可以序列化成功”直接等价成“可以无损保存”。如果项目使用的 msgpack-jackson 版本支持相关 API,优先考虑 handleBigIntegerAndBigDecimalAsString() 这一类显式配置。

另一类是 Java 8 时间类型,例如 LocalDateTimeInstantOffsetDateTime。这里容易产生一个误解:既然已经把 ObjectMapper 换成了 MessagePackFactory,是不是时间类型也会自动处理。实际上不会。MessagePack 只负责底层数据格式,Java 时间类型是否能正确映射,仍然取决于 Jackson 模块是否注册完整。例如:

1
2
3
ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

如果工程里还没有引入 jackson-datatype-jsr310,这里还需要补上对应依赖。

也就是说,MessagePack 解决的是“用什么格式写字节”,而不是“所有 Java 类型都会自动拥有合适的映射规则”。这类模块化配置和 JSON 场景本质上是同一套思路。

4. 封装一个可复用的工具类

如果项目里有多个模块都要处理 MessagePack,一个简单、无状态的工具类会比手写单例更清晰,也更不容易出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public final class MessagePackUtil {

    private static final ObjectMapper MAPPER = new ObjectMapper(new MessagePackFactory())
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    private MessagePackUtil() {
    }

    public static byte[] serialize(Object value) throws JsonProcessingException {
        if (value == null) {
            return null;
        }
        return MAPPER.writeValueAsBytes(value);
    }

    public static <T> T deserialize(byte[] bytes, Class<T> clazz) throws IOException {
        if (bytes == null) {
            return null;
        }
        return MAPPER.readValue(bytes, clazz);
    }

    public static <T> T deserialize(byte[] bytes, TypeReference<T> typeReference) throws IOException {
        if (bytes == null) {
            return null;
        }
        return MAPPER.readValue(bytes, typeReference);
    }

    public static JsonNode deserializeToTree(byte[] bytes) throws IOException {
        if (bytes == null) {
            return null;
        }
        return MAPPER.readTree(bytes);
    }
}

这一版和常见旧代码相比,主要做了几件事:

  • ObjectMapper 明确绑定到 MessagePackFactory
  • 去掉了不必要的手写单例逻辑
  • 泛型方法补上 TypeReference<T> 的类型参数
  • 工具类职责只保留“序列化 / 反序列化”,不混入额外状态

5. 放到 RedisTemplate 里时要注意什么

原理上,RedisTemplate 完全可以搭配 MessagePack 使用,因为 Redis 最终存的就是字节数组。

但这里最容易出现一个误区:只要用了 Jackson 的序列化器,并不等于已经用了 MessagePack。

例如下面这种写法:

1
2
Jackson2JsonRedisSerializer<Object> serializer =
        new Jackson2JsonRedisSerializer<>(Object.class);

它序列化出来的仍然是 JSON,而不是 MessagePack。也就是说,RedisTemplate 里如果挂的是 Jackson2JsonRedisSerializer,那本质上还是 JSON 方案。

从链路上看,Redis 场景里真正发生的是下面这件事:

graph LR
    A[业务对象] --> B[ObjectMapper]
    B --> C[MessagePack bytes]
    C --> D[RedisSerializer]
    D --> E[Redis]
    E --> D2[RedisSerializer]
    D2 --> C2[MessagePack bytes]
    C2 --> B2[ObjectMapper]
    B2 --> A2[业务对象]

也就是说,Redis 并不知道 value 里放的是 JSON 还是 MessagePack;真正决定编码格式的是挂在 RedisTemplate 上的序列化器,以及序列化器内部使用的 ObjectMapper

如果确实想让 Redis 的 value 使用 MessagePack,可以自定义一个 RedisSerializer,内部把 ObjectMapper 换成 MessagePack 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MessagePackRedisSerializer<T> implements RedisSerializer<T> {

    private final ObjectMapper objectMapper;
    private final JavaType javaType;

    public MessagePackRedisSerializer(ObjectMapper objectMapper, Class<T> type) {
        this.objectMapper = objectMapper;
        this.javaType = objectMapper.getTypeFactory().constructType(type);
    }

    @Override
    public byte[] serialize(T value) throws SerializationException {
        if (value == null) {
            return null;
        }
        try {
            return objectMapper.writeValueAsBytes(value);
        } catch (IOException e) {
            throw new SerializationException("MessagePack serialize error", e);
        }
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length == 0) {
            return null;
        }
        try {
            return objectMapper.readValue(bytes, javaType);
        } catch (IOException e) {
            throw new SerializationException("MessagePack deserialize error", e);
        }
    }
}

这种按目标类型构造 RedisSerializer<T> 的方案更适合 value 类型固定 的场景,例如 RedisTemplate<String, UserCacheDTO>。因为反序列化时目标类型是明确的,byte[] -> UserCacheDTO 这条链路不会有歧义。

如果缓存 value 是 Object,或者同一个模板里可能放很多不同 DTO,问题就会变成“读出来之后到底该还原成哪一种 Java 类型”。这时有几种常见选择:

  • 一个 RedisTemplate 对应一种稳定 value 类型,避免混用
  • key 前缀或业务分区严格隔离不同对象类型
  • 序列化时显式携带类型信息,再按约定做反序列化

这里要特别注意,类型信息不是 MessagePack 自动帮忙解决的。它只是把数据编码成二进制;至于反序列化时如何恢复成具体 Java 类,仍然需要应用层自己定义规则。

还有一个很实际的取舍是可观测性。用 JSON 时,redis-cli 或控制台工具里通常能直接看出对象大致内容;换成 MessagePack 之后,体积和效率可能更好,但排查问题时往往需要额外反序列化工具,或者在应用里补一个调试入口。

然后再把它接到 RedisTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Bean
public RedisTemplate<String, UserCacheDTO> redisTemplate(
        RedisConnectionFactory redisConnectionFactory) {

    ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory())
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    RedisSerializer<UserCacheDTO> valueSerializer =
            new MessagePackRedisSerializer<>(objectMapper, UserCacheDTO.class);

    RedisTemplate<String, UserCacheDTO> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(valueSerializer);
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashValueSerializer(valueSerializer);
    redisTemplate.afterPropertiesSet();
    return redisTemplate;
}

对应依赖一般至少需要:

1
2
3
4
5
6
7
8
9
10
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>org.msgpack</groupId>
    <artifactId>jackson-dataformat-msgpack</artifactId>
    <version>${msgpack.version}</version>
</dependency>

这里还有一个工程上的取舍:

  • 如果缓存对象类型固定,且比较关注体积和编解码效率,MessagePack 很合适
  • 如果缓存对象类型很多、还希望直接在 Redis CLI 里肉眼排查,JSON 往往更省心
  • 如果对象里包含高精度数值或复杂时间类型,需要额外检查 Jackson 模块和数值处理策略

所以在 Redis 场景里,MessagePack 不一定是默认答案,但它确实是一个很实用的二进制序列化选项。

6. 小结

把 MessagePack 放到 Java 项目里看,核心其实有四件事:

  • 先分清楚自己要的是底层二进制编解码,还是 Jackson 风格的数据绑定
  • 新项目优先使用 msgpack-corejackson-dataformat-msgpack
  • RedisTemplate 里真正想用 MessagePack,就要替换成基于 MessagePackFactory 的序列化器,而不是继续使用 JSON serializer
  • BigDecimal、时间类型、多态对象这些边界场景,仍然要单独设计映射和反序列化策略

如果只是把它理解成 “二进制版 JSON”,这个理解并不算错;但真正落地时,重点不在概念类比,而在对象映射链路、依赖选型和序列化器配置是否一致。