在处理大量数据时,数据压缩是优化存储和传输效率的重要手段。在 Go 语言中,我们可以通过自定义 JSON 的 Marshal 方法,实现在数据入库前自动进行 gzip 压缩,从而减少存储空间占用并提高传输效率。

在某些业务场景下,我们可能会在数据库中保存一些 JSON 格式的元数据,例如使用 varchar(2048) 存储一些复杂的原始输入数据或存储一些商品快照信息等,这类数据通常都会随着业务的发展不断膨胀。最终很有可能会超过最初设计的字符上限,导致在创建或更新数据记录时报错。

Error 1406 (22001): Data too long for column 'xxx' at row 1

常规操作:简单粗暴扩大字段

通常,我们能想到的解决办法就是修改数据表的字段,增加字段大小,比如将字段大小从 varchar(2048) 改为 varchar(4096)。或者更激进的使用 TEXTBLOB 大字段。这种方式操作起来最简单,但是会显著降低数据库的查询效率。

本文介绍另外一种思路,可以在数据入库前对数据进行压缩。

另辟蹊径:入库前自动压缩

考虑到我们使用这类 JSON 数据的场景多为入库前执行 JSON 序列化,查询后使用 JSON 反序列转为 Go 中结构体变量进行使用。我们可以利用 Go 语言的 json 包提供了自定义 MarshalJSONUnmarshalJSON 方法的机制,在数据被序列化为 JSON 格式之前进行 gzip 压缩,在反序列化时进行解压缩。

具体实现步骤:

  1. 创建一个自定义类型,用于存储需要压缩的数据
  2. 为该类型实现 json.Marshaler 接口的 MarshalJSON 方法
  3. 在 MarshalJSON 方法中对数据进行 gzip 压缩
  4. 为该类型实现 json.Unmarshaler 接口的 UnmarshalJSON 方法
  5. 在 UnmarshalJSON 方法中对数据进行 gzip 解压缩

示例代码

下面是一个完整的示例代码,展示了如何实现这一功能:

package main

import (
	"bytes"
	"compress/gzip"
	"encoding/base64"
	"encoding/json"
	"io"
)

var (
	CompressionThreshold = 1800 // 阈值根据需要调整
)

// CompressedJSON 是一个泛型包装器,用于自动压缩和解压缩数据
type CompressedJSON[T any] struct {
	Data T
}

// MarshalJSON 实现了 json.Marshaler 接口
// 它会将 CompressedData.Data 序列化为 JSON,然后压缩,再进行 Base64 编码
func (cj *CompressedJSON[T]) MarshalJSON() ([]byte, error) {
	// 1. 将实际数据 Data 序列化为 JSON 字节
	rawData, err := json.Marshal(cj.Data)
	if err != nil {
		return nil, err
	}
	// 2. 判断是否需要压缩
	if len(rawData) <= CompressionThreshold {
		return rawData, nil
	}
	// 3. 压缩 JSON 字节
	var buf bytes.Buffer
	gzWriter := gzip.NewWriter(&buf)
	if _, err := gzWriter.Write(rawData); err != nil {
		gzWriter.Close() // 确保在出错时关闭 writer
		return nil, err
	}
	if err := gzWriter.Close(); err != nil {
		return nil, err
	}

	// 4. 对压缩后的数据进行 Base64 编码
	bys := buf.Bytes()
	encoded := base64.StdEncoding.EncodeToString(bys)
	// 5. 包装压缩后的数据
	compressPayload := struct {
		Compressed string `json:"_c"`
	}{
		Compressed: encoded,
	}
	return json.Marshal(compressPayload)
}

