news 2026/5/22 2:45:09

抖音小游戏用户数据系统重构:云开发范式实践指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
抖音小游戏用户数据系统重构:云开发范式实践指南

1. 这不是“移植”,是重构:为什么抖音小游戏的用户数据系统必须重做

很多人拿到“Unity项目转抖音小游戏”这个任务时,第一反应是:把Unity里写好的PlayerPrefs存档逻辑、本地SQLite数据库、甚至已经跑在自建服务器上的REST API,原封不动搬过去——结果十有八九卡在登录后数据不加载、排行榜始终为空、用户等级一退出就归零。我去年帮三个团队做过类似迁移,最典型的一个案例是某款休闲合成游戏,Unity端用Addressable+JSON本地缓存实现了离线排行榜,上线抖音后第一天就有2000+用户反馈“登进去还是新手”,后台查日志发现所有云函数调用都返回401,但开发者根本没改过鉴权逻辑。

问题不在代码,而在底层契约变了。Unity Editor里运行的代码默认拥有完整文件系统读写权限、可直连任意IP的HTTP服务、能自由创建线程和WebSocket长连接;而抖音小游戏运行在严格沙箱化的WebView容器中,它只允许你通过抖音官方SDK提供的唯一入口与后端交互——这个入口就是云开发(Tencent CloudBase for Douyin),它强制你放弃“自己搭服务器”的思维,转而接受“云数据库+云函数”这一对原子能力组合。这不是技术选型偏好,而是平台安全策略决定的硬性边界:你的用户ID由抖音OAuth2.0授权体系生成,用户行为数据必须经由抖音可信通道上传,任何绕过云开发SDK的网络请求都会被客户端拦截。

所以,“转抖音小游戏”的本质,不是打包发布,而是用云开发范式重写数据层。你不需要再纠结Nginx配置、MySQL主从同步、JWT密钥轮换,但必须理解:云数据库不是MySQL的精简版,它是文档型NoSQL(类MongoDB);云函数不是Node.js的简单封装,它是无状态、短生命周期、按调用计费的执行单元;而最关键的——所有数据操作必须绑定用户身份,且该身份只能通过抖音SDK的wx.login()wx.getUserInfo()链路获取。这直接决定了你设计用户表结构、定义权限规则、编写函数逻辑的方式。接下来我会以一个真实上线项目的完整路径为例,从零开始构建一套可落地的用户数据系统,不讲概念,只说你打开编辑器后要敲的每一行关键代码、要填的每一个控制台配置项、以及那些文档里绝不会写的坑。

2. 云数据库设计:别再用“用户表”思维,用“用户空间”思维

2.1 为什么不能照搬Unity里的User类结构?

在Unity项目中,我们习惯定义一个C#User类:

public class User { public string userId; public string nickname; public int level; public int exp; public List<string> inventory; public DateTime lastLogin; }

然后用PlayerPrefs或SQLite存成一行记录。但当你把这套结构直接映射到抖音云数据库的users集合时,会立刻踩到三个深坑:

  • 坑1:权限模型错配
    云数据库的集合级权限(read/write)是全局的,但你的需求是“用户A只能读写自己的数据”。如果给users集合设为“仅创建者可读写”,那用户A插入一条记录后,他下次查询where userId == "A"时会失败——因为云开发的where查询默认不校验创建者身份,它只校验当前登录用户的OpenID是否匹配该文档的_openid字段。而_openid是云开发自动注入的隐藏字段,你无法在C#类里显式声明。

  • 坑2:数据膨胀失控
    inventory字段在Unity里可能存着50个道具ID字符串,但在云数据库中,单文档大小上限是1MB,且频繁更新大数组会触发高延迟(实测100个字符串更新耗时从80ms飙升到320ms)。更致命的是,抖音小游戏冷启动时间要求严苛,首屏加载超过1.5秒就会流失35%用户,而一次包含大数组的get请求很容易突破阈值。

  • 坑3:关系查询失效
    Unity里常用SELECT * FROM users WHERE level > 10 ORDER BY exp DESC LIMIT 10实现排行榜。但云数据库不支持跨文档ORDER BYLIMIT组合(早期版本甚至不支持ORDER BY),你必须用聚合管道(Aggregate Pipeline),而聚合管道对_openid字段的权限校验逻辑又和普通查询不同。

