Skip to content

数据库与存储

索引是什么?

标准答案 索引就是一份“目录”:它把某个查询条件,比如 idnamepath,映射到数据的位置或引用。 核心作用是:让查询更快。核心代价是:多占内存,数据变化时还要维护索引

底层原理 没有索引时,查一个数据通常要遍历:

从第 1 条看到第 N 条

有索引后,流程变成:

先查索引表 -> 找到位置 -> 直接拿数据

比如配置表里有 10 万条道具配置,如果每次都遍历查 itemId,就是 O(n)。 如果提前建一个 Dictionary<int, ItemConfig>,通过 itemId 查配置,通常接近 O(1)

数据库里的索引常见是 B+Tree,适合范围查询和排序;程序里的哈希索引常见就是 Dictionary,适合等值查询。

Unity/游戏场景 游戏客户端里索引很常见:

配置表:configId -> Config 资源系统:assetPath -> AssetBundle 对象管理:entityId -> GameObject/Actor UI 管理:windowName -> UIController 网络同步:playerId -> PlayerEntity

c
using System.Collections.Generic; // 引入 Dictionary 和 List 集合类型

public sealed class ItemConfig // 定义道具配置类
{ // 类开始
    public int Id; // 道具唯一 ID
    public string Name; // 道具名称
} // 类结束

public sealed class ItemConfigTable // 定义道具配置表
{ // 类开始
    private readonly Dictionary<int, ItemConfig> index = new Dictionary<int, ItemConfig>(); // 用 Dictionary 建立 id 到配置的索引

    public void BuildIndex(List<ItemConfig> configs) // 根据配置列表建立索引
    { // 方法开始
        index.Clear(); // 先清空旧索引,避免旧数据残留
        foreach (ItemConfig config in configs) // 遍历所有配置
        { // 循环开始
            index[config.Id] = config; // 用配置 ID 作为 key,把配置对象放进索引表
        } // 循环结束
    } // 方法结束

    public bool TryGet(int id, out ItemConfig config) // 根据 id 查询配置
    { // 方法开始
        return index.TryGetValue(id, out config); // 通过索引快速查找配置
    } // 方法结束
} // 类结束

优缺点 优点:查询快,代码简单,适合高频读取。 缺点:占额外内存,插入、删除、修改时要同步更新索引。 常见坑:数据变了但索引没更新,会查到旧数据、空引用或错误对象。

面试加分说法 我理解索引本质是“空间换时间”。如果某个字段经常被查,就值得建索引;如果只是偶尔查,或者数据经常变化,索引太多反而会增加维护成本。项目里我会优先给配置 ID、资源路径、实体 ID 这类高频查询字段建索引。

B+ 树为什么适合数据库索引?

b-plus-tree-database-index

标准答案 B+ 树适合数据库索引,核心原因是:数据库查数据的主要成本不是 CPU 比较,而是磁盘页 IO;B+ 树可以用很少的页访问定位数据,并且天然支持范围查询。

底层原理 B+ 树的节点通常对应数据库里的一个页,比如 16KB 页。 一个页里可以放很多 key + child pointer,所以一个节点可以有很多子节点,这叫高扇出。扇出越高,树越矮,访问磁盘页次数越少。

B+ 树还有两个关键设计:

非叶子节点只存索引 key,不存整行数据,所以能放更多 key。 所有真实数据或数据指针都在叶子节点,叶子节点之间用链表连接,所以范围查询很高效。

比如查 id = 55根节点 -> 中间节点 -> 叶子节点

比如查 id between 35 and 75: 先找到 35 所在叶子页,然后沿叶子链表顺序往后扫。

为什么不用普通二叉树 二叉树每个节点最多两个孩子,树高会更高。 数据库每下一层可能就是一次磁盘页读取,所以二叉树随机 IO 次数更多,不适合磁盘索引。

为什么不用哈希表 哈希表等值查询很快,比如 id = 1001。 但哈希表不适合范围查询和排序,比如:

id > 1000order by idbetween 100 and 200

B+ 树的 key 是有序的,所以范围查询更自然。

c
using System; // 引入基础命名空间

