本文翻译自 《Apache BookKeeper Insights Part 2 — Closing Ledgers Safely》。原文链接:https://medium.com/splunk-maas/apache-bookkeeper-insights-part-2-closing-ledgers-safely-386a399d0524,作者 Jack Vanlightly。校对与整理:StreamNative

译者简介

邱峰 @360 技术中台基础架构部中间件产品线成员,主要负责 Pulsar、Kafka 及周边配套服务的开发与维护工作。

在上一篇文章中,我们了解到 BookKeeper 副本机制是如何通过无状态的客户端实现的,而不是有状态的服务端节点。在这篇文章中,我们将介绍复制协议如何处理客户端的失败情况,避免脑裂和一些容易被忽视但较棘手的问题。

建议先阅读第一篇文章,从架构层面了解 BookKeeper 复制协议。在这篇文章中,我们将深入研究协议中特定的部分。

协议的某些方面是必要的,这是因为协议设计的 BookKeeper 采用分布式日志分段(ledger)实现,而不是无界日志(例如 Raft / Kafka)。BookKeeper 的 Ledger 并非必须要如此设计,由于各种原因才会有这样的选择。任何事情都一样,这种设计也是一种权衡。

在研究有趣和比较难的部分之前,我们需要了解 Ledger 的生命周期,以及它如何组织形成无界日志。

Ledger ——日志分段

每个 Ledger 都有生命周期。每个客户端都可以创建 Ledger,但是只有创建 Ledger 的客户端才可以对该 Ledger 进行写操作。最好的情况是客户端 A 创建了 Ledger L1,向它写入一段时间的 entry 然后再关闭它。

一个不好的情况是客户端 A 创建 Ledger L1,并对它进行写入,然后客户端突然消失,使这个 Ledger 处于 OPEN 状态。为了让分片日志可以继续写入,必须通过另一个客户端的介入关闭该 Ledger,然后创建一个新的 Ledger,以便可以继续写入日志。

在 Apache Pulsar 中,broker 使用 ZooKeeper 来决定哪些 broker 拥有哪些 topic。任何一个给定的 topic 只能由一个 broker 持有,这个 broker 使用 BookKeeper 的客户端来创建、写入和关闭它持有的每个 topic 中的 Ledger。

图 1:Topic 是由 BookKeeper Ledger 构成的 segment log

如果 broker 宕机,那么这个 broker 上 topic 的所有权就会转移到其他 broker 上。每个获得给定 topic 所有权的 broker 会创建一个 BookKeeper 客户端来关闭该 topic 的最后一个 Ledger(由其他 broker 的 BK 客户端创建),创建一个新的 Ledger,并继续为这个 topic 提供写服务。

图 2:Broker 故障和 topic 分段

一个更糟糕的情况是,原始的客户端并没有宕机,可能由于长时间 GC 或网络分区导致客户端在一段时间内无法访问。如果一个客户端正在关闭 Ledger,而原来的客户端仍然在尝试对这个 Ledger 执行写操作,此时会发生什么呢?这就是所谓的脑裂,会导致数据丢失。

因此,我们需要一种安全的方式来让一个客户端关闭另一个客户端的 Ledger,这就是 Ledger 的恢复。当一个客户端关闭另一个客户端的 Ledger 时,均通过该机制完成。

Ledger 恢复过程

当客户端关闭自己的 Ledger 时,会将状态设置为 CLOSED,并将 LastEntryId 设置为 Last Add Confirmed (LAC),这是客户端提交的最新 entry。当该 Ledger 关闭后,其他客户端读取该 Ledger 时永远不会超过 LastEntryId。该 LastEntryId 是这个 Ledger 的末尾。

当一个客户端关闭另一个客户端的 Ledger 时,也必须要设置 LastEntryId。由于原来的客户端已消失,找到 LAC 的唯一方法就是通过存储节点(bookie)查询。

图 3:Ledger 状态

恢复数据时,客户端首先将 Ledger 元数据中的状态由 OPEN 修改为 IN_RECOVERY,接下来会查询该 Ledger 的最后一个 fragment 所对应的每个 bookie 中存储的 LAC。每个 entry 中存储的 LAC 通常落后于实际的 LAC,因此在所有 bookie 中找到最新的 LAC 仅仅是恢复的起点。可能 LAC 之后还有已提交的 entry,它们也需要被发现。