2.2 正确的分层设计:用户核心档案 + 原子化行为快照

我们最终采用的方案是将用户数据拆成三层,每层解决一个核心问题:

层级集合名存储内容权限策略典型操作
核心层user_profiles用户基础信息(昵称、头像URL、注册时间、最后登录时间)创建者可读写,其他用户不可见首次登录创建、昵称修改
状态层user_states当前游戏状态(等级、经验、金币、体力值)创建者可读写,其他用户不可见每次升级/获得金币时更新
行为层user_actions行为快照(类型、时间戳、关联ID)创建者可读写,管理员可读登录、分享、通关关卡、购买道具

提示:user_actions集合不设索引,靠云函数聚合生成排行榜;user_states集合对level字段建索引,支撑实时等级查询;user_profiles集合对nickname建唯一索引,防昵称重复。

这样设计的底层逻辑是:把“可变状态”和“不可变事实”分离。用户等级会变,但某次通关关卡的行为事实不会变。当需要展示“好友最高关卡”,云函数只需扫描user_actionstype == "pass_level"的最新记录;当需要实时显示“当前金币数”,前端直接查user_states——避免了传统方案中为满足不同场景而不断加字段、改索引的恶性循环。

2.3 实操:三步完成集合初始化与权限配置

第一步:在抖音开发者后台创建集合
进入【抖音开放平台】→【小程序管理】→【云开发】→【数据库】,点击“新建集合”,依次创建:

  • user_profiles:勾选“启用用户权限”,不勾选“启用定时触发”
  • user_states:同样启用用户权限
  • user_actions:启用用户权限 + 启用“按创建时间自动排序”(此选项影响聚合性能)

注意:集合名必须全小写+下划线,不能用驼峰(如userProfiles会报错),这是云开发SDK的硬性约束。

第二步:配置安全规则(关键!)
在每个集合的“安全规则”页,粘贴以下规则(以user_states为例):

{ "read": "auth.openid == doc._openid", "write": "auth.openid == doc._openid" }

这条规则的意思是:只有当前登录用户的OpenID与该文档的_openid字段完全相等,才允许读写。auth.openid是云开发自动注入的当前用户身份,doc._openid是文档创建时自动写入的字段。切记不要写成auth.openid == doc.userId——userId是你自己定义的字段,而_openid才是抖音认证体系的唯一凭证,后者由平台保证不可伪造。

第三步:在Unity中初始化数据库引用
在Unity脚本中,不要用CloudBase.Init()这种旧接口(已废弃),必须用新版SDK:

// 初始化云开发(在Awake中调用) CloudBase.Init(new InitOptions { EnvId = "your-env-id-here", // 在抖音后台云开发概览页复制 Region = "ap-shanghai" // 必须与后台创建环境时选择的地域一致 }); // 获取user_states集合引用(推荐封装成单例) private static CollectionReference userStatesRef => CloudBase.GetDatabase().Collection("user_states");

实测发现,Region参数若填错(比如后台选的是ap-beijing却填ap-shanghai),所有数据库操作会静默失败,控制台无任何错误日志——这是抖音云开发最隐蔽的坑之一,务必核对两次。

3. 云函数实战:不是写API,是编排事件流

3.1 为什么90%的开发者写的第一个云函数就超时?

我见过太多人把云函数当成“远程PHP脚本”来用:在Unity里调用cloud.callFunction("updateUserLevel"),函数里直接写db.collection('users').where({userId: event.userId}).update({...})。结果上线后大量报错Function execution timeout。根本原因在于:云函数默认超时时间是3秒,而一次未优化的数据库查询+JSON序列化+网络传输很容易突破这个阈值

更深层的问题是思维惯性——在Unity里,UpdateUserLevel()是一个瞬时完成的内存操作;但在云函数里,它是一次完整的网络往返:Unity客户端 → 抖音CDN节点 → 云函数实例 → 云数据库 → 返回结果。这个链路中任何一个环节延迟升高(比如数据库慢查询、函数冷启动),都会导致超时。

3.2 真正高效的云函数设计:原子操作 + 异步解耦

我们重构后的用户数据更新流程如下图所示(文字描述):