public static class BPlusTreePageSearch // 定义一个模拟 B+ 树页内查找的工具类
{ // 类开始
    public static int FindChildIndex(int[] keys, int targetKey) // 根据目标 key 找到应该走哪个子指针
    { // 方法开始
        int left = 0; // 定义二分查找左边界
        int right = keys.Length; // 定义二分查找右边界,使用左闭右开区间
        while (left < right) // 只要搜索区间还没有收缩完就继续
        { // 循环开始
            int mid = left + (right - left) / 2; // 计算中间位置,避免直接相加溢出
            if (targetKey < keys[mid]) // 如果目标 key 小于中间 key
            { // 判断分支开始
                right = mid; // 目标应该落在左侧子范围
            } // 判断分支结束
            else // 如果目标 key 大于或等于中间 key
            { // else 分支开始
                left = mid + 1; // 目标应该落在右侧子范围
            } // else 分支结束
        } // 循环结束
        return left; // 返回应该访问的子指针下标
    } // 方法结束
} // 类结束

面试加分说法 B+ 树不是因为“比较次数最少”才适合数据库,而是因为它贴合数据库的页式存储:高扇出降低树高,减少随机 IO;叶子链表让范围查询变成顺序扫描;内部节点更小,更容易被缓存。 代价是插入、删除时可能导致页分裂、页合并,所以写入成本比无索引高。

事务 ACID 是什么?

database-transaction-acid

标准答案 事务 ACID 是数据库事务可靠性的四个特性:

A:Atomicity,原子性 C:Consistency,一致性 I:Isolation,隔离性 D:Durability,持久性

一句话记:事务要么全做要么不做;做完后数据规则不能坏;并发之间不能互相污染;提交成功后数据不能丢。

四个特性分别是什么 原子性:一组操作要么全部成功,要么全部失败回滚。 比如玩家 A 给玩家 B 转 100 金币,不能出现 A 扣了钱但 B 没收到。

一致性:事务执行前后,数据都要满足业务规则和数据库约束。 比如金币不能为负数,背包数量不能小于 0,订单状态不能乱跳。

隔离性:多个事务并发执行时,不能互相看到不该看到的中间状态。 隔离性弱可能出现脏读、不可重复读、幻读。

持久性:事务提交成功后,结果要真正保存下来。 即使数据库宕机,也能通过日志恢复数据。

底层原理 原子性通常靠 undo log 或回滚机制实现。 持久性通常靠 redo log、WAL 写前日志实现。 隔离性通常靠锁或 MVCC 实现。 一致性则依赖数据库约束、事务逻辑和业务规则共同保证。

游戏/服务端场景 充值发货、邮件领奖、玩家交易、战斗结算都需要事务。 比如领取邮件奖励时,删除邮件和增加道具必须在一个事务里。否则可能出现奖励发了但邮件没删,玩家重复领取;或者邮件删了但奖励没发,玩家丢道具。

c
using System; // 引入基础异常类型
using System.Data; // 引入数据库连接和事务接口

public sealed class GoldTradeService // 定义金币交易服务
{ // 类开始
    public void TransferGold(IDbConnection connection, int fromUserId, int toUserId, int amount) // 定义转金币方法
    { // 方法开始
        connection.Open(); // 打开数据库连接
        using IDbTransaction transaction = connection.BeginTransaction(); // 开启数据库事务
        try // 开始捕获事务执行中的异常
        { // try 开始
            DeductGold(connection, transaction, fromUserId, amount); // 从转出玩家扣除金币
            AddGold(connection, transaction, toUserId, amount); // 给转入玩家增加金币
            WriteTradeLog(connection, transaction, fromUserId, toUserId, amount); // 写入交易日志
            transaction.Commit(); // 所有操作成功后提交事务
        } // try 结束
        catch // 如果中途任意一步失败
        { // catch 开始
            transaction.Rollback(); // 回滚事务,撤销已经执行的数据库修改
            throw; // 把异常继续抛给上层处理
        } // catch 结束
    } // 方法结束

    private void DeductGold(IDbConnection connection, IDbTransaction transaction, int userId, int amount) { } // 扣金币 SQL,实际项目中要检查余额不能为负

    private void AddGold(IDbConnection connection, IDbTransaction transaction, int userId, int amount) { } // 加金币 SQL,必须和扣金币使用同一个事务

    private void WriteTradeLog(IDbConnection connection, IDbTransaction transaction, int fromUserId, int toUserId, int amount) { } // 写交易日志,方便审计和问题回滚
} // 类结束

面试加分说法 ACID 不是免费能力。事务越大,锁持有时间越长;隔离级别越高,并发能力通常越低。实际项目里要把事务范围控制小,只包住真正需要一致性的数据库操作,不要在事务里做网络请求、长时间计算或等待外部服务。

