序列化(Serialization)是把内存中的数据结构转换成可存储或可传输的字节流的过程,反序列化(Deserialization)则是逆过程。在 Lua 中,需要序列化的场景非常集中:游戏存档、配置表落盘、网络协议编解码、缓存写入以及跨语言进程间通信。不同场景对格式的要求差异很大——存档看重可读性与向后兼容,网络通信看重体积与速度,跨语言通信看重规范一致性。
Lua 本身没有内置的序列化库,这既是负担也是自由:你可以用几行代码手写一个可读的序列化器,也可以引入 cjson、lua-MessagePack 这类 C 扩展获得接近原生的性能。本文先讲清楚各格式的取舍,再落到具体代码与选型建议。文中涉及的库安装方式见 https://plumephp.com/lua-modules-and-packages/。
格式选型:先问三个问题
在动手之前,先明确三个维度,选型几乎就确定了:
| 维度 | 倾向文本格式 | 倾向二进制格式 |
|---|---|---|
| 是否需要人眼可读 | 配置、调试、存档 | 网络包、大数组 |
| 体积与带宽敏感度 | 低 | 高 |
| 是否需要跨语言 | 弱(同语言自洽) | 强(多端约定) |
常见的候选格式对比:
| 格式 | 类型 | 相对体积 | 编码速度 | 跨语言 |
|---|---|---|---|---|
| Lua 表字面量 | 文本 | 大 | 快 | 差(仅 Lua) |
| JSON | 文本 | 中 | 中 | 极好 |
| MessagePack | 二进制 | 小 | 快 | 好 |
| CBOR | 二进制 | 小 | 快 | 好 |
| Protocol Buffers | 二进制 | 最小 | 最快 | 好(需 schema) |
选择的关键不是"哪个最好",而是"哪个约束最少"。配置表用 JSON 便于人工编辑;高频网络包用 MessagePack 省带宽;多语言强类型接口用 Protobuf。还需要考虑团队现状:如果既有服务全是 JSON,为了省 30% 体积切到 MessagePack 未必划算,因为解析侧的改造成本可能更高。
Lua 原生序列化:string.format 与 load
最小依赖的方案是手写。思路是用 string.format 把基本类型转成 Lua 字面量,递归处理表:
local function serialize(value, seen)
local t = type(value)
if t == "number" or t == "boolean" then
return tostring(value)
elseif t == "string" then
return string.format("%q", value) -- %q 转义引号与换行
elseif t == "table" then
seen = seen or {}
if seen[value] then error("循环引用") end
seen[value] = true
local parts = {}
-- 数组部分
for i = 1, #value do
parts[#parts + 1] = serialize(value[i], seen)
end
-- 哈希部分
for k, v in pairs(value) do
if type(k) ~= "number" or k > #value or k < 1 then
parts[#parts + 1] = string.format("[%s]=%s",
serialize(k, seen), serialize(v, seen))
end
end
seen[value] = nil
return "{" .. table.concat(parts, ",") .. "}"
else
error("不支持的类型: " .. t)
end
end
print(serialize({1, 2, name = "lua", ok = true}))
-- {1,2,["name"]="lua",["ok"]=true}
反序列化直接用 load 执行这段字面量:
local function deserialize(text)
local chunk = assert(load("return " .. text, "serialize", "t"))
return chunk()
end
local data = deserialize('{1,2,["name"]="lua"}')
print(data.name) -- lua
string.format("%q") 会正确处理转义,但对 math.huge、nan 这类特殊数值无能为力,需要在序列化前显式处理,否则会写出 1e9999 或 -nan 这类无法回读的字面量。更严重的是 load 的安全问题——它会把任意字符串当代码执行,绝不能用于反序列化不可信输入。生产环境请改用 load(..., "t") 仅允许文本模式,或干脆用纯数据格式。
手写方案的另一个常见缺陷是不记录键的顺序,pairs 的遍历顺序不稳定,导致同一份数据两次序列化结果不同,无法做校验和或 diff。需要稳定输出时,要先收集所有键并 table.sort。
JSON:cjson 与 dkjson 的取舍
JSON 是 Lua 生态最通用的文本格式。两个主流实现差异明显:
| 实现 | 语言 | 速度 | 特性 |
|---|---|---|---|
lua-cjson | C | 极快 | 默认不区分空数组与空对象 |
dkjson | 纯 Lua | 慢 | 可配置、无编译依赖 |
rapidjson 绑定 | C++ | 极快 | 支持流式与 SAX |
lua-cjson 的典型用法:
local cjson = require "cjson"
-- 编码
local ok = cjson.encode({name = "lua", version = 5.4})
-- {"name":"lua","version":5.4}
-- 解码
local obj = cjson.decode('{"ok":true,"list":[1,2,3]}')
print(obj.list[2]) -- 2
cjson 有几个必须知道的坑:
- 空表歧义:
cjson.encode({})输出{}(对象),但cjson.encode(cjson.empty_array)才输出[]。需要显式控制时用cjson.empty_array_mt。 - 稀疏数组:含空洞的数组会被编码成对象,破坏结构。
- 大整数精度:JSON 数字统一按 double 处理,超过 2^53 的整数会丢精度。
null映射:JSON 的null默认解码为cjson.null(一个 lightuserdata),而非nil,判空时要用v == cjson.null。
若对纯 Lua 有强依赖(如嵌入式环境无法编译 C 扩展),则用 dkjson:
local json = require "dkjson"
local text = json.encode({a = 1}, {indent = true}) -- 带缩进,便于人读
local obj, pos, err = json.decode(text)
if err then print("解析失败: " .. err) end
dkjson 支持 indent 美化输出,适合生成配置文件;代价是编码速度通常是 cjson 的十几分之一。性能敏感路径应选 cjson。
MessagePack 与二进制格式
当数据体积成为瓶颈时,二进制格式的优势就体现出来。MessagePack 把整数、字符串、数组、映射编码成带类型前缀的紧凑字节,同样的数据通常只有 JSON 的 40%~70%。
local mp = require "MessagePack"
-- 编码
local packed = mp.pack({id = 1001, tags = {"lua", "fast"}})
print(#packed) -- 例如 24 字节
-- 解码
local obj = mp.unpack(packed)
print(obj.tags[1]) -- lua
MessagePack 的整数编码按数值范围自适应:小整数只占 1 字节,这让 ID、枚举、坐标这类数据非常省。但它同样不保留类型信息——Lua 的整数与浮点都编码为数字,解码端按规范还原。
对于需要 schema 约束的场景,Protocol Buffers 更合适。Lua 侧常用 lua-protobuf(纯 Lua 解析 .proto):
local pb = require "pb"
pb.loadfile("player.proto")
local encoded = pb.encode("Player", {id = 1, name = "neo"})
local decoded = pb.decode("Player", encoded)
二进制格式的通用代价是不可读:调试时必须借助十六进制工具或专门的反序列化脚本。因此常见做法是"开发用 JSON、上线切二进制",用同一份数据模型驱动两套编解码。Protobuf 还带来 schema 演进的能力:字段号一旦分配就不应复用,删除字段要标记 reserved,这样新旧版本可以共存。
CBOR 与自描述二进制
CBOR(Concise Binary Object Representation)常被称作"二进制版 JSON",它保留了 JSON 的自描述特性(不需要 schema 就能解码),同时体积更小。相比 MessagePack,CBOR 对整数、浮点、时间戳、标签类型的定义更严格,适合需要长期存储、跨系统交换的场景。
local cbor = require "cbor"
local bytes = cbor.encode({ts = os.time(), temp = 23.5, ok = true})
local obj = cbor.decode(bytes)
三者的取舍可以这样记:JSON 图可读,MessagePack 图快,CBOR 图规范。如果数据要落盘存档十年,CBOR 的自描述与标准标签更稳妥;如果只是进程间传一包数据,MessagePack 的成熟度与生态更广。
游戏存档中的序列化实践
游戏存档是 Lua 序列化最典型的场景,需求往往是"可读、可迁移、可容错"。这类项目通常不直接用 JSON,而是自定义一层格式。存档要解决的三个问题:
- 版本迁移:老存档要能在新版本加载。做法是在存档头写入
version字段,加载时按版本号逐级升级。 - 完整性校验:防止玩家手改。用 CRC32 或哈希校验关键字段。
- 可调试:出问题时能打印出可读内容。
local Save = {}
function Save.dump(state)
local payload = serialize(state) -- 上文的手写序列化器
local checksum = crc32(payload) -- 校验和
return string.format("-- v%d\n%s\n-- crc:%08x",
state.version or 1, payload, checksum)
end
function Save.load(text)
local version = tonumber(text:match("v(%d+)"))
local body = text:match("\n(.-)\n%-%- crc")
local data = deserialize(body)
if version < CURRENT_VERSION then
data = migrate(data, version) -- 逐级升级
end
return data
end
这套思路与通用游戏存档系统的设计一致:把"数据内容"与"元信息(版本、校验)“分层,元信息永远用固定格式,数据内容才允许演进。若存档量大,还可对 payload 做压缩(如 zlib)再落盘,读档时解压。
流式与增量解析
当数据体积很大(如几十 MB 的地图数据或日志归档),一次性 decode 会把整棵树读进内存,峰值占用可能翻倍。此时需要流式(streaming)或增量(incremental)解析。
MessagePack 与 JSON 都有支持分块读取的解析器,思路是"喂一段字节、吐一个对象”:
-- 伪代码:增量喂数据,解析器在完整对象就绪时回调
local parser = json.new_parser()
parser.on_object = function(obj)
process(obj) -- 处理完立即释放,不累积
end
for chunk in read_chunks(file) do
parser:feed(chunk) -- 每次只解析出能解析的部分
end
parser:finish()
流式解析的三个好处:内存峰值与单条记录大小成正比而非文件总大小;可以边下载边处理;遇到损坏数据能定位到具体偏移。代价是解析器实现更复杂,且不能随机访问——你只能顺序消费。
对于 Lua 表字面量存档,则没有现成的流式解析器,只能整体 load。这也是大存档更推荐二进制格式的原因之一。
二进制布局与字节序
手写二进制格式时,字节序(endianness)是绕不开的坑。Lua 5.3+ 提供 string.pack / string.unpack 直接处理字节序,不必依赖 FFI:
-- "<" 小端,"i4" 32位整数,"d" double,"z" 以 \0 结尾的字符串
local bytes = string.pack("<i4dz", 1001, 23.5, "lua")
local id, temp, name = string.unpack("<i4dz", bytes)
print(id, temp, name) -- 1001 23.5 lua
格式串的常用标记:
| 标记 | 含义 | 字节数 |
|---|---|---|
i4 | 有符号 32 位整数 | 4 |
I8 | 无符号 64 位整数 | 8 |
d | double | 8 |
z | 零结尾字符串 | 变长 |
s4 | 长度前缀字符串 | 4 + n |
跨端通信时必须两端约定一致:小端(<)在 x86/ARM 上更自然,大端(>)是网络字节序。约定写进协议文档,比在代码里隐式依赖平台更可靠。
反序列化的安全边界
反序列化是攻击面。核心原则只有一条:把数据当数据,不要当代码执行。
- 避免
load/loadstring执行输入。若必须(如 Lua 表字面量存档),则用load(text, name, "t", env)并传入一个白名单环境,让脚本无法访问os、io等危险库。 - JSON 解析器要限制嵌套深度,防止恶意深层结构导致栈溢出。
- 限制输入大小,避免一次
decode吃掉几百 MB 内存。
-- 用受限环境加载不可信的表字面量
local safe_env = {} -- 空环境,无任何库
local chunk = load("return " .. text, "data", "t", safe_env)
local ok, data = pcall(chunk)
if not ok then
error("非法数据: " .. tostring(data))
end
这套沙箱思路与 Lua 与 C 交互时的边界控制一脉相承,涉及原生扩展调用时更要注意内存安全,可参考 https://plumephp.com/lua-c-integration-guide/。
性能与选型建议
把上面的讨论收敛成几条可执行的建议:
- 配置与调试:JSON(
dkjson带缩进),人可读优先。 - 高频网络包:MessagePack 或 Protobuf,省带宽优先;二进制格式的编解码开销通常低于文本格式的解析。
- 游戏存档:自定义文本格式 + 校验和 + 版本号,兼顾可读与容错。
- 缓存到 Redis:
cjson或 MessagePack,注意 Redis 侧的解码成本。 - 跨语言强类型:Protobuf,用 schema 保证两端一致。
选型时还要考虑兼容性演进:JSON 加字段天然向后兼容,Protobuf 需要预留 reserved 字段号,MessagePack 无 schema 则要双方约定好字段含义。把"未来怎么加字段"想清楚,比选哪个格式更重要。
一个常被忽视的性能点是反序列化后的对象形态。cjson.decode 返回的是一棵全新的表树,字段访问全靠哈希查找;如果同一份数据会被反复访问,可以在解码后做一次结构转换(如把数组字段转成定长元组),减少后续的哈希开销。序列化本身的耗时往往不是瓶颈,真正拖慢的是解码后高频的字段读取。
常见问题(FAQ)
Lua 序列化后能不能直接跨语言读?
取决于格式。JSON、MessagePack、CBOR、Protobuf 都有成熟的跨语言实现,可以。但手写的 Lua 表字面量(如 {1,2,["k"]=v})其他语言无法直接解析,只适合同语言自洽的场景。
cjson 编码空表为什么是 {} 而不是 []?
因为 Lua 的空表既是数组又是映射,没有类型信息。cjson 默认按对象处理,输出 {}。要输出空数组,用 cjson.encode(cjson.empty_array),或给空表设置 cjson.empty_array_mt 元表。
大整数序列化会丢精度吗?
JSON 会。JSON 规范没有整数类型,所有数字按 IEEE 754 double 处理,超过 2^53 的整数无法精确表示。MessagePack 和 Protobuf 有独立的整数类型,可以无损存储 64 位整数。
反序列化不可信数据最需要注意什么?
三点:不要用 load 执行输入(或必须用白名单环境);限制嵌套深度与输入体积;对解析器做 pcall 包裹,避免畸形数据让进程崩溃。
为什么同一份表两次序列化结果不一样?
因为 pairs 遍历表的顺序不稳定,哈希部分的顺序取决于内部哈希布局。若需要结果可复现(如做校验和或版本 diff),必须先把键收集起来排序,再按固定顺序输出。
相关阅读
- https://plumephp.com/lua-redis-scripting/
- 游戏存档序列化系统
- C++ 序列化库对比
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。