Unity客户端 ↓ 调用云函数 updateUserState(传入 {level: 15, exp: 2300}) 云函数 updateUserState ├─ 1. 校验token有效性(用event.context.openid比对) ├─ 2. 执行原子更新:db.collection('user_states').doc(event.context.openid).update(...) └─ 3. 触发异步事件:向消息队列发送"LEVEL_UP"事件(用于后续成就系统)

关键点在于:

  • 所有数据库操作必须基于_openid主键更新,而不是where查询。doc(event.context.openid)直接定位到用户专属文档,耗时稳定在200ms内;
  • 绝不做跨集合join操作。比如“更新等级同时扣减体力”,必须拆成两个独立函数调用,或在Unity端合并状态后一次性提交;
  • 复杂业务逻辑(如发放成就)必须异步化,云函数本身只做状态持久化,避免阻塞主线程。

3.3 从零编写一个可上线的云函数:updateUserState

第一步:在抖音后台创建函数
进入【云开发】→【云函数】→【新建函数】,填写:

  • 函数名称:updateUserState(必须小写,不能含下划线)
  • 运行环境:Node.js 16(推荐,兼容性最好)
  • 内存规格:256MB(够用,512MB成本翻倍但无必要)
  • 超时时间:5秒(必须大于默认3秒,留出缓冲)

第二步:编写函数代码(重点看注释)

// index.js const cloudBase = require('@cloudbase/node-sdk'); exports.main = async (event, context) => { // 1. 初始化云开发SDK(必须!否则db操作无效) const app = cloudBase.init({ env: cloudBase.SYMBOL_CURRENT_ENV, }); const db = app.database(); try { // 2. 从上下文获取用户OpenID(这是唯一可信身份) const openid = context?.openid; if (!openid) { throw new Error('Missing openid in context'); } // 3. 从event中提取业务参数(必须做白名单校验!) const { level, exp, gold, stamina } = event; // 白名单校验:只允许更新指定字段,防止恶意传入其他字段 const validFields = {}; if (typeof level === 'number' && level >= 1 && level <= 100) { validFields.level = level; } if (typeof exp === 'number' && exp >= 0) { validFields.exp = exp; } if (typeof gold === 'number' && gold >= 0) { validFields.gold = gold; } if (typeof stamina === 'number' && stamina >= 0 && stamina <= 100) { validFields.stamina = stamina; } // 4. 原子更新:直接操作_openid文档(性能关键!) const res = await db .collection('user_states') .doc(openid) // 直接用openid作为文档ID .update({ data: { ...validFields, updatedAt: db.serverDate(), // 自动写入服务器时间 }, }); return { code: 0, message: 'success', data: res, }; } catch (err) { console.error('updateUserState error:', err); return { code: -1, message: err.message || 'Internal error', }; } };

第三步:在Unity中调用(注意错误处理)

// 封装调用方法 public static async Task<CloudFunctionResult> UpdateUserStateAsync( int? level = null, int? exp = null, int? gold = null, int? stamina = null) { var param = new Dictionary<string, object>(); if (level.HasValue) param["level"] = level.Value; if (exp.HasValue) param["exp"] = exp.Value; if (gold.HasValue) param["gold"] = gold.Value; if (stamina.HasValue) param["stamina"] = stamina.Value; try { var result = await CloudBase.CallFunction("updateUserState", param); Debug.Log($"Update success: {JsonConvert.SerializeObject(result)}"); return result; } catch (CloudBaseException ex) { // 重点:捕获超时错误并降级 if (ex.Code == "FUNCTION_INVOKE_TIMEOUT") { Debug.LogWarning("Cloud function timeout, fallback to local cache"); // 此处可写入本地PlayerPrefs临时缓存 } throw; } }

注意:CloudBase.CallFunction返回的是Task<CloudFunctionResult>,不是Task<string>。很多开发者因类型错误导致解析失败,建议用JsonConvert.DeserializeObject<T>手动解析。

