java之父叫我redis用protobuf序列化

5,564 阅读2分钟

一般情况下,我们会对 Redis 的数据进行 JSON 格式序列化,但是二般情况下,我们可以使用 Protobuf 格式来优化存储。

使用 Protobuf 格式有明显的优缺点:

优点:

  1. 存储和读取速度更快;
  2. 占用空间更小。

缺点:

  1. 内容不可读;(有的redis客户端可以转成可读内容)

  2. 代码结构更复杂。

下面演示如何在 Java 和 Go 中使用 Protobuf 进行 Redis 数据序列化和反序列化。

1. 创建一个简单的 Web 项目

首先创建一个简单的 Web 项目,并在 Java 目录同级下创建 proto 目录。

2. 定义一个复杂的 User 结构

为了展示 Protobuf 序列化的性能,我们创建一个 user.proto 文件来定义一个复杂的 User 结构。这个文件是通用语言的文件,稍后可以在 Go 中使用。

syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.haowen.protobuf.proto";
option java_outer_classname = "UserProto";

message User {
  int64 id = 1; // 用户ID
  string name = 2; // 用户名
  string email = 3; // 邮箱
  bool is_active = 4; // 用户是否激活
  float account_balance = 5; // 账户余额
  double rewards_points = 6; // 奖励积分
  bytes avatar = 7; // 头像(二进制)
  Address address = 8; // 地址(自定义类型)
  repeated PhoneNumber phone_numbers = 9; // 电话号码列表(用户可以有多个电话号码)
}

message Address {
  string street = 1; // 街道
  string city = 2; // 城市
  string state = 3; // 州/省
  string country = 4; // 国家
  string postal_code = 5; // 邮政编码
}

message PhoneNumber {
  string number = 1; // 电话号码
  Type phone_type = 2; // 电话类型

  enum Type {
    MOBILE = 0; // 移动电话
    HOME = 1; // 住宅电话
    WORK = 2; // 工作电话
  }
}
// protoc --java_out=../java user.proto

3. 使用官方的 protoc 工具将 user.proto 文件转换为 Java 文件

这里使用22.3版本进行演示,网址github.com/protocolbuf…,下完后是一个exe文件

在控制台进入 user.proto 文件所在目录,执行以下命令:

E:\rpc\protoc-22.3-win64\bin\protoc --java_out=../java user.proto

user.proto文件中,我们定义了三个message,分别为Address、PhoneNumber和User。针对这些message,系统生成了3个对应的Java类、3个构建器(Builder)类以及一个名为UserProto的辅助类。UserProto类提供了如User.parseFrom()等实用方法,用于处理二进制数据的解析。

4. 将 User 对象存储到 Redis

生成对应的 Java 文件后,我们可以将 User 对象存储到 Redis。首先创建一个 protoRedisTemplate,使用 String 序列化 key,使用 byte[] 序列化 value。

@Bean
public RedisTemplate<String, byte[]> protoRedisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, byte[]> redisTemplate = new RedisTemplate<>();
    StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
    RedisSerializer<byte[]> byteRedisSerializer = new RedisSerializer<>() {
        @Override
        public byte[] serialize(byte[] bytes) {
            return bytes;
        }

        @Override
        public byte[] deserialize(byte[] bytes) {
            return bytes;
        }
    };
    redisTemplate.setConnectionFactory(factory);
    redisTemplate.setKeySerializer(stringRedisSerializer);
    redisTemplate.setHashKeySerializer(stringRedisSerializer);
    redisTemplate.setValueSerializer(byteRedisSerializer);
    redisTemplate.setHashValueSerializer(byteRedisSerializer);
    redisTemplate.afterPropertiesSet();

    return redisTemplate;
}

然后编写一个用于保存 user 的方法,分别以 protobuf 格式和 json 格式存储到 redis,以便比较它们的区别。