当客户端向每个 bookie 查找 LAC 时,还会在请求中设置 fencing 标志,使每个 bookie 对 Ledger 进行 fence 操作。Fencing 是局部单向幂等操作。一旦 bookie 对 Ledger 进行了 fence 操作,该 bookie 就再也不会接受对这个 Ledger 的任何写操作。如果原来的客户端还存活,这将防止其继续对该 Ledger 进行写操作。如果没有 fencing,可能会出现脑裂,即两个客户端可以同时操作同一个 Ledger,最终可能会导致数据丢失。

图 4:恢复客户端对足够多的 bookie 进行 fence 操作,如果原来的客户端仍然存活将不能操作这个 Ledger

注意:你可以在本系列文章[1]中了解更多有关 fencing 和 TLA+ 中 BookKeeper 协议验证的信息。

图 5:恢复客户端在最后一个 fragment 对应的 bookie ensemble 中找到最新的 LAC,上图中最新值为 1(滞后于原始客户端的 LAC)

了解最新的 LAC 只是第一步,现在客户端必须找到存在于这点之外(LAC)更多的 entry。客户端开始对 LAC 之外的 entry 发送读请求(同时带有 fencing 标识)。对于每个 entry,客户端必须判断这个 entry 是否可恢复。如果判断一个 entry 不可恢复,客户端会在该点停止读取,并将前一个 entry 作为 Last Entry Id。客户端会继续向前读取,直到达到它认为的最后一个可恢复的 entry。

对于成功读取到的每个 entry,客户端都会将其再次写入到 bookie ensemble。Entry 的写入操作是幂等的,不会引起重复或者排序问题。我们将在另一篇文章中了解到,为什么重写 entry 在恢复期间对于正确性很有必要。如果所有的写操作都成功(达到 AQ),那么最后一个动作就是关闭 Ledger,将状态设置为 CLOSED,LastEntryId 设置为客户端找到的最后提交的 entry。

警惕:Ledger 截断

当客户端执行恢复操作时,如果将 LastEntryId 设置的过低而导致提交的 entry 不可读,此时就会发生 Ledger 截断。这些 entry 至少在 AQ 的 bookie 磁盘中是安全的,但是因为这些 entry 现在超过了 LastEntryId,所以他们不可读,因此基本上就丢失了。

图 6:恢复客户端在 entry 1 关闭 Ledger,但是 entry 2 在之前已经提交

Ledger 截断是所有 BookKeeper 贡献者和管理员都需要警惕的事情。接下来我们将介绍一些可能发生的情况以及如何避免。理解 Ledger 截断如何发生的关键是理解 Ledger 恢复过程中如何决定哪些 entry 可恢复,哪些不可恢复。

决定 entry 可恢复的时机

在 Ledger 恢复期间,对于收到的每个恢复读响应,客户端会决定这个 entry 是否可恢复,或者需要更多的响应来决定。

注意:一个 entry 可能不可恢复,因为它不存在。恢复客户端可以继续读取,直到到达原始客户端写入的 entry。

对于任何给定的 entry,客户端都不能等待 ensemble 中每个 bookie 的响应。如果这样做就意味着,如果一个 bookie下线就无法完成 Ledger 恢复。但是,如果没有 bookie 响应,就不能关闭 Ledger,否则会导致 Ledger 截断。这个 entry 可能存在于所有的 bookie 中,但是如果没有 bookie 响应,我们只需要终止 Ledger 恢复而不是关闭 Ledger。

我们希望避免 Ledger 截断,但是又不想被较慢或不可用的 bookie 阻碍恢复流程 — Ledger 需要快速恢复,因为只要在 Ledger 恢复过程中,分段日志(segmented log)就不能继续执行。因此,很难同时兼顾 Ledger 的恢复速度和安全性。

BookKeeper 处理的方法是宽松处理 positive 结果,对会导致 Ledger 关闭的 non-positive 结果严格处理。也就是说,只要有一个恢复读操作是 positive 结果,客户端就判定该 entry 为可恢复。然而,要判定 entry 为不可恢复(并在该点关闭 Ledger),标准会设置的更高。我们不想截断 Ledger。

我们将恢复读响应分为三类:

  • • Positive(entry 在那个 bookie 上)

  • • Negative(entry 不在那个 bookie 上)

  • • Unknown(发生了错误或者超时,所以情况未知)