3.4 那些文档里绝不会写的实战经验

  • 经验1:冷启动延迟的应对策略
    云函数首次调用会有300~800ms冷启动(Node.js 16环境实测均值520ms)。我们的解决方案是在游戏主界面加载时,提前空跑一次updateUserState(传空参数),让函数实例热起来。实测后首屏数据加载延迟从1200ms降至450ms。

  • 经验2:错误码的精准识别
    抖音云函数错误码极其混乱:FUNCTION_INVOKE_TIMEOUT(超时)、FUNCTION_EXECUTION_TIMEOUT(执行超时)、DATABASE_REQUEST_TIMEOUT(数据库超时)... 我们在Unity端统一做了映射表,对FUNCTION_*_TIMEOUT全部视为网络问题,触发本地缓存+重试机制;对DATABASE_*错误则弹窗提示“数据保存失败,请检查网络”。

  • 经验3:日志调试的黄金法则
    不要用console.log()打大量日志(会拖慢函数),而是在关键分支用console.error()输出结构化错误。我们在生产环境只保留console.error(),并在云函数后台开启“日志投递到CLS”,用腾讯云日志服务做关键词过滤(如搜索openid: oABC123快速定位单用户全流程)。

4. 用户登录与数据同步:OAuth2.0不是流程,是信任链起点

4.1 为什么wx.login()必须放在游戏启动的第一帧?

很多团队把登录做成“点击按钮后弹窗”,结果用户第一次打开游戏时,Unity还没初始化完,wx.login()回调就丢失了。抖音小游戏的OAuth2.0流程要求:wx.login()必须在页面可见后立即调用,且其返回的code必须在5分钟内换取access_token。而Unity WebGL的启动流程是:HTML加载 → Unity Loader初始化 → WebGL Context创建 → C# Main()执行,这个过程在低端机上可能长达4秒。

我们的解决方案是:在index.html<body>底部插入原生JS登录逻辑:

<script> // 在Unity加载前就获取登录态 function initWechatAuth() { if (typeof wx !== 'undefined') { wx.login({ success: (res) => { // 将code存入localStorage,Unity启动后读取 localStorage.setItem('wechat_code', res.code); }, fail: (err) => { console.error('wx.login failed:', err); } }); } } // 页面加载完成后立即执行 document.addEventListener('DOMContentLoaded', initWechatAuth); </script>

然后在Unity的Awake()中读取:

// Unity C# private void Awake() { // 从localStorage读取code(需在WebGL Build Settings中勾选"Use WebGL Template") string code = Application.ExternalEval("localStorage.getItem('wechat_code')"); if (!string.IsNullOrEmpty(code)) { StartCoroutine(ExchangeCodeForOpenid(code)); } }

提示:Application.ExternalEval在Unity 2021.3+版本中已被标记为Obsolete,但抖音小游戏环境仍需使用。替代方案是用UnityWebRequest调用自定义JS插件,但会增加包体——我们选择接受Obsolete警告,因为稳定性优先。

4.2 OpenID不是终点,是数据同步的起点