@GetMapping("/saveUser")
public void saveUser() {
    // 创建User对象
    Address address = Address.newBuilder()
        .setStreet("天河路")
        .setCity("广州")
        .setState("广东省")
        .setCountry("中国")
        .setPostalCode("510000")
        .build();

    PhoneNumber phoneNumber1 = PhoneNumber.newBuilder()
        .setNumber("13912345678")
        .setPhoneType(PhoneNumber.Type.MOBILE)
        .build();

    PhoneNumber phoneNumber2 = PhoneNumber.newBuilder()
        .setNumber("020-12345678")
        .setPhoneType(PhoneNumber.Type.HOME)
        .build();

    User user = User.newBuilder()
        .setId(1L)
        .setName("张三")
        .setEmail("zhangsan@example.com")
        .setIsActive(true)
        .setAccountBalance(123.45f)
        .setRewardsPoints(678.90)
        .setAvatar(ByteString.copyFromUtf8("avatar-data"))
        .setAddress(address)
        .addAllPhoneNumbers(Arrays.asList(phoneNumber1, phoneNumber2))
        .build();

    // 以protobuf格式存储User对象到Redis
    protoRedisTemplate.opsForValue().set("user_proto:" + user.getId(), user.toByteArray());
    // 以json格式存储User对象到Redis
    redisTemplate.opsForValue().set("user_json:" + user.getId(), JSON.toJSONString(user));
}

可以明显看到 protobuf 在大小占用上的优势,并且redis客户端识别到了protobuf并且转成了可阅读的格式。

5. 从 Redis 中获取 User 对象

我们再定义个方法,把 user_proto 从 redis 取出来

@SneakyThrows
@GetMapping("/getUser/{id}")
public String getUser(@PathVariable Long id) {
    // 从Redis中获取User对象
    byte[] userBytes = protoRedisTemplate.opsForValue().get("user_proto:" + id);
    if (userBytes == null) {
        return null;
    }
    // 将字节数组解析为User对象
    User user = User.parseFrom(userBytes);
    return JSON.toJSONString(user);
}

反序列化也是没有问题的

5. 批量操作比较一下速度

下面定义一个一点都不严谨的方法来看看速度情况,一万个对象批量存

@GetMapping("compareSpeed")
public void compareSpeed() {
    // 创建User对象
    Address address = Address.newBuilder()
        .setStreet("天河路")
        .setCity("广州")
        .setState("广东省")
        .setCountry("中国")
        .setPostalCode("510000")
        .build();

    PhoneNumber phoneNumber1 = PhoneNumber.newBuilder()
        .setNumber("13912345678")
        .setPhoneType(PhoneNumber.Type.MOBILE)
        .build();

    PhoneNumber phoneNumber2 = PhoneNumber.newBuilder()
        .setNumber("020-12345678")
        .setPhoneType(PhoneNumber.Type.HOME)
        .build();

    User user = User.newBuilder()
        .setId(1L)
        .setName("张三")
        .setEmail("zhangsan@example.com")
        .setIsActive(true)
        .setAccountBalance(123.45f)
        .setRewardsPoints(678.90)
        .setAvatar(ByteString.copyFromUtf8("avatar-data"))
        .setAddress(address)
        .addAllPhoneNumbers(Arrays.asList(phoneNumber1, phoneNumber2))
        .build();

    // 初始化一下lettuce线程池
    byte[] bytes = {};
    protoRedisTemplate.opsForValue().set("a", bytes);
    redisTemplate.opsForValue().set("随便", "更随便");

    // 记录Proto时间
    byte[] userProto = user.toByteArray();
    long start = System.currentTimeMillis();
    Map<String, byte[]> protoMap = IntStream.range(0, 10_000) // 一万个对象
        .boxed()
        .collect(Collectors.toMap(i -> "user_proto:" + i, i -> userProto));
    protoRedisTemplate.opsForValue().multiSet(protoMap);
    System.out.println("Proto用时:" + (System.currentTimeMillis() - start));

    // 记录JSON时间
    String userJson = JSON.toJSONString(user);
    start = System.currentTimeMillis();
    Map<String, String> jsonMap = IntStream.range(0, 10_000) // 一万个对象
        .boxed()
        .collect(Collectors.toMap(i -> "user_json:" + i, i -> userJson));
    redisTemplate.opsForValue().multiSet(jsonMap);
    System.out.println("Json用时:" + (System.currentTimeMillis() - start));
}

go操作放在下一章