注意:之前的一个误解是将所有 non-positive 读视为 Negative。这不正确,可能会导致 Ledger 截断。一些响应可能是由于 bookie 过载、瞬时 I/O 异常等引起的超时、拒绝请求,这意味着 entry 可能确实存在。

Quorum Coverage(QC)

关闭 Ledger 的标准是 Quorum Coverage(QC)。我们已经在 TLA+ series[2] 系列文章中介绍了 Quorum Coverage。但是基本上我们将它定义为:

  • • 对于指定请求,从足够多的 bookie 节点收到成功响应,使得在给定集合中 Ack Quorum (AQ) 数量的 bookie 节点组成的任意组合中,都至少包含一个收到成功响应的 bookie 节点。

  • • 对于指定请求,从足够多的 bookie 节点收到成功响应,使得在给定集合中不存在 Ack Quorum (AQ) 数量的 bookie 节点没有收到成功响应。

这两个定义是等价的。

Ledger 恢复有两处使用了 Quorum Coverage:

  • • Fencing。这个属性是一个给定的 bookie 状态是 fenced,给定的集合就是当前的 ensemble。当当前 ensemble 中达到 Ack Quorum 数量的 bookie 都处于 fenced 状态时,Fencing 达到 quorum coverage,这相当于每个 AQ 中的 bookie 至少有一个是 fenced 状态。如果我们让 AQ 中的 bookie 处于 unfenced 状态,那么原来的客户端就可以继续对 Ledger 进行写操作,且不限时间(脑裂)。

  • • 决定 entry 的不可恢复性。这个属性是一个给定的 bookie 没有 entry,并且给定的集合就是 entry 的写集合(bookie 集合应该包含 entry)。如果 entry 没有在 bookie 集合的 AQ 中,那么这个 entry 不可恢复。

Quorum Coverage 的阈值可以很容易地计算成一个数字:

满足条件的 Bookie 数量 = (集合大小 - AQ)+ 1

将这个阈值应用到 fencing 会给我们一个 (E - AQ) + 1 的阈值,满足 “is fenced” 的请求。

将这个阈值应用到恢复读取操作,negative 读取阈值 = (WQ - AQ) + 1,满足 “entry does not exist” 的请求。

客户端只需计算 negative 响应的数量,如果数量达到 Quorum Coverage,则这个 entry 不可恢复。

注意:如果你想要一个更规范的 Quorum Coverage 定义,可以查看 TLA+ 规范,其中包含不同的测量 Quorum Coverage 的等价方法:

  • • https://github.com/Vanlightly/bookkeeper-tlaplus/blob/main/QuorumCoverageFencing.tla

  • • https://github.com/Vanlightly/bookkeeper-tlaplus/blob/main/QuorumCoverageRecoveryReads.tla

Positive 与 Non-positive 的处理方式:宽松与严格

一个 positive 读请求可以被看作成功的恢复读,但为了关闭已经达到第一个不可恢复的 entry 的 ledger,我们必须应用 Quorum Coverage。

在接收到每个响应时,客户端会评估 entry 是否可恢复,还是需要等待更多的响应。

示例

Positive 恢复读 #1

图 7:第二个响应为 positive,由此可将 entry 视为可恢复,若有更多响应将忽略

Negative 恢复读 #1

图 8:前两个响应为 negative,已达到 negative 的阈值

Unknown #1

图 9:没有明确的 positive 或 negative 响应,意味着我们不知道 entry 是否可恢复,所以终止恢复

等待更多的响应

图 10:两个阈值都没达到,因此要等待最后一个响应

Positive 恢复读 #2

图 10a:最后的响应到达并且为 positive,达到了我们的 positive 阈值

Negative 恢复读 #2

图 10b:最后的响应到达并且为 negative,达到了我们的 negative 阈值

Unknown #2

图 10c:最后的响应到达而阈值都没达到。在 AQ 集合中的 bookie 可能持有 entry,因此我们不能把这个读请求当做 negative。ledger 恢复要被终止,并再次尝试

如果 ledger 恢复可以完成,则所有可恢复的 entry 已经完成提交,并且已经设置 Last Entry Id。如果由于无法判断某 entry 是否可恢复而无法完成,则可以重复尝试恢复 Ledger,直到成功。一旦完成,分段日志(segmented log)就可以继续写入。

Ack Quorum 为 1 的危险性