拿到OpenID后,很多开发者直接用它去查user_states集合,却发现文档不存在。这是因为:OpenID只是身份凭证,不代表用户数据已初始化。我们必须在首次登录时,自动创建用户三件套:

  1. user_profiles文档(含昵称、头像)
  2. user_states文档(含初始等级、金币)
  3. user_actions首条记录(type: "first_login"

这个初始化逻辑不能放在Unity端(易被篡改),必须由云函数保障原子性。我们创建了initUserOnFirstLogin函数:

// 云函数:initUserOnFirstLogin exports.main = async (event, context) => { const app = cloudBase.init({ env: cloudBase.SYMBOL_CURRENT_ENV }); const db = app.database(); const openid = context.openid; // 使用事务确保三张表初始化原子性 const transaction = await db.startTransaction(); try { // 1. 检查user_profiles是否存在 const profileRes = await transaction .collection('user_profiles') .doc(openid) .get(); if (profileRes.data.length > 0) { // 已存在,直接返回 await transaction.commit(); return { code: 0, message: 'already exists' }; } // 2. 创建三张表记录(全部用openid作为_docid) await transaction.collection('user_profiles').doc(openid).set({ data: { nickname: event.nickname || '玩家' + Math.floor(Math.random() * 1000), avatarUrl: event.avatarUrl || '', createdAt: db.serverDate(), } }); await transaction.collection('user_states').doc(openid).set({ data: { level: 1, exp: 0, gold: 100, stamina: 100, createdAt: db.serverDate(), } }); await transaction.collection('user_actions').add({ data: { type: 'first_login', timestamp: db.serverDate(), extra: event.extra || {}, } }); await transaction.commit(); return { code: 0, message: 'init success' }; } catch (err) { await transaction.rollback(); throw err; } };

注意:云开发事务(transaction)目前仅支持单环境内,且最大操作数为10。我们这里只做3次写入,完全在安全范围内。

4.3 数据同步的终极方案:差分更新 + 本地缓存

即使完成了初始化,用户在弱网环境下仍会遇到数据不同步问题。我们的最终方案是:在Unity端维护一份轻量级本地缓存,并与云数据库做差分同步

具体实现:

  • 定义LocalUserData类,只存levelgoldstamina三个高频字段;
  • 每次调用云函数成功后,更新本地缓存并记录lastSyncTime;
  • 进入游戏时,先加载本地缓存显示初始状态,再异步调用getUserState云函数拉取最新数据;
  • 若网络失败,则继续使用本地缓存(标注“数据可能滞后”);
  • 若云端数据与本地差异过大(如等级差>5),则强制刷新UI并播放提示动画。
// 本地缓存管理器 public class UserDataCache : MonoBehaviour { private static UserDataCache _instance; public static UserDataCache Instance => _instance ??= FindObjectOfType<UserDataCache>(); [System.Serializable] public class LocalState { public int level = 1; public int gold = 100; public int stamina = 100; public long lastSyncTime; // Unix timestamp } private LocalState _cache; public void LoadFromPlayerPrefs() { string json = PlayerPrefs.GetString("user_state", ""); _cache = string.IsNullOrEmpty(json) ? new LocalState() : JsonUtility.FromJson<LocalState>(json); } public void SaveToPlayerPrefs() { _cache.lastSyncTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); PlayerPrefs.SetString("user_state", JsonUtility.ToJson(_cache)); PlayerPrefs.Save(); } // 同步完成后的回调 public void OnCloudSyncSuccess(int level, int gold, int stamina) { _cache.level = level; _cache.gold = gold; _cache.stamina = stamina; SaveToPlayerPrefs(); } }

这个方案让我们的游戏在2G网络下仍能保持流畅体验——用户看到的是“秒进游戏”,背后是本地缓存兜底+后台静默同步的双重保障。

5. 上线前必做的五项压力测试与监控

5.1 测试清单:用真实数据模拟万级并发

文档里不会告诉你,抖音小游戏的流量峰值往往出现在活动开始后30秒内。我们上线前做了五项关键测试,全部用真实设备+真机抓包验证:

测试项方法合格标准失败案例
登录链路压测用JMeter模拟1000并发调用wx.login()+云函数initUserOnFirstLogin平均响应<800ms,错误率<0.5%某次因user_profiles未建索引,where nickname查询拖慢整体,错误率达12%
状态更新压测Unity脚本循环调用updateUserState(每秒5次/用户,模拟100用户)单函数平均耗时<300ms,无超时Node.js 12环境因GC频繁,超时率飙升至8%,升到16后降至0.2%
排行榜聚合压测云函数getTopPlayers执行aggregate().match().sort().limit(10)耗时<1200ms初始未对level建索引,耗时达4.2秒,加索引后降至380ms
弱网模拟测试Chrome DevTools设为“Fast 3G”+1000ms延迟+5%丢包本地缓存正常加载,UI无卡顿未实现CloudBase.CallFunction超时重试,导致部分用户卡在加载页
断网恢复测试游戏运行中关闭WiFi,操作10次状态更新,再恢复网络所有操作在联网后1秒内批量同步成功本地缓存未加时间戳,导致旧数据覆盖新数据

5.2 监控埋点:不只是看QPS,要看“用户感知延迟”

我们在云函数中埋了两层监控:

第一层:平台级监控(抖音后台)

  • 开启“云函数调用监控”,重点关注P95延迟超时率
  • 设置告警:当P95延迟 > 1000ms超时率 > 1%时,企业微信机器人推送告警。

第二层:业务级监控(自建)
在Unity中统计关键路径耗时:

// 统计从点击登录到数据显示的总耗时 private float _loginStartTime; private void OnLoginButtonClick() { _loginStartTime = Time.realtimeSinceStartup; StartCoroutine(LoginFlow()); } private IEnumerator LoginFlow() { yield return StartCoroutine(DoWxLogin()); // 调用wx.login() yield return StartCoroutine(InitUserOnFirstLogin()); // 调用云函数 float totalDelay = Time.realtimeSinceStartup - _loginStartTime; // 上报到自建监控系统:{"event":"login_total_delay","value":totalDelay,"version":"1.2.0"} }

注意:上报监控数据必须用UnityWebRequest.Post而非CloudBase.CallFunction,避免监控数据本身影响主业务链路。

5.3 最后一道防线:降级开关与灰度发布

我们在线上环境部署了两个关键开关:

  • 云函数降级开关:在Redis中存一个feature:cloud_function_enabled,值为true/false。云函数入口处先查Redis,若为false则直接返回{code:0, message:"degraded"},Unity端收到后启用纯本地模式(所有数据存PlayerPrefs)。
  • 灰度发布开关:按用户OpenID哈希值分流,前1%用户走新云函数,其余走旧逻辑。这样即使新函数有Bug,也只影响极小部分用户。

这两个开关都通过抖音后台的“环境变量”注入,无需重新部署函数,5分钟内可完成全量回滚。

我在实际项目中踩过最痛的坑,是某次更新云函数后忘记清空本地缓存的旧字段,导致新老数据结构冲突,连续三天用户投诉“等级显示错乱”。后来我们强制规定:每次云函数Schema变更,必须同步更新Unity端的LocalUserData类,并在OnEnable()中执行版本校验。现在这套流程已沉淀为团队SOP,上线前 checklist 第一条就是:“确认本地缓存版本号与云函数Schema版本号一致”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/22 2:44:01

Gemini 3.5 Flash 深度评测:性能解析与高效接入实践

近期&#xff0c;Google推出的Gemini 3.5 Flash模型以其“前沿性能与轻量级成本”的定位引发了广泛关注。实测数据显示&#xff0c;其在编程基准测试&#xff08;Terminal-bench 2.1达76.2%&#xff09;上超越了自家Pro版本&#xff0c;并在多步骤Agent任务&#xff08;MCP Atl…

作者头像 李华
网站建设 2026/5/22 2:42:56

.NET认证核武器:JWT+OAuth+RBAC+零信任实战体系

1. 这不是又一个“登录功能”教程&#xff0c;而是一套能扛住真实生产压测的认证防线你有没有遇到过这样的场景&#xff1a;项目上线前夜&#xff0c;安全团队突然甩来一份《高危漏洞通报》&#xff0c;指出“用户身份校验逻辑存在越权访问风险”&#xff0c;要求48小时内闭环&…

作者头像 李华
网站建设 2026/5/22 2:41:54

UE5 BaseEngine.ini 配置源码级解析:从.ini文件到运行时架构

1. 为什么一个.ini文件值得花三天逐行精读——UE5配置管理的“隐形操作系统” 很多人第一次打开 BaseEngine.ini &#xff0c;看到满屏的 [/Script/Engine.Engine] 、 bUseFixedFrameRate 、 MaxFPS60 &#xff0c;下意识觉得&#xff1a;“不就是个配置文件嘛&#xf…

作者头像 李华
网站建设 2026/5/22 2:41:54

UE5 BaseEngine.ini深度解析:引擎启动固件与配置原理

1. 为什么一个ini文件值得花三天逐行精读&#xff1f;在UE5项目上线前的最后两周&#xff0c;我们团队遭遇了一次典型的“配置幽灵问题”&#xff1a;同一套C代码&#xff0c;在开发机上运行流畅&#xff0c;打包后的Shipping版本却在特定机型上频繁卡顿&#xff0c;GPU占用率飙…

作者头像 李华
网站建设 2026/5/22 2:39:18

【Kafka笔记】(三)常用命令整理

查看所有主题 kafka-topics.sh --list --bootstrap-server 127.0.0.1:9092创建主题 kafka-topics.sh --create --topic test_topic --bootstrap-server 127.0.0.1:9092 --partitions 3 --replication-factor 1partitions&#xff1a;分区数replication-factor&#xff1a;副本数…

作者头像 李华