乐观锁和悲观锁区别是什么?

optimistic-vs-pessimistic-lock

标准答案 悲观锁和乐观锁都是为了解决并发修改同一份数据的问题。

悲观锁:认为冲突很可能发生,所以先加锁,再操作,其他请求必须等。 乐观锁:认为冲突不一定发生,所以先不加锁,提交时检查版本号,如果版本不一致就失败或重试。

底层原理 悲观锁的核心是“占住资源”。比如数据库里的 SELECT ... FOR UPDATE,或者程序里的 lockmutex。它能保证同一时间只有一个事务修改数据,但会带来阻塞等待,锁持有时间太长还可能导致死锁或吞吐下降。

乐观锁的核心是“版本校验”。数据表里通常会有一个 version 字段。读取时拿到版本号,更新时带上旧版本:

c
UPDATE item SET count = count - 1, version = version + 1 WHERE id = 1 AND version = 3

如果影响行数是 1,说明没人改过,更新成功。 如果影响行数是 0,说明版本已经变了,当前操作失败,需要重新读取、重试或提示用户失败。

怎么选择 冲突概率高、强一致要求高,用悲观锁更稳。比如交易、抢购、库存扣减。 读多写少、冲突概率低,用乐观锁更轻。比如玩家资料修改、配置版本更新、非核心状态保存。

c
public sealed class ItemRecord // 定义库存记录
{ // 类开始
    public int Count; // 当前库存数量
    public int Version; // 当前数据版本号
} // 类结束

public interface IItemRepository // 定义库存仓库接口
{ // 接口开始
    ItemRecord Load(int itemId); // 根据道具 ID 读取库存和版本
    int UpdateIfVersionMatch(int itemId, int newCount, int oldVersion); // 只有版本匹配时才更新库存
} // 接口结束

public sealed class InventoryService // 定义库存服务
{ // 类开始
    public bool TryConsumeWithOptimisticLock(IItemRepository repository, int itemId, int count) // 使用乐观锁扣减库存
    { // 方法开始
        ItemRecord record = repository.Load(itemId); // 先读取当前库存记录
        if (record.Count < count) return false; // 如果库存不足,直接返回失败
        int newCount = record.Count - count; // 计算扣减后的库存
        int affectedRows = repository.UpdateIfVersionMatch(itemId, newCount, record.Version); // 带旧版本号尝试更新
        return affectedRows == 1; // 影响 1 行说明成功,影响 0 行说明版本冲突
    } // 方法结束

    private readonly object syncRoot = new object(); // 定义悲观锁使用的同步对象

    public bool TryConsumeWithPessimisticLock(ItemRecord record, int count) // 使用悲观锁扣减库存
    { // 方法开始
        lock (syncRoot) // 先加锁,保证同一时间只有一个线程进入
        { // 加锁区域开始
            if (record.Count < count) return false; // 如果库存不足,直接返回失败
            record.Count -= count; // 在锁保护下扣减库存
            record.Version++; // 修改成功后递增版本号
            return true; // 返回扣减成功
        } // 加锁区域结束
    } // 方法结束
} // 类结束

游戏/服务端场景 如果是玩家交易、抢购礼包、全服限量库存,我会倾向悲观锁或数据库行锁,因为冲突概率高,不能超卖。 如果是玩家昵称修改、个人配置保存、弱冲突资料更新,我会倾向乐观锁,因为不用长时间持锁,并发性能更好。

面试加分说法 悲观锁是“我先占住,别人等”;乐观锁是“大家先做,提交时看版本,不匹配就重试”。实际项目里不是哪个绝对更好,而是看冲突概率、业务一致性要求和失败重试成本。

缓存穿透、击穿、雪崩是什么?

cache-penetration-breakdown-avalanche

标准答案 缓存穿透、击穿、雪崩都是缓存失效导致数据库压力变大的问题,但原因不同:

缓存穿透:查一个根本不存在的数据,缓存没有,数据库也没有。 缓存击穿:一个热点 key 过期,大量请求同时打到数据库。 缓存雪崩:大量 key 同时过期,或者缓存服务整体不可用,导致数据库被流量淹没。

底层原理 穿透的重点是“数据不存在”。比如恶意请求一直查不存在的玩家 ID:playerId = -1,每次缓存 miss,每次都查数据库。