// UnmarshalJSON 实现了 json.Unmarshaler 接口
// 它会接收 Base64 编码的压缩数据,解码,解压,然后反序列化到 CompressedData.Data
func (cj *CompressedJSON[T]) UnmarshalJSON(b []byte) error {
	// 尝试反序列化到压缩的数据结构
	var compressedPayload struct {
		Compressed string `json:"_c"`
	}
	// 如果是压缩格式
	if err := json.Unmarshal(b, &compressedPayload); err == nil && compressedPayload.Compressed != "" {
		// Base64 解码
		decodedData, err := base64.StdEncoding.DecodeString(compressedPayload.Compressed)
		if err != nil {
			return err
		}

		// 解压缩数据
		gzReader, err := gzip.NewReader(bytes.NewReader(decodedData))
		if err != nil {
			return err
		}
		defer gzReader.Close()

		decompressedJSON, err := io.ReadAll(gzReader)
		if err != nil {
			return err
		}
		return json.Unmarshal(decompressedJSON, &cj.Data)
	}
	// 非压缩数据直接反序列化
	return json.Unmarshal(b, &cj.Data)
}

优势何在?

  1. 节省存储空间:JSON 数据(尤其是包含大量重复文本或结构时)通常有不错的压缩率。这意味着你可以用更小的数据库字段(或者在现有字段里存更多数据)来存储相同的信息。
  2. 减少传输压力:如果这些数据需要在网络间传输(例如,从数据库到应用服务器),压缩也能减少网络I/O。
  3. “无感”集成:对业务代码的侵入性小。你只需要定义好你的 CompressedData[T] 类型,并在需要的地方使用它即可。序列化和反序列化时,Go 的 encoding/json 会自动调用你的自定义方法。
  4. 可能避免或推迟数据库变更:在某些情况下,这可以让你在不立即修改数据库表结构(如将 varchar 升级到 TEXT)的情况下解决数据超长问题。

需要权衡的因素

当然,这种方法并非银弹,也有一些需要考虑的成本:

  1. CPU 开销:压缩和解压缩操作会消耗 CPU 资源。对于读写非常频繁且对延迟要求极高的场景,需要评估这个开销。通常 gzip 的速度是很快的,但还是需要测试。
  2. 压缩率的不确定性:压缩效果取决于数据的具体内容。对于已经高度压缩或随机性很强的数据,压缩效果可能不佳,甚至可能轻微增大体积(加上 Base64 编码的33%的体积膨胀)。但对于典型的 JSON 文本,效果通常不错。
  3. 数据库内可读性:数据在数据库中是以压缩和 Base64 编码的形式存储的,你不能直接在数据库管理工具里查看其原始内容,调试时可能需要先手动解压。
  4. 无法对压缩内容进行数据库级别的 JSON 查询:如果你的数据库支持对 JSON 字段内部进行查询(例如 PostgreSQL 的 ->>, ->> 操作符,MySQL 的 JSON 函数),那么压缩后的数据将无法使用这些功能。你必须先将数据完整取出并解压后才能在应用层进行查询。
  5. 错误处理:在 MarshalJSONUnmarshalJSON 方法中,需要妥善处理可能发生的错误(如压缩/解压失败、编码/解码失败)。

一些有用的知识

Q:gzip 压缩后为什么要 Base64 编码?

A:JSON(JavaScript Object Notation)是一种纯文本格式,只能存储字符串、数字、布尔值等文本类型数据,而二进制数据(如 Gzip 压缩后的字节流)无法直接存入 JSON 字段。

Q:有没有更简单的方案?

A:直接使用数据库的 JSON 字段或使用 mongoDB 。

总结

通过自定义 Go 的 json.Marshalerjson.Unmarshaler 接口,我们可以巧妙地实现 JSON 数据的自动压缩与解压缩,从而在一定程度上缓解数据库字段长度限制带来的问题。这种方法不仅能够节省存储空间,还能保持应用层代码的简洁性。

当你遇到 Error 1406: Data too long 并且不想轻易扩大数据库字段时,不妨试试这个“无感压缩”的技巧。它就像给你的数据穿上了一件“压缩衣”,入库时自动收紧,出库时自动还原,是不是很酷?

希望这个小技巧能对你有所帮助!你还有其他处理这类问题的妙招吗?欢迎在评论区留言分享!


扫码关注微信公众号