设置 Ack Quorum 为 1 不仅危险(因为某些 entry 可能没有冗余),而且如果单个 bookie 离线可能会导致 ledger 暂停恢复。在 Ack Quorum 为 1 的情况下,只能在收到来自每个 bookie 的响应后再关闭 ledger — 超时或离线的 bookie 可能持有这个 entry!Ledger 恢复过程的停滞会导致 Pulsar Topic 不可用。

请记住,由于 BookKeeper 的动态 Ledger 成员特性,你可以设置 WQ=AQ。因此,如果只想副本因子为 2 ,那么可以设置 WQ=2 和 AQ=2。设置 AQ=1 将导致数据丢失或不可用。

注意 Ledger 截断!

我们知道什么是 ledger 截断,我们也知道 ledger 恢复如何避免这种情况,但还有一些方式会无意中允许 ledger 截断发生!BookKeeper 运维人员和 BookKeeper 贡献者同样也需要注意这点。

避免 ledger 截断的基本规则是永远不能允许 bookie 对之前确认的 entry 做出 NoSuchEntry 或 NoSuchLedger 的响应。这样做可能会导致正在恢复的 ledger 发生 ledger 截断。

示例:假如我们有一个 WQ=3,AQ=2 的 ledger。

  1. 1. 由于 ensemble 发生变化,entry 10 只能写到 b1 和 b3。

  2. 2. 继续写入并且 Last Add Confirmed 当前是 100。

  3. 3. entry 10 由于某种原因在 b1 上丢失。

  4. 4. 客户端在该 ledger 上启动恢复。当它到达 entry 10 时,它接收到的前两个响应是来自 b1 和 b2 的 NoSuchEntry 响应。已经满足 negative 阈值。

  5. 5. 恢复客户端在 entry 9 处关闭该 ledger 并截断它(丢失 91 个 entry)。

导致这个问题的原因只是一个错误的 NoSuchEntry 响应!

但是为什么确认的 entry 会消失呢?让我们看一些示例。

运维示例 1 — 禁用 Journal 的情况下运行 4.15 之前的版本