击穿的重点是“单个热点 key”。比如全服活动配置、排行榜第一页、热门商品库存突然过期,大量玩家同时访问,数据库瞬间被打满。

雪崩的重点是“大面积失效”。比如大量缓存设置了相同 TTL,到了同一秒一起过期;或者 Redis 集群不可用,请求全部落到数据库。

治理方案 穿透:参数校验、布隆过滤器、缓存空值。 击穿:热点 key 加互斥锁、singleflight、逻辑过期、后台异步刷新。 雪崩:TTL 加随机抖动、缓存预热、多级缓存、限流、熔断、降级、缓存高可用。

c
using System; // 引入基础类型
using System.Collections.Generic; // 引入 Dictionary 集合
using System.Threading.Tasks; // 引入 Task 异步类型

public sealed class CacheAsideService<T> // 定义缓存旁路服务
{ // 类开始
    private readonly Dictionary<string, T> cache = new Dictionary<string, T>(); // 模拟缓存存储
    private readonly Dictionary<string, object> locks = new Dictionary<string, object>(); // 保存每个 key 对应的互斥锁
    private readonly Random random = new Random(); // 用随机数给 TTL 增加抖动

    public async Task<T> GetOrLoadAsync(string key, Func<Task<T>> loadFromDb) // 定义查询缓存,缓存没有就查数据库的方法
    { // 方法开始
        if (cache.TryGetValue(key, out T cachedValue)) // 先查询缓存是否命中
        { // 缓存命中分支开始
            return cachedValue; // 命中缓存则直接返回
        } // 缓存命中分支结束

        object keyLock = GetLock(key); // 获取当前 key 对应的互斥锁
        lock (keyLock) // 对同一个 key 加锁,防止热点 key 同时重建缓存
        { // 加锁区域开始
            if (cache.TryGetValue(key, out cachedValue)) // 进入锁后再次检查缓存
            { // 二次检查分支开始
                return cachedValue; // 如果其他线程已经重建缓存,则直接返回
            } // 二次检查分支结束
        } // 加锁区域结束

        T dbValue = await loadFromDb(); // 缓存没有时查询数据库
        cache[key] = dbValue; // 把数据库结果写入缓存
        int ttlSeconds = 300 + random.Next(0, 60); // 给 TTL 增加随机抖动,避免大量 key 同时过期
        _ = ttlSeconds; // 示例里不真正实现过期,只表示这里会把 TTL 传给 Redis
        return dbValue; // 返回最终结果
    } // 方法结束

    private object GetLock(string key) // 根据 key 获取锁对象
    { // 方法开始
        if (!locks.TryGetValue(key, out object keyLock)) // 如果当前 key 还没有锁对象
        { // 创建锁分支开始
            keyLock = new object(); // 创建新的锁对象
            locks[key] = keyLock; // 把锁对象保存起来
        } // 创建锁分支结束
        return keyLock; // 返回 key 对应的锁对象
    } // 方法结束
} // 类结束

游戏场景回答 游戏里排行榜、活动配置、商城商品、热门玩家资料都可能成为热点缓存。 我会对活动配置和排行榜做预热,对热点 key 做逻辑过期和异步刷新;对不存在的玩家 ID 或道具 ID 做参数校验和空值缓存;对大批量缓存设置 TTL 时加随机抖动,避免同一时间集体失效。

面试加分说法 这三个问题不能混着答。穿透是“查不存在”,击穿是“一个热点失效”,雪崩是“大量缓存同时失效或缓存整体不可用”。定位清楚原因后,方案才是对的。

Redis 常用数据结构有哪些?

redis-common-data-structures

标准答案 Redis 常用数据结构主要有:StringHashListSetZSet,再加上常见扩展型结构:BitmapHyperLogLogGeoStream

怎么理解String:最基础的 key-value,适合缓存、计数器、分布式锁标记。 Hash:一个 key 下有多个字段,适合存玩家信息、角色属性。 List:有序列表,两端 push/pop,适合简单队列、消息列表。 Set:无序且不重复,适合去重、好友集合、活动参与用户集合。 ZSet:有序集合,每个成员有 score,最典型用途是排行榜。 Bitmap:用 bit 存状态,适合签到、在线状态、活动标记。 HyperLogLog:近似去重计数,适合统计 UV、活跃用户数。 Geo:地理位置查询,适合附近玩家、附近服务器。 Stream:消息流,支持消费者组,适合异步任务、日志、可靠事件流。

