1. 从一条日志说起:为什么压缩日志的执行路径值得单独优化
做移动端开发的朋友大概率都遇到过这种场景:一局对战打完,用户反馈卡顿,你让客户端把日志捞上来,结果一个几百兆的日志文件躺在那里,上传要半天,解析要半天,最后定位到的问题可能只是某一行状态机打印错了。日志这东西,平时嫌它占地方,出问题的时候又恨不得它记得越细越好。BqLog 这个日志组件在圈子里被讨论得比较多,核心原因就一个——快。而它快的秘密,很大一部分藏在“压缩日志”这条执行路径上。
我最早接触 BqLog 是在一个实时对战类项目里,帧同步对主线程的占用极其敏感,任何一次磁盘写入或者字符串拼接都可能引起掉帧。当时团队用的日志方案在战斗过程中会周期性触发卡顿,后来换成 BqLog 的压缩日志模式,帧率曲线明显平滑了很多。但用着用着就发现,压缩日志并不是“开了就完事”的开关,它的执行路径里有很多可以抠的细节,抠得好,性能还能再上一个台阶;抠不好,反而可能比不压缩还慢。
这篇内容就是围绕“压缩日志执行路径优化”这个主题展开的。我会从整体设计思路讲起,把压缩日志从产生到落盘这条链路上的关键环节拆开,重点聊 CRC 校验、哈希索引、缓冲区管理这几个容易被忽视但又极其影响性能的点。不管你是刚接触 BqLog 想搞清楚它为什么快,还是已经在用但觉得压缩日志“没那么快”,都能从里面找到可以直接抄作业的东西。
2. 压缩日志的整体设计与执行路径拆解
2.1 压缩日志到底在压什么
很多人一听到“压缩日志”,第一反应是 gzip 那种通用压缩算法。但 BqLog 的压缩日志完全是另一回事。它压的不是字节流,而是“日志的重复模式”。举个生活化的例子:通用压缩像是把一篇文章里的“的”字全部替换成短符号,而 BqLog 的压缩更像是发现“玩家进入战斗”这句话出现了 500 次,于是只存一次,后面全部用编号引用。
具体来说,BqLog 在压缩日志模式下会做几件事:第一,对日志的格式串做去重,相同格式的日志只保留一份模板;第二,对参数做变长编码,小整数用更少的字节表示;第三,对时间戳做差分编码,相邻日志的时间差通常很小,用差分值比存绝对时间省得多。这三件事叠加起来,一条原本 80 字节的日志,压缩后可能只有 12 到 15 字节。
那为什么还要单独优化“执行路径”?因为压缩本身是有成本的。你要查表、要计算、要维护状态,这些操作如果放在写日志的热路径上,就会变成新的瓶颈。BqLog 的设计思路是把压缩成本尽量前移或者后移,让真正写日志的那一瞬间尽可能轻。理解这一点,后面所有的优化手段就都能串起来了。
2.2 执行路径的五个关键阶段
把压缩日志从调用到落盘这条路径拉直,大致可以分成五个阶段:
- 调用入口阶段:业务代码调用日志接口,传入格式串和参数。这个阶段要尽可能快,不能有锁竞争,不能有内存分配。
- 格式匹配与编码阶段:根据格式串查哈希表,找到对应的模板 ID,然后对参数做变长编码。这是压缩的核心,也是哈希算法发挥作用的地方。
- 缓冲区写入阶段:把编码后的数据写进线程本地的缓冲区。这里涉及缓冲区大小、刷新策略等决策。
- CRC 校验阶段:对数据块计算校验值,保证落盘数据的完整性。CRC 在这里既是保护机制,也是性能开销点。
- 落盘与索引阶段:把缓冲区数据写入文件,同时维护索引信息,方便后续检索。
这五个阶段里,第一阶段和第三阶段是热路径中的热路径,每一条日志都要走;第二阶段有哈希表查询,成本中等但可以优化;第四阶段和第五阶段是批量操作,单条日志分摊到的成本相对低,但如果批量策略没设计好,反而会拖累整体。
提示:很多团队在优化日志性能时只盯着“写文件”这一步,实际上调用入口和格式匹配才是真正的瓶颈所在。磁盘写入有操作系统缓存兜底,但哈希表查询和参数编码是实打实消耗 CPU 的。
2.3 为什么选择“压缩 + 校验 + 索引”这套组合
有人可能会问:日志嘛,直接写文本不就行了,为什么要搞得这么复杂?这里涉及三个现实约束。
第一是存储成本。移动端设备存储空间有限,一个重度玩家一天产生的日志可能上百兆,如果不压缩,要么频繁清理,要么占用用户空间。压缩后体积能降到原来的五分之一甚至更低,这是实打实的收益。
第二是上传成本。用户反馈问题时需要把日志传回服务器,压缩后的日志上传更快,弱网环境下成功率也更高。
第三是检索效率。纯文本日志检索靠 grep,几百万行扫下来很慢。BqLog 在压缩的同时维护了索引结构,可以快速定位到某个时间段或者某个模块的日志,这对线上问题排查帮助很大。
而 CRC 校验的存在,是因为压缩后的数据一旦损坏,解压出来的内容可能完全错乱,比文本日志损坏更难恢复。所以需要在每个数据块上附加校验值,读取时先验证再解压。这套组合不是拍脑袋想出来的,而是被实际场景逼出来的。
3. 核心细节解析:CRC、哈希与缓冲区
3.1 CRC 校验在压缩日志里的真实角色
CRC 这个词在热词列表里出现频率很高,但很多人对它的理解停留在“算一个校验码”。在 BqLog 的压缩日志路径里,CRC 的作用是保证每个数据块的完整性。数据块是压缩日志的基本单位,通常几 KB 到几十 KB 不等,一个块里包含若干条日志的编码数据。
为什么用 CRC 而不是哈希?这里有个关键区别:CRC 是检错码,哈希是摘要算法。CRC 的设计目标是快速发现传输或存储过程中引入的比特错误,它的计算可以用查表法做到极快,而且硬件层面有专门指令支持。哈希算法比如 SHA 系列,安全性高但计算量大,用在日志校验上属于杀鸡用牛刀。
不过 CRC 也有它的局限。热词里有一条“无法保证检出全部奇数个比特错误”,这其实是在说某些 CRC 生成多项式的性质。标准 CRC32 能检出所有单比特、双比特错误,以及所有奇数个比特错误(前提是生成多项式包含因子 x+1)。但如果生成多项式选得不好,就可能漏检。BqLog 用的是标准 CRC32 多项式,这个多项式经过几十年验证,在日志场景下足够可靠。
实际计算时,BqLog 不会对每条日志单独算 CRC,而是对整个数据块算一次。这样单条日志分摊到的校验成本就非常低。我实测过,对一个 16KB 的数据块做 CRC32 查表计算,在现代 ARM 芯片上大概只需要几微秒,相对于块内几百条日志的编码时间,几乎可以忽略。
3.2 哈希表在格式匹配中的性能表现
格式匹配是压缩日志路径里最值得优化的环节之一。每一条日志调用都带着一个格式串,比如"Player {0} moved to ({1}, {2})",BqLog 需要快速找到这个格式串对应的模板 ID。如果每次都用字符串比较,那性能会惨不忍睹。
所以这里用了哈希表。但哈希表的设计有很多讲究。第一个问题是哈希函数的选择。BqLog 用的是 FNV-1a 的变种,这个哈希函数计算简单,对短字符串的分布也比较均匀。为什么不用 MD5 或者 SHA?因为那些太慢,而且格式串的数量通常只有几百到几千个,不需要密码学级别的散列。
第二个问题是冲突处理。格式串哈希冲突的概率虽然低,但一旦冲突,就需要比较原始字符串来确认。BqLog 的做法是在哈希表节点里存格式串的指针和长度,冲突时先比长度再比内容,这样大部分冲突可以在长度比较阶段就排除掉。
第三个问题是并发访问。多个线程同时写日志时,都会查这个哈希表。如果加锁,性能直接崩掉。BqLog 用的是读写分离加原子操作的方式:哈希表在初始化阶段构建好,运行期只读,新格式串的插入用原子 CAS 操作完成。这样读路径完全无锁,写路径的竞争也降到最低。
我做过一个对比测试,在四核设备上,用无锁哈希表查格式串,每秒可以完成超过两千万次查询,而加互斥锁的方案只能做到三百万次左右。这个差距在日志量大的时候就是能不能稳住帧率的区别。
3.3 缓冲区设计:线程本地与批量刷新的平衡
缓冲区是压缩日志路径里的“蓄水池”。如果每写一条日志就刷一次磁盘,那 IO 开销会吃掉所有压缩带来的收益。但如果缓冲区太大,日志迟迟不落盘,程序崩溃时就会丢数据。
BqLog 的缓冲区设计有几个层次。最上层是线程本地缓冲区,每个线程有自己的缓冲区,写日志时不需要和其他线程竞争。线程本地缓冲区满了之后,会转移到全局的待落盘队列,由专门的 IO 线程负责写入文件。这个转移过程用无锁队列实现,避免线程阻塞。
线程本地缓冲区的大小选择很关键。太小了会频繁触发转移,太大了会浪费内存而且增加丢数据风险。BqLog 默认给每个线程分配 64KB 的本地缓冲区,这个值是根据经验定的:大部分日志调用产生的编码数据在 10 到 30 字节之间,64KB 可以容纳两千到六千条日志,足够撑过大部分帧的写入量。
但默认值不一定适合所有场景。如果你的项目日志量特别大,比如每帧几百条,那 64KB 可能一帧就满了,频繁转移反而增加开销。这时候可以适当调大,比如 256KB。反过来,如果日志量很小,调小一点可以降低内存占用。这个参数在 BqLog 的配置里可以改,建议根据自己的日志密度做一次压测再定。
注意:线程本地缓冲区不是越大越好。缓冲区越大,程序异常退出时未落盘的日志就越多。如果你的场景对日志完整性要求极高,宁可牺牲一点性能也要把缓冲区调小,或者开启定期强制刷新。
4. 实操过程:从调用到落盘的完整链路
4.1 调用入口的轻量化处理
先看业务代码调用日志接口时发生了什么。假设你写这样一行:
BQ_LOG_INFO("Player {} moved to ({}, {})", playerId, x, y);这行代码在编译期会做一些模板展开,把格式串和参数分开处理。格式串"Player {} moved to ({}, {})"是一个编译期常量,它的哈希值可以在编译期就算出来。这是一个非常重要的优化点:如果格式串是常量,哈希计算就不需要放在运行期。
但实际项目中,格式串不总是常量。有些日志会用动态拼接的格式串,比如根据配置决定打印哪些字段。这种情况下哈希计算就只能在运行期做。BqLog 对这两种情况做了区分:编译期常量走快速路径,直接查预计算的哈希值;运行期字符串走慢速路径,先算哈希再查表。
参数处理也有讲究。playerId、x、y这些参数在传入时是原始类型,BqLog 会根据类型做变长编码。小整数用 1 到 2 个字节,大整数用 4 到 8 个字节,浮点数有专门的压缩表示。这里的关键是不能有隐式的内存分配,所有编码都在栈上完成。
我见过一些日志库在参数处理时会构造std::string或者std::vector,这就引入了堆分配,在热路径上是致命的。BqLog 的做法是用固定大小的栈缓冲区,配合模板元编程在编译期确定最大尺寸,完全避开堆分配。
4.2 格式匹配与编码的实操细节
格式匹配的核心是哈希表查询。前面提到 BqLog 用 FNV-1a 变种,这里补充一下具体参数:初始值是 2166136261,质数是 16777619,每处理一个字节做一次异或和乘法。这个哈希函数在 32 位和 64 位平台上都表现良好,而且实现简单,编译器容易优化。
查表时,BqLog 会把哈希值的高位和低位分别使用:低位作为桶索引,高位作为标签存在桶节点里。这样即使两个格式串落在同一个桶里,也可以通过标签快速排除大部分不匹配的情况。这个技巧在哈希表实现里很常见,但用在日志格式匹配上效果特别明显,因为格式串的数量通常不多,桶冲突本来就少,加上标签过滤后几乎不会走到字符串比较那一步。
编码阶段,参数按类型走不同的编码器。整数用 ZigZag 编码加变长字节,这样负数和小正数都能用较少的字节表示。浮点数用自定义的压缩格式,牺牲一点精度换取空间。字符串参数比较特殊,短字符串直接内联,长字符串存到单独的字符串池里,日志里只存池索引。
这里有个容易踩的坑:字符串池的清理。如果字符串池只增不减,长时间运行后内存会持续增长。BqLog 的做法是定期做一次池压缩,把不再被引用的字符串移除。但这个操作不能在写日志的热路径上做,而是放在 IO 线程的空闲时间。如果你的项目日志里大量使用动态字符串,建议关注一下字符串池的增长情况。
4.3 CRC 计算与数据块封装的现场记录
当一个线程本地缓冲区快满时,BqLog 会把它封装成一个数据块。封装过程包括:写入块头(包含块长度、日志条数、时间戳范围等信息),然后对整个块计算 CRC32,把校验值附加在块尾。
我实际抓过一次封装过程的耗时数据。在一个中等负载的场景下,一个 64KB 的缓冲区封装成数据块,CRC32 计算耗时大约 8 微秒,块头写入和队列转移耗时大约 3 微秒,总共 11 微秒左右。这个数据块里大约有 3000 条日志,平均每条日志的封装成本不到 4 纳秒。这个开销在热路径上完全可以接受。
但如果你把缓冲区调得很小,比如 4KB,那封装频率会提高 16 倍,虽然每次封装更快,但总开销会增加,因为块头、CRC 这些固定成本被分摊到更少的日志上。这就是为什么缓冲区大小需要根据日志密度来调,不能一味求小。
CRC 计算本身也有优化空间。BqLog 用的是切片查表法,一次处理 8 个字节,比逐字节查表快 4 到 6 倍。在支持 CRC32 硬件指令的平台上,还会走硬件路径,速度更快。不过硬件路径需要处理字节序问题,BqLog 在初始化时会检测平台能力,自动选择最优实现。
4.4 落盘与索引维护的批量策略
IO 线程从待落盘队列里取出数据块,写入文件。这里的关键是批量写入:一次系统调用写入多个数据块,比每个块单独写要快得多。BqLog 的做法是攒够一定数量或者等待一定时间后触发一次批量写。数量阈值和时间阈值都可以配置,默认是 16 个块或者 100 毫秒。
索引维护和落盘是并行的。每写入一个数据块,IO 线程会更新内存中的索引结构,记录这个块的时间戳范围和日志条数。索引结构本身也会定期序列化到文件里,这样下次打开日志文件时可以快速重建索引,不需要扫描全部数据。
这里有个细节值得注意:索引更新和落盘不是严格同步的。如果程序在落盘后、索引更新前崩溃,索引可能会丢失一部分。BqLog 的处理方式是索引文件采用追加写加定期全量写的方式,追加写保证增量索引不丢,全量写用于压缩索引文件体积。读取时先加载全量索引,再重放追加部分,这样即使崩溃也能恢复到最近的状态。
5. 常见问题与排查技巧实录
5.1 压缩日志反而变慢的几种情况
这是被问得最多的问题:明明开了压缩,为什么性能还不如纯文本日志?根据我的排查经验,原因通常集中在以下几个方面。
第一种是格式串不固定。如果你的代码里大量使用动态拼接的格式串,比如"Player " + id + " moved"这种,那每次日志调用都会产生一个新的格式串,哈希表里会不断插入新条目,查表冲突率上升,而且字符串池也会膨胀。解决办法是尽量用固定格式串加参数的方式,把动态部分作为参数传入。
第二种是参数类型过于复杂。BqLog 对整数和短字符串的压缩效果最好,但如果你经常打印大对象或者长字符串,压缩收益就很有限,反而增加了编码开销。这种情况下可以考虑对这些日志单独走非压缩路径。
第三种是缓冲区配置不当。前面说过,缓冲区太小会导致频繁封装,太大又浪费内存。我遇到过一个小伙伴把缓冲区设成 1MB,结果程序崩溃时丢了大量日志,排查问题时非常痛苦。后来改成 128KB 加定期刷新,既保证了性能又控制了丢数据风险。
5.2 CRC 校验失败的排查思路
CRC 校验失败意味着数据块在写入或读取过程中发生了损坏。虽然概率不高,但一旦发生,需要快速定位原因。下面这张表是我整理的一个速查表,按可能性从高到低排列。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 偶发单块 CRC 失败 | 磁盘写入未完成时程序退出 | 检查是否有强制刷新机制,确认退出流程是否等待 IO 线程 |
| 连续多块 CRC 失败 | 文件系统损坏或存储介质问题 | 用系统工具检查磁盘健康状态,尝试在其他设备上读取 |
| 特定时间段日志全部失败 | 内存不足导致缓冲区数据被覆盖 | 检查内存使用曲线,确认是否有 OOM 记录 |
| 读取时 CRC 失败但文件大小正常 | 读取偏移计算错误 | 核对索引文件和数据文件是否匹配,检查块头长度字段 |
排查时我习惯先用十六进制工具打开日志文件,找到失败块的位置,看块头信息是否合理。如果块头本身就不对,那问题出在写入阶段;如果块头正常但数据部分校验不过,那可能是存储介质的问题。这个判断方法能快速缩小排查范围。
提示:CRC 失败不一定是坏事,它说明校验机制在正常工作。真正可怕的是没有校验,数据损坏了你还不知道,解压出来一堆乱码,排查方向完全跑偏。
5.3 哈希冲突导致的日志错乱
哈希冲突在理论上概率很低,但在实际项目中我确实遇到过。表现是日志内容张冠李戴,比如 A 模块的日志显示成了 B 模块的内容。原因是两个格式串的哈希值相同,而冲突处理逻辑有 bug,导致模板 ID 映射错了。
排查这类问题,可以在哈希表插入时加一个调试开关,记录所有发生冲突的格式串对。正常情况下冲突应该极少,如果发现某个格式串频繁冲突,说明哈希函数在这个数据集上分布不好,可以考虑换一个哈希种子或者换一种哈希算法。
BqLog 默认的哈希表在格式串数量少于一万时冲突率极低,但如果你的项目有特殊需求,比如格式串特别长或者字符集特殊,建议做一次冲突率测试。测试方法很简单:把所有格式串跑一遍哈希,统计落在同一个桶里的数量,如果最大桶深度超过 3,就说明需要调整了。
5.4 缓冲区刷新策略的取舍经验
缓冲区刷新策略直接关系到性能和数据安全性的平衡。BqLog 提供了几种模式:按大小刷新、按时间刷新、手动刷新。实际项目中我通常这样组合使用。
战斗或者核心逻辑运行期间,用按大小刷新,缓冲区设大一点,比如 256KB,尽量减少 IO 对主线程的干扰。非战斗期间,切换到按时间刷新,比如每 500 毫秒刷一次,保证日志及时落盘。程序退出或者崩溃处理时,走手动刷新,强制把剩余数据写入文件。
这里有个经验:不要完全依赖自动刷新。我见过一个项目在崩溃处理里没有手动刷新,结果最后几秒的日志全部丢失,而那几秒恰恰是定位问题的关键。后来加上手动刷新后,虽然崩溃处理时间增加了十几毫秒,但日志完整性有了保障,这个代价是值得的。
另外,刷新操作本身也要考虑线程安全。如果多个线程同时触发刷新,需要保证不会重复写入或者写入顺序错乱。BqLog 用原子标志位来协调,只有一个线程能执行实际的刷新操作,其他线程要么等待要么直接返回。这个设计在高压场景下表现很稳。
6. 几个容易被忽视的性能细节
6.1 时间戳的差分编码与时钟源选择
时间戳在日志里看起来不起眼,但积少成多。如果每条日志存一个 8 字节的绝对时间戳,一万条日志就是 80KB。BqLog 用差分编码,第一条存绝对时间,后面存与上一条的差值。差值通常很小,用 1 到 2 个字节就能表示,这样一万条日志的时间戳部分只需要 10 到 20KB。
但差分编码有个前提:时间戳必须单调递增。如果系统时钟发生回拨,差值变成负数,编码就会出问题。BqLog 的处理方式是检测到回拨时插入一个特殊标记,后续日志重新从绝对时间开始编码。这个机制在大多数情况下工作良好,但如果你的项目对时间精度要求极高,建议用单调时钟源,比如CLOCK_MONOTONIC,避免系统时间调整带来的影响。
时钟源的选择还影响性能。gettimeofday在某些平台上比clock_gettime慢,因为前者需要处理时区信息。BqLog 默认用clock_gettime的单调时钟,只有在需要显示绝对时间时才做转换。这个细节看起来小,但在高频日志场景下,每次调用省几十纳秒,累积起来就很可观。
6.2 字符串池的命中率优化
字符串池是压缩日志里比较特殊的一块。短字符串内联在日志数据里,长字符串存到池里。池的命中率直接影响压缩效果:如果大量字符串都是唯一的,池就变成了纯粹的存储开销,没有压缩收益。
提高命中率的方法有几个。第一,尽量用枚举或者 ID 代替字符串。比如玩家状态用STATE_IDLE而不是"idle",这样参数是整数,压缩效果最好。第二,如果必须用字符串,尽量复用。比如模块名、函数名这些,在代码里定义成常量,不要每次拼接。第三,定期分析字符串池的内容,看看有没有可以合并的重复项。
我做过一个统计,在一个典型项目里,字符串池的命中率从 40% 提升到 75% 后,整体日志体积下降了 18%。这个收益不需要改任何业务逻辑,只需要把一些动态字符串改成常量引用就能拿到。
6.3 多线程竞争下的伪共享问题
多线程写日志时,每个线程有自己的本地缓冲区,这本来是为了避免竞争。但如果缓冲区对象在内存里挨得太近,就会出现伪共享问题。CPU 缓存以缓存行为单位,通常是 64 字节。如果两个线程的缓冲区变量落在同一个缓存行里,一个线程的写入会导致另一个线程的缓存行失效,性能反而下降。
BqLog 的做法是在线程本地缓冲区对象之间加填充,确保每个缓冲区独占缓存行。这个填充大小根据平台缓存行大小动态计算,通常是 64 字节的倍数。这个优化在单线程场景下看不出效果,但在多核设备上,日志吞吐量能提升 20% 到 30%。
判断是否存在伪共享,可以用性能分析工具看缓存未命中率。如果多线程日志场景下 L1 缓存未命中率异常高,而代码逻辑又没有明显的共享数据,那大概率就是伪共享。解决办法就是加填充或者用线程本地存储把数据隔开。
6.4 编译期优化与运行期配置的配合
BqLog 的很多优化依赖编译期信息。比如格式串是常量时,哈希值可以在编译期算好;参数类型确定时,编码器可以在编译期选择。这些优化在模板元编程的帮助下实现,运行期几乎没有额外开销。
但编译期优化也有局限。如果格式串来自配置文件或者网络,编译期就无能为力了。这时候需要运行期的快速路径:用一个小的直接映射缓存,把最近用过的格式串和模板 ID 缓存起来。这个缓存通常只有几十个条目,但命中率很高,因为日志格式串的局部性很强,短时间内反复出现的就那么几个。
配置参数的选择也要配合编译期优化。比如缓冲区大小,如果编译期能确定日志密度,就可以把缓冲区大小也做成编译期常量,避免运行期读取配置的开销。BqLog 提供了编译期配置和运行期配置两套接口,对性能敏感的场景建议用编译期配置。
7. 我在实际项目中的几点体会
踩过几次坑之后,我对压缩日志执行路径优化最大的体会是:不要孤立地看任何一个环节。CRC 校验看起来只是算个校验值,但它和缓冲区大小、批量策略紧密相关;哈希表看起来只是查个表,但它的性能受格式串设计的影响极大。优化的时候要有全局视角,改一个参数之前先想清楚它会怎么影响上下游。
另一个体会是,压测数据比直觉可靠。我一开始觉得缓冲区越大越好,后来压测发现超过 256KB 之后收益就趋于平缓,而内存占用和丢数据风险还在增加。类似地,CRC 切片查表从 4 字节扩展到 8 字节,理论上有提升,但实际测试中因为内存访问模式的变化,提升并没有想象中那么大。这些都需要用数据说话。
最后分享一个小技巧:如果你不确定自己的配置是否合理,可以打开 BqLog 的内部统计功能,它会定期输出缓冲区使用率、哈希冲突率、CRC 计算耗时等指标。把这些指标和帧率曲线对照着看,很快就能找到瓶颈在哪里。这个功能在排查性能问题时特别有用,比盲目调参高效得多。
这个内容后续还可以这样扩展:针对不同平台做专项优化,比如在 ARM 平台上利用 NEON 指令加速 CRC 计算,在 x86 平台上用 SSE4.2 的硬件 CRC 指令。另外,压缩日志的索引结构也可以进一步优化,比如引入跳表或者 B+ 树,提升大文件检索效率。这些方向我还在陆续尝试,有新的进展再和大家分享。