注意:在撰写本文时,我的团队正在向 BookKeeper 提交一个更改,允许在没有 journal 的情况下运行,且不会导致 ledger 截断。希望这将在 4.15 版本公开(译者注:BookKeeper 4.15 版本已发布:https://github.com/apache/bookkeeper/releases/tag/release-4.15.0)。

通过配置 journalWriteData=false 可以在没有 journal 的情况下运行。写操作被添加到 ledger storage 写缓存然后确认,完全跳过 journal。Entry 将一直保存在缓存中,直到下一个 batch 执行完刷盘操作。Bookie 崩溃可能导致已确认的 entry 丢失,因为所有未刷盘的 entry 都将丢失。

当前,请不要在没有 journal 的情况下运行。

运维示例 2 — 回退一个死掉的 bookie(并且删除了 cookie)

如果一个 bookie 磁盘出现故障,作为操作人员你可能会尝试启动一个具有相同 id 的新 bookie 和一个新的空磁盘。在这种情况下 BookKeeper cookie 会阻止 bookie 启动,但你可以使用 CLI 工具删除 ZooKeeper 中的 cookie,以允许 bookie 用空磁盘启动。你认为这些 entry 已经复制到其他 bookie 上,所以没有问题,但是你忘了 ledger 截断的可能性。

丢失的数据原本可恢复也有可能变得不可恢复。

更好的做法是,使用退役过程安全地移除死亡的 bookie,然后使用空磁盘恢复该 bookie 并将其添加到集群中。

运维示例 3 — 索引文件损坏

文件损坏可能会发生在 DbLedgerStorage 的 entry 位置和 ledger 索引。幸运的是,如果发生这种情况,有 CLI 命令可以重建这些索引。你应该在 bookie 离线或处于只读模式时运行这些命令重建。

如果在 bookie 运行时执行重建,则重建索引将不包含重建操作期间添加的 entry,这些 ledger 很有可能会发生 ledger 截断。

贡献示例 1 — 存储扩展

BookKeeper 的这项功能存在缺陷,没有考虑到 ledger 截断。它允许你添加或移除 journal 和 ledger 存储目录。BookKeeper 支持多个目录,可以挂载多个不同的磁盘,从而可以对单个 bookie 进行纵向扩缩。

每个 journal 目录对应一个 journal 实例,同样每个 ledger 目录对应一个 ledger 存储实例。ledger 读写操作通过 ledger id 路由到 journal 和 ledger 存储实例。如果更改路由,必须重写所有现有的数据,否则后续读取可能会命中错误的 ledger 存储实例。例如,从 1 个 ledger 目录扩展到 2 个会导致 50% 的 ledger 不可读。OPEN 状态的 ledger 中不可读的 entry 现在容易使 ledger 发生截断。

更好的做法是,使用退役过程安全移除 bookie,然后使用你想要的配置回退。

贡献示例 2 — 修改 ledger 路由算法

有贡献者提出了一项修改方案,在不考虑现有数据的情况下,修改 bookie 读写操作路由到 journal 和 ledger 存储实例的方式。如果采纳该方案,并且 bookie 通过修改路由得到升级,这将导致很多现有的 entry 不可读,从而容易被截断。

贡献示例 3 — 由于 entry log 文件损坏返回 NoSuchEntry 响应

文件损坏可能会导致一个或多个 entry 无法读取。在某些情况下,这会导致返回 NoSuchEntry 响应。

贡献者:知道 entry(根据索引该 entry 确实在 bookie 上)返回 NoSuchEntry 响应的影响。元数据有可能显示 entry 存在于 bookie 上,但却并不存在。这种情况是合理的,可能由于 ensemble 发生变化导致。然而,如果一个 bookie 的索引显示它在本地存在,那么它应该肯定存在。在这种情况下,返回 NoSuchEntry/Ledger 响应表明有可能会发生 ledger 截断。

除上述示例外,可能还存在其他会引起 Ledger 截断的情况,例如以各种方式修改元数据。

Bookie 退役过程

退役过程使用的是外部恢复机制,将数据从一个 bookie 迁移到另一个 bookie。它会将托管在目标 bookie 中所有的 ledger fragment 迁移到集群中的其他 bookie 上。每个 fragment 会重新复制然后更新元数据,以包括要替换的 bookie 信息。

更多关于 bookie 的退役的内容可以阅读官方文档[3]

该退役机制并不完美,因为首先会将 bookie 下线,然后将根据配置的副本因子恢复 entry。一个更安全的方法是先迁移数据,然后再下线 bookie。

最后,bookie 退役最安全的方法是将其设置为只读,然后等待数据保留策略来删除所有数据。

总结

BookKeeper 协议与 Raft 或 Kafka 复制协议等集成协议非常不同。对一种系统适用的方法可能并不通用,甚至会产生相反的效果。

因此请注意,如果要为项目贡献代码,确保对 BookKeeper 的任何更改不会导致 bookie 之前写入的 entry 返回 NoSuchEntry 或 NoSuchLedger 响应。如果运行 BookKeeper,确保不会导致之前写入的 entry 变得不可读或消失。

在本系列下一篇文章中,我们将对协议稍加修改,做一些小的改变,查看出现的故障情况。这往往也是学习技术原理的好方法。

推荐阅读

  • • 深入解析 BookKeeper 多副本协议(一)

  • • 深入解析 BookKeeper 协议模型与验证

  • • 调试 BookKeeper 协议 - 无界 Ledger

  • • 深入解析 Apache BookKeeper 系列:第四篇—背压

  • • 深入解析 Apache BookKeeper 系列:第三篇——读取原理

  • • 深入解析 Apache BookKeeper 系列:第二篇 — 写操作原理

引用链接

[1] 本系列文章: https://medium.com/splunk-maas/detecting-bugs-in-data-infrastructure-using-formal-methods-704fde527c58
[2] TLA+ series: https://medium.com/splunk-maas/a-guide-to-the-bookkeeper-replication-protocol-tla-series-part-2-29f3371fe395
[3] 官方文档: https://bookkeeper.apache.org/docs/admin/decomission


关注「Apache Pulsar」,获取干货与动态

译文推荐 | Apache BookKeeper 洞察系列(二)— 安全关闭 Ledger相关推荐

  1. 深入解析 Apache BookKeeper 系列:第四篇—背压

    本文由 StreamNative 组织翻译自<Apache BookKeeper Internals - Part 4 - Back Pressure>,作者 Jack Vanlightl ...

  2. 博文推荐|BookKeeper - Apache Pulsar 高可用 / 强一致 / 低延迟的存储实现

    本文转自:DataFunTalk,分享嘉宾:翟佳,StreamNative 联合创始人 编辑整理:张晓伟 美团点评 导读:多数读者们了解 BookKeeper 是通过 Pulsar,实际上 BookK ...

  3. 【研究】移动办公趋势洞察系列之二:人工智能、智能硬件精彩纷呈,业务协同初心不变

    作者:欧应刚  | 小编:阿软 经历了这一年的发展,中国移动办公市场到底发展的怎么样了,市场规模和竞争格局怎样,技术.产品的发展趋势是什么,目前移动办公市场应用情况如何? 这些问题是大家关心的,也是亟 ...

  4. 图机器学习(GML)图神经网络(GNN)原理和代码实现(前置学习系列二)

    图机器学习(GML)&图神经网络(GNN)原理和代码实现(PGL)[前置学习系列二] 上一个项目对图相关基础知识进行了详细讲述,下面进图GML networkx :NetworkX 是一个 P ...

  5. A.图机器学习(GML)图神经网络(GNN)原理和代码实现(前置学习系列二)

    图学习图神经网络算法专栏简介:主要实现图游走模型(DeepWalk.node2vec):图神经网络算法(GCN.GAT.GraphSage),部分进阶 GNN 模型(UniMP标签传播.ERNIESa ...

  6. 日志框架LOG4J2系列二——log4j2配置文件

    日志框架LOG4J2系列二--log4j2配置文件 一.log4j2支持的配置文件格式 二.log4j2.xml配置文件 三.log4j2.xml配置项详解 总结 上一节:日志框架LOG4J2系列一- ...

  7. 搜索引擎ElasticSearchV5.4.2系列二之ElasticSearchV5.4.2+kibanaV5.4.2+x-packV5.4.2安装

    相关博文: 搜索引擎ElasticSearchV5.4.2系列一之ES介绍 搜索引擎ElasticSearchV5.4.2系列二之ElasticSearchV5.4.2+klanaV5.4.2+x-p ...

  8. 《CDN 之我见》系列二:原理篇(缓存、安全)

    2019独角兽企业重金招聘Python工程师标准>>> <CDN之我见>共由三个篇章组成,分为原理篇.详解篇和陨坑篇.本篇章适合那些从未接触过.或仅了解一些 CDN 专业 ...

  9. Velocity魔法堂系列二:VTL语法详解

    一.前言 Velocity作为历史悠久的模板引擎不单单可以替代JSP作为Java Web的服务端网页模板引擎,而且可以作为普通文本的模板引擎来增强服务端程序文本处理能力.而且Velocity被移植到不 ...

最新文章

  1. ubuntu16 18 用着速度不错的apt源
  2. 1月第4周中美五大顶级域名总量涨幅相近 均有5.4万个
  3. linux笔记之 开机服务启动的控制,系统日志的查看,防火墙的关闭
  4. String ,StringBuilder,StringBuffer
  5. 告别花瓶:2015年智能电视路在何方?
  6. 翻车实录之Nature Medicine新冠单细胞文献|附全代码
  7. php 取得文件行数,PHP获取文件行数的方法
  8. (101)FPGA面试题-Verilog设计偶校验位
  9. 家里无线网络每天不定时段出现网速很慢或者直接无连接,这是怎么回事?
  10. 已收藏!java自学网址
  11. R语言ETL工程:连接(join)
  12. 教你用 Python 爬取 Baidu 文库全格式文档!
  13. 关于C程序设计谭浩强第五版考研学习心得的分享
  14. 【MATLAB】三维绘图 三维数据插值
  15. 互联网思维之极致思维
  16. 汉诺塔游戏(Python)
  17. excel高效之指定列求和、列加单位、列间做基础运算
  18. 谁说码农不懂浪漫?js写的'老婆生日快乐'特效
  19. OpenMV4开发笔记1-感光元件初始化
  20. SpringCloud分布式框架

热门文章

  1. 深圳FC1511型号单片机应用程序编程开发环境MCU
  2. keras实现LFW测试
  3. iOS开发小记:初次接入环信SDK3.0时遇到的问题及解决办法汇总
  4. Pyecharts基本图:饼图
  5. oracle账户余额表和明细表,科目余额表与明细账
  6. C语言数据结构——广义表
  7. Boolean初始值是什么?
  8. 关于蓝桥杯第十二届H题杨辉三角(满分结果)
  9. 单片机指令MOV、MOVC、MOVX的区别与联系
  10. MAC 10.14安装第三方软件的方法