底层原理 Redis 的数据结构不是简单的表面类型,内部会根据数据规模选择不同编码。比如小 Hash 可能用紧凑结构节省内存,数据变大后切换到哈希表;ZSet 小数据可能用紧凑结构,数据变大后常见是跳表加字典。面试说到这里,会显得你不是只背命令。

游戏场景 排行榜用 ZSet。 玩家基础信息缓存用 Hash。 在线玩家集合用 SetBitmap。 每日签到用 Bitmap。 限流、分布式锁、计数器用 String。 异步邮件、日志、任务管道可以用 Stream

c
using StackExchange.Redis; // 引入 Redis 客户端命名空间
using System.Threading.Tasks; // 引入 Task 异步类型

public sealed class RedisGameExample // 定义 Redis 游戏业务示例类
{ // 类开始
    public async Task DemoAsync(IDatabase db) // 定义一个 Redis 数据结构示例方法
    { // 方法开始
        await db.StringSetAsync("lock:match:1001", "server-1"); // String:保存匹配房间锁标记
        await db.HashSetAsync("player:1001", "level", 30); // Hash:保存玩家等级字段
        await db.ListLeftPushAsync("mail:queue", "mail_9001"); // List:把邮件任务放入队列
        await db.SetAddAsync("online:players", "1001"); // Set:把玩家加入在线集合
        await db.SortedSetAddAsync("rank:power", "1001", 9527); // ZSet:把玩家战力写入排行榜
        await db.StringSetBitAsync("signin:2026-08:1001", 4, true); // Bitmap:记录玩家当月第 5 天已签到
        await db.HyperLogLogAddAsync("uv:login:today", "device_abc"); // HyperLogLog:统计今日登录设备去重数
        await db.StreamAddAsync("battle:log", "event", "skill_cast"); // Stream:追加一条战斗日志事件
    } // 方法结束
} // 类结束

面试加分说法 我不会把 Redis 只当成字符串缓存用。实际选型要看访问模式:对象用 Hash,排名用 ZSet,去重用 Set,状态位用 Bitmap,消息流用 Stream。还要注意大 key、热 key、过期策略和内存淘汰,否则结构选对了也可能把 Redis 打慢。

Redis 为什么快?

redis-why-fast

标准答案

Redis 快主要有几方面原因:它大部分操作都在内存中完成;使用 I/O 多路复用处理大量连接;命令执行模型简单,减少锁竞争和线程上下文切换;内部数据结构做了大量优化;协议也比较轻量。

底层一点说

Redis 不是每个客户端连接都开一个线程,而是通过事件循环监听多个 socket 事件。网络请求来了之后,Redis 解析命令,操作内存里的数据结构,然后把结果写回客户端。因为主路径大多不碰磁盘,也没有复杂 SQL 优化、事务锁竞争那套开销,所以速度很快。

为什么单线程也快

这里说的单线程主要是“命令执行单线程”。Redis 的瓶颈很多时候在网络和内存访问,不一定在 CPU 计算。单线程反而减少了锁竞争、线程切换、并发控制成本。不过 Redis 也不是完全没有多线程,比如持久化、异步删除、网络 I/O 在一些版本和配置下会用后台线程辅助。

常见加分点

Redis 快不代表所有命令都快。大 Key、慢查询、阻塞命令、持久化策略不当、网络延迟、内存淘汰,都可能导致 Redis 变慢。面试里不要只回答“因为 Redis 是 C 写的”,这太浅了。

一句话收尾

Redis 快的核心是:内存操作为主,事件驱动处理网络,单线程命令执行减少并发开销,再配合高效数据结构和简单协议。

本地存档如何防损坏?

unity-local-save-damage-prevention

标准答案 本地存档防损坏,核心是:不要直接覆盖原文件,而是原子写入、保留备份、读取校验、版本兼容

最标准的流程是:

先写 save.tmp。 写完后计算 hashCRC。 校验通过后,把旧的 save.dat 变成 save.bak。 再把 save.tmp 替换成新的 save.dat。 读取时先读主存档,校验失败就读备份。

底层原理 存档损坏最常见的原因是写到一半时游戏崩溃、手机断电、App 被系统杀掉、磁盘空间不足,或者新版本字段变了。 如果直接覆盖原文件,一旦中途失败,原来的好存档也没了。 所以要先写临时文件,确认完整后再替换正式文件,这就是“原子写入”的思路。

Unity 工程实践 本地存档一般放在 Application.persistentDataPath。 重要数据建议带上:

schemaVersion:存档结构版本。 hash:检测文件是否损坏。 timestamp:判断新旧。 payload:真正的存档内容。 backup:上一份可用存档。

c
using System.IO; // 引入文件读写 API
using System.Security.Cryptography; // 引入 SHA256 校验 API
using System.Text; // 引入 UTF8 编码 API
using UnityEngine; // 引入 Unity 的 Application 路径 API

public static class SafeSaveSystem // 定义安全存档系统
{ // 类开始
    public static void Save(string fileName, string json) // 保存 JSON 存档
    { // 方法开始
        string dir = Application.persistentDataPath; // 获取 Unity 可持久化存档目录
        string mainPath = Path.Combine(dir, fileName); // 拼出正式存档路径
        string tempPath = mainPath + ".tmp"; // 拼出临时存档路径
        string backupPath = mainPath + ".bak"; // 拼出备份存档路径
        string hash = ComputeSha256(json); // 计算 JSON 内容的 SHA256
        string wrapped = hash + "\n" + json; // 把 hash 和正文一起写入文件
        File.WriteAllText(tempPath, wrapped, Encoding.UTF8); // 先写临时文件,避免直接覆盖正式存档
        if (!VerifyFile(tempPath)) return; // 如果临时文件校验失败,直接放弃替换
        if (File.Exists(mainPath)) File.Copy(mainPath, backupPath, true); // 如果正式存档存在,先复制成备份
        File.Copy(tempPath, mainPath, true); // 用临时文件覆盖正式存档
        File.Delete(tempPath); // 删除临时文件
    } // 方法结束

    public static bool TryLoad(string fileName, out string json) // 读取 JSON 存档
    { // 方法开始
        string dir = Application.persistentDataPath; // 获取 Unity 可持久化存档目录
        string mainPath = Path.Combine(dir, fileName); // 拼出正式存档路径
        string backupPath = mainPath + ".bak"; // 拼出备份存档路径
        if (TryReadValid(mainPath, out json)) return true; // 优先读取正式存档
        if (TryReadValid(backupPath, out json)) return true; // 正式存档损坏时读取备份
        json = null; // 如果主存档和备份都失败,返回空内容
        return false; // 返回读取失败
    } // 方法结束

    private static bool TryReadValid(string path, out string json) // 尝试读取并校验文件
    { // 方法开始
        json = null; // 默认输出为空
        if (!File.Exists(path)) return false; // 文件不存在则读取失败
        string text = File.ReadAllText(path, Encoding.UTF8); // 读取整个文件内容
        int lineIndex = text.IndexOf('\n'); // 找到第一行 hash 和正文的分隔位置
        if (lineIndex <= 0) return false; // 如果没有分隔符,说明格式不合法
        string savedHash = text.Substring(0, lineIndex); // 取出文件里保存的 hash
        string body = text.Substring(lineIndex + 1); // 取出真正的 JSON 正文
        if (savedHash != ComputeSha256(body)) return false; // 重新计算 hash,不一致说明文件损坏或被改
        json = body; // 校验通过后输出 JSON 正文
        return true; // 返回读取成功
    } // 方法结束

    private static bool VerifyFile(string path) // 校验一个存档文件
    { // 方法开始
        return TryReadValid(path, out _); // 复用读取校验逻辑判断文件是否有效
    } // 方法结束

    private static string ComputeSha256(string text) // 计算字符串的 SHA256
    { // 方法开始
        byte[] bytes = Encoding.UTF8.GetBytes(text); // 把字符串转成 UTF8 字节数组
        byte[] hashBytes = SHA256.HashData(bytes); // 计算 SHA256 字节数组
        return System.Convert.ToHexString(hashBytes); // 把 hash 转成十六进制字符串
    } // 方法结束
} // 类结束

面试加分说法 如果只是防损坏,hash/CRC 就够用;如果还要防玩家篡改,要用 HMAC 或签名,因为普通 hash 玩家也能重新算。 另外存档里一定要有版本号,新版本读取旧存档时做迁移,不能因为字段变动直接读崩。

JSON、XML、二进制存储怎么选?

json-xml-binary-storage-choice

标准答案 JSON、XML、二进制存储没有绝对谁更好,核心看五个维度:可读性、体积、解析速度、版本兼容、工具链成本

JSON:可读性好,简单,跨平台友好,适合配置、存档、接口调试。 XML:层级和 Schema 能力强,但很啰嗦,适合旧系统、工具配置、文档型数据。 二进制:体积小、读写快、传输效率高,但不可读、版本兼容要求高,适合网络包、回放、大量运行时数据。

Unity/游戏里怎么选 开发期:优先 JSON,方便策划、程序排查问题。 上线期:大量高频数据可以转二进制,比如配置表打包、战斗回放、网络协议。 旧工具链:如果已有 XML 生态,比如编辑器、Schema 校验、老项目配置,可以继续用 XML。 存档:小型单机存档可以 JSON + 校验 + 备份;追求体积和防改可以二进制 + 压缩 + HMAC。 网络:一般不用 JSON/XML,倾向 Protobuf、MessagePack、自定义二进制协议。

底层原理 JSON 和 XML 都是文本格式,优点是人能看懂,缺点是字段名、标签、空白字符会增加体积,解析时要做字符串处理。 二进制格式直接按约定写入数值、长度、字段编号,体积更小,解析更快,但必须管理好字段顺序、类型、版本和兼容。

c
using System.IO; // 引入文件流和二进制读写 API
using UnityEngine; // 引入 Unity 的 JsonUtility API

[System.Serializable] // 让 Unity JsonUtility 可以序列化这个类
public sealed class PlayerSaveData // 定义玩家存档数据
{ // 类开始
    public int Version; // 存档版本号,用于后续兼容和迁移
    public int Level; // 玩家等级
    public int Gold; // 玩家金币
} // 类结束

public static class SaveFormatExample // 定义存储格式示例类
{ // 类开始
    public static string ToJson(PlayerSaveData data) // 把存档转成 JSON
    { // 方法开始
        return JsonUtility.ToJson(data); // 使用 Unity 内置 JSON 序列化,方便调试
    } // 方法结束

    public static byte[] ToBinary(PlayerSaveData data) // 把存档转成二进制
    { // 方法开始
        using MemoryStream stream = new MemoryStream(); // 创建内存流保存二进制数据
        using BinaryWriter writer = new BinaryWriter(stream); // 创建二进制写入器
        writer.Write(data.Version); // 写入版本号,保证以后可以做兼容
        writer.Write(data.Level); // 写入玩家等级
        writer.Write(data.Gold); // 写入玩家金币
        return stream.ToArray(); // 返回最终二进制字节数组
    } // 方法结束

    public static PlayerSaveData FromBinary(byte[] bytes) // 从二进制恢复存档
    { // 方法开始
        using MemoryStream stream = new MemoryStream(bytes); // 使用字节数组创建内存流
        using BinaryReader reader = new BinaryReader(stream); // 创建二进制读取器
        PlayerSaveData data = new PlayerSaveData(); // 创建存档对象
        data.Version = reader.ReadInt32(); // 读取版本号
        data.Level = reader.ReadInt32(); // 读取玩家等级
        data.Gold = reader.ReadInt32(); // 读取玩家金币
        return data; // 返回反序列化后的存档对象
    } // 方法结束
} // 类结束

常见坑点 不要用 BinaryFormatter,它已经不推荐使用,并且有安全风险。 二进制不要裸写字段,必须带版本号,否则字段一变旧数据就可能读崩。 JSON 也不是天然安全,玩家能改本地文件,所以重要数据要加校验、签名或放服务端。 配置表不要只考虑格式,还要考虑导表、校验、版本兼容和错误提示。

配置表如何做版本兼容?

config-table-version-compatibility

标准答案 配置表做版本兼容,不能只加一个版本号。完整方案应该是:版本字段 + 兼容规则 + 默认值 + 迁移器 + 导表校验 + 运行时回滚

核心目标是:老客户端遇到可兼容的新配置能正常跑;遇到不兼容配置能拒绝加载、回滚旧配置或提示强更,而不是直接崩。

版本字段怎么设计 通常至少有这几个字段:

schemaVersion:表结构版本,比如字段新增、字段类型变化。 dataVersion:内容版本,比如数值改了、掉落概率改了。 minClientVersion:最低客户端版本,低于这个版本不能加载。 hash:校验配置包是否完整。 tableVersion:单张表自己的版本,方便局部更新和排查。

兼容规则 新增字段可以兼容,但必须有默认值。 字段改名、改类型、删除字段通常不兼容,要写迁移逻辑。 枚举值可以追加,但不要重排旧枚举值。 配置 ID 一旦上线不要随便改含义,因为它可能被存档、日志、任务、服务器引用。

Unity/游戏工程实践 热更新配置时,不应该下载完就直接覆盖当前内存配置。 我会先放到 staging 临时区,做版本检查、hash 校验、字段迁移、引用检查、索引构建。全部成功后,再切换到运行时配置。 如果失败,就继续使用 last good config,同时上报日志,必要时提示强更或重新拉包。

c
using System; // 引入 Serializable 特性
using System.Collections.Generic; // 引入 Dictionary 集合类型

[Serializable] // 标记该类可以被序列化
public sealed class ConfigManifest // 定义配置清单
{ // 类开始
    public int SchemaVersion; // 配置结构版本
    public int DataVersion; // 配置内容版本
    public int MinClientVersion; // 最低客户端版本
    public string Hash; // 配置包完整性校验值
} // 类结束

[Serializable] // 标记该类可以被序列化
public sealed class RawItemConfig // 定义原始道具配置
{ // 类开始
    public int SchemaVersion; // 当前行数据对应的结构版本
    public int Id; // 道具 ID,上线后不要随便改含义
    public string Name; // 道具名称
    public int Quality; // 道具品质
    public string IconPath; // 道具图标路径,新版本新增字段需要默认值
} // 类结束

public sealed class ItemConfigTable // 定义运行时道具配置表
{ // 类开始
    private readonly Dictionary<int, RawItemConfig> index = new Dictionary<int, RawItemConfig>(); // 用 ID 建立索引

    public bool TryLoad(ConfigManifest manifest, List<RawItemConfig> rows, int clientVersion) // 尝试加载配置表
    { // 方法开始
        if (clientVersion < manifest.MinClientVersion) return false; // 客户端版本太低时拒绝加载
        Dictionary<int, RawItemConfig> staging = new Dictionary<int, RawItemConfig>(); // 创建临时索引区
        foreach (RawItemConfig row in rows) // 遍历每一行配置
        { // 循环开始
            RawItemConfig migrated = Migrate(row, manifest.SchemaVersion); // 先把旧结构迁移到当前结构
            if (!Validate(migrated)) return false; // 校验失败则拒绝整包配置
            if (staging.ContainsKey(migrated.Id)) return false; // 发现重复 ID 则拒绝加载
            staging.Add(migrated.Id, migrated); // 把通过校验的数据放入临时索引
        } // 循环结束
        index.Clear(); // 清空旧运行时索引
        foreach (KeyValuePair<int, RawItemConfig> pair in staging) // 遍历临时索引
        { // 循环开始
            index.Add(pair.Key, pair.Value); // 把临时配置切换为正式运行时配置
        } // 循环结束
        return true; // 返回加载成功
    } // 方法结束

    private RawItemConfig Migrate(RawItemConfig row, int targetSchemaVersion) // 把旧配置迁移到目标结构
    { // 方法开始
        if (row.SchemaVersion < 2 && string.IsNullOrEmpty(row.IconPath)) // 如果旧版本还没有 IconPath 字段
        { // 判断开始
            row.IconPath = "Icons/DefaultItem"; // 给新增字段补默认值
        } // 判断结束
        row.SchemaVersion = targetSchemaVersion; // 更新为当前结构版本
        return row; // 返回迁移后的配置
    } // 方法结束

    private bool Validate(RawItemConfig row) // 校验单行配置是否合法
    { // 方法开始
        if (row.Id <= 0) return false; // ID 必须是正数
        if (string.IsNullOrEmpty(row.Name)) return false; // 名字不能为空
        if (row.Quality < 0) return false; // 品质不能为负数
        if (string.IsNullOrEmpty(row.IconPath)) return false; // 图标路径不能为空
        return true; // 所有规则通过则返回成功
    } // 方法结束
} // 类结束

常见坑点 只加版本号但不写迁移器,没用。 新增字段没有默认值,旧配置会读出空值。 运行时直接覆盖当前配置,一旦新配置坏了就无法回滚。 导表时不做引用检查,会在线上出现空引用,比如怪物表引用了不存在的技能 ID。 配置 ID 复用很危险,可能让旧存档、战斗日志、任务进度全部解释错。

面试加分说法 我会把配置表当成“数据契约”来管理:字段变化要有兼容规则,破坏性变化要有迁移器,导表阶段做静态校验,运行时先在 staging 区加载验证,成功后再切换,失败就回滚 last good config。这样才能支撑热更新和多版本客户端共存。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0