<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>小良的分布式之路</title>
  <icon>https://liang7878.github.io/images/logo.png</icon>
  <subtitle>事业常成于坚忍，毁于浮躁</subtitle>
  <link href="https://liang7878.github.io/atom.xml" rel="self"/>
  
  <link href="https://liang7878.github.io/"/>
  <updated>2023-10-27T15:26:53.000Z</updated>
  <id>https://liang7878.github.io/</id>
  
  <author>
    <name>Chengliang</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>我的七周七数据库 -- Citus: Distributed PostgreSQL for Data-Intensive Applications</title>
    <link href="https://liang7878.github.io/posts/455069a2.html"/>
    <id>https://liang7878.github.io/posts/455069a2.html</id>
    <published>2023-10-27T15:26:53.000Z</published>
    <updated>2023-10-27T15:26:53.000Z</updated>
    
    <content type="html"><![CDATA[<p>最近一些工作会稍微用到 Citus 相关的产品，想起早年在实验室做数据库开发的时候也稍微了解过一些，这次稍微搜了一下，发现 Citus 团队在 21 年的时候发了一篇 SIGMOD，正好也就顺便看了一下。简单来说，PostgreSQL本身是一个单机数据库，虽然也提供主从复制的高可用方案，但是并没有原生的分库分表方案。 正好，PG 本身提供了大量的扩展开发接口，Citus 团队以 PG 扩展的方式构建了一个分库分表的方案，这就是 Citus 最开始的应用场景。</p><p>下面我们来看看论文，跟着论文一起了解一下 Citus 主要的应用场景和对应场景的解决方案。基于 Citus 论文的结构，我们这篇文章会分为一下几个模块：</p><ul><li>Citus 简介</li><li>Citus 的应用场景</li><li>Citus 的整体架构实现</li><li>Citus 的性能评测结果</li></ul><p>由于是 industry track，论文整体理解起来也比较简单，我们来过一下。</p><h2 id="Citus-简介"><a href="#Citus-简介" class="headerlink" title="Citus 简介"></a>Citus 简介</h2><p>作为一个非常著名的开源数据库，PostgreSQL应用于许多的商业场景，这也催生了将其由单机数据库扩展为多机器的分布式数据库的需求。以可扩展性为目的，Citus 基于 PostgreSQL的插件开发接口构建了分片层、分布式查询计划生成器和执行器、以及分布式事务。同时由于其完全基于扩展接口开发，它依然保留了 PG 本身的各种功能。</p><p>文章基于 Citus 本身的应用，归纳总结了四种需要扩展 PG 的应用场景，并介绍了这四种场景下对分布式数据库的功能需求。接着，文章介绍了 Citus 是如何基于 PG 的扩展接口构建一个分布式数据库系统，并介绍了它是如何解决各类应用场景下的需求。</p><h2 id="四种典型的应用场景及其需求"><a href="#四种典型的应用场景及其需求" class="headerlink" title="四种典型的应用场景及其需求"></a>四种典型的应用场景及其需求</h2><p>扩展PG 的四种应用场景：多租户、实时分析、高性能增删改查、数据仓库。</p><p><img data-src="/posts/455069a2/workloads.png" alt="Citus 针对四类典型工作负载的示意图"></p><h3 id="多租户"><a href="#多租户" class="headerlink" title="多租户"></a>多租户</h3><p>多租户的场景是一个很典型的需要多数据进行分片的场景，最传统的方式是对租户的数据进行手动分片，将不同租户的数据放到不同的分片下，以实现资源的隔离。（我们的数据库就是这种方式）。还有一种方式是通过 tenant ID 列来对数据进行分片，Citus 采用的就是这种方式，这种方式下，往往需要不同租户采用相同的数据表模式，但事实上用户会希望针对不同租户设置特定的数据表模式。针对这一需求，作者简易可以使用 JSONB 数据类型累进行扩展。同时，客户也需要能够控制租户数据的位置，这样可以在监控到热点的时候对数据进行迁移。</p><h3 id="实时分析"><a href="#实时分析" class="headerlink" title="实时分析"></a>实时分析</h3><p>实时分析是为了对大规模流数据进行及时的分析。主要的流数据包括 event 数据和时间序列数据，这些数据往往是用来进行系统监控、异常检测等。这种场景下，数据库需要能够承受很高的写请求的吞吐，同时还要接收每秒数百个的读请求，用于查询数据。这类场景下，查询往往是基于预先定义好的一些索引、物化视图以及其他的一些数据转换请求，数据库需要高效地将最新的数据增量更新到上述结构中。 </p><p>对于这类场景，PG 本身也有很好的支持，通过 COPY 语句可以实现非常快的数据导入，它的 MVCC 允许在写入的同时服务大量的读请求。 PG 也能支持许多复杂的数据类型。其唯一的缺点在于，这类场景下，数据往往会很快达到单机的存储极限。</p><p>为了应对这类场景，数据库系统需要能够将数据分发到不同的机器上，同时支持并行的批量加载以读取这些数据。我们之前也提到，Citus 可以实现分库分表的操作，能够将写入的数据进行分发。后面也会讲到这种情况下 Citus 如何支持高效的查询，包括比较复制的跨节点 join 等操作。</p><h3 id="高性能增删改查"><a href="#高性能增删改查" class="headerlink" title="高性能增删改查"></a>高性能增删改查</h3><p>高性能的CRUD（创建、读取、更新、删除）场景会涉及许多相对独立修改的对象&#x2F;文档。应用程序主要通过简单的CRUD操作访问数据，但也可能对对象发出更复杂的查询。这些对象通常遵循类似JSON的非结构化数据格式。PG 可以支持针对 JSON 的查询，一台大型的PostgreSQL服务器可以处理每秒数十万次的写入操作和数百万次的读取操作。不过，这里存在一些限制，PostgreSQL的MVCC（多版本并发控制）模型需要写入一个新的副本，然后在之后通过自动清理（auto-vacuuming）来回收空间。如果自动清理无法跟上，这可能导致性能下降。另一个限制是，由于采用每个连接一个进程的架构以及进程的相对高内存开销，PostgreSQL只能处理有限数量的（空闲）连接。</p><h3 id="数据仓库"><a href="#数据仓库" class="headerlink" title="数据仓库"></a>数据仓库</h3><p>数据仓库将来自不同来源的数据合并到单个数据库系统中，以生成即时的分析报告。该应用程序通常不需要低延迟或高吞吐量，但查询可能需要扫描非常大量的数据。数据库需要支持非常快速的扫描，并能够为涉及任意连接的手写SQL找到高效的查询计划。 </p><p>一般来说，PostgreSQL在合理的时间内执行非常大的扫描时，缺乏扫描性能和并行性。另一方面，它的性能特性、全面的SQL支持和生态系统仍然使其成为分析的吸引人选择。</p><p>为了扩展数据仓库应用程序，需要通过并行、分布式SELECT和列式存储来加速扫描。应选择分布列以最大程度地增加共同分布连接的数量，但数据库还需要通过重新分配或在网络上传播数据来支持有效的非共同分布连接。查询优化器需要决定最小化网络流量的连接顺序。</p><h2 id="Citus-架构"><a href="#Citus-架构" class="headerlink" title="Citus 架构"></a>Citus 架构</h2><p>在Citus集群中，所有服务器都运行带有Citus扩展以及任意数量其他扩展的PG实例。Citus使用PG扩展API来以两种方式改变数据库的行为：首先，Citus将数据库对象（如自定义类型和函数）复制到所有服务器。其次，Citus添加了两种新的表类型来利用外部服务器。</p><p><img data-src="/posts/455069a2/deployment.png" alt="Citus 集群部署架构图"></p><h3 id="Citus-是如何修改-PG-本身的行为的"><a href="#Citus-是如何修改-PG-本身的行为的" class="headerlink" title="Citus 是如何修改 PG 本身的行为的"></a>Citus 是如何修改 PG 本身的行为的</h3><p>了解过 PG 扩展开发的同学多少都知道，PG 本身提供大量的编程接口，我们可以在扩展插件中编写自己的方法来调整数据库的行为。 Citus主要使用了以下几个接口：</p><p>User-defined functions (UDFs) 可以在 SQL 查询时作为事务一部分被调用，主要用于操作Citus元数据以及执行 RPC。</p><p>查询计划器和执行器的 hook 本身是一组全局函数指针，允许扩展提供替代的查询计划和执行方法。在PG解析查询后，Citus 会检查查询是否涉及Citus表。如果是的话，Citus会生成一个包含CustomScan节点的计划树，该节点封装了分布式查询计划。</p><p>CustomScan是PG查询计划中的执行节点，它保存自定义状态并通过自定义函数指针返回元组。Citus CustomScan调用分布式查询执行器，该执行器将查询发送到其他服务器，并在将结果返回给PG执行器之前收集这些结果。</p><p>Transaction callbacks在事务的生命周期中的关键点（例如预提交、后提交、中止）被调用。Citus使用这些回调来实现分布式事务。</p><p>Utility hook 在解析不经过常规查询计划器的任何命令后被调用。Citus用这些 hook 来执行Citus表相关的DDL和COPY命令。</p><p>Background workers在单独的进程中运行用户提供的代码。Citus使用此API运行维护守护进程。该守护进程执行分布式死锁检测、两阶段提交准备事务恢复和清理。</p><p>通过这些钩子，Citus可以拦截客户端和涉及 Citus 表的 PG 引擎之间的任何交互，并替换或增强PG的行为。</p><h3 id="Citus-的整体架构"><a href="#Citus-的整体架构" class="headerlink" title="Citus 的整体架构"></a>Citus 的整体架构</h3><p>Citus部署通常包括一个coordinator和多个worker，coordinator存有分布式表的元数据，客户端通常连接到协调器。当用户通过Citus UDF添加工作节点时，一个PG服务器隐式地成为协调器。worker存储包含实际数据的分片。当集群规模较小时，协调器本身也可以用作工作节点，因此最小可能的Citus集群是一个单独的服务器。</p><p>将单个coordinator作为入口点的好处在于，PG库和工具可以与Citus集群交互，就好像它是一个普通的PG服务器。由于分布式查询的开销与查询执行相比较小，一个大型协调器节点可以处理每秒数十万次的事务或通过PG的COPY命令每秒摄入数百万行的数据。</p><p>在这种架构下，很显然 coordinator 节点很容易成为整个系统的瓶颈，Citus 也有相对应的解决方案，通过将元数据分发到 worker 节点，Citus 能够降低coordinator 的查询压力，coordinator则只负责 DDL 语句的执行，由于 DDL 语句的执行量很少，这几乎不会导致coordinator 的瓶颈问题。然而，这种方案会导致 client 端连接 citus 集群时创建更多的连接，大量的连接则会导致另一个瓶颈。</p><h3 id="Citus-的表类型"><a href="#Citus-的表类型" class="headerlink" title="Citus 的表类型"></a>Citus 的表类型</h3><p>Citus 有两种表，分布表和引用表。在创建普通的 PG 本地表之后，我们可以通过执行 Citus 定义的方法来将本地表转换为 Citus 表，之后，Citus 会接管所有和这个表相关的操作。 通过真分区键进行哈希，Citus 可以将表上的数据均匀分布到 worker 节点上。Citus 可以保证相同范围的哈希值对应的数据都在相同的 worker 节点上，因此，在执行 join 等操作时，citus 不需要进行跨节点通信。</p><p>分布表和引用表的区别在于，分布表的数据通过哈希分片到不同 worker 节点上，但引用表会在所有节点上复制同步。这样，当分布表和引用表发生 join 时，worker 只需要执行针对本地的分片来执行join 。</p><h3 id="数据再平衡"><a href="#数据再平衡" class="headerlink" title="数据再平衡"></a>数据再平衡</h3><p>可以想象，当单个 worker 节点的数据达到负载极限时，我们需要将数据移动到新的节点，从而实现整体的负载均衡。 Citus 提供一个 rebalancer 来执行移动数据的操作，在执行 rebalance 操作时，rebalancer 会选取一个分片和与之相关的其他数据，通过 PG 的逻辑复制进行移动，这时，分片依然可以接收读写请求。在完成所有存量和增量的复制后，Citus 会对分片加上写锁来等待所有复制完成，并执行分布表的元数据更改。这时一般会有几秒的写宕机。</p><h3 id="分布式查询计划器"><a href="#分布式查询计划器" class="headerlink" title="分布式查询计划器"></a>分布式查询计划器</h3><p>在客户端请求查询 Citus 相关的数据表时，Citus 会生成一个包含有分布式查询计划的 Custom-Scan节点，Citus 有不同的查询计划器，以应对不同场景下的查询请求。如下图所示：</p><p><img data-src="/posts/455069a2/query_planner.png" alt="Citus 查询规划流程图"></p><p>Fast path planner 快速路径规划器处理的是针对单个表的简单CRUD查询，并且该表只有一个分布列的取值。它直接从查询中的过滤条件中提取分布列的值，并确定与该值匹配的分片。然后，规划器将表名重写为分片名，以构建在工作节点上运行的查询，这可以在CPU开销极小的情况下完成。因此，快速路径规划器支持高吞吐量的CRUD工作负载。</p><p> Router planner 路由规划器处理的是可以限定在一组共同分布的分片上的任意复杂查询。路由规划器会检查或推断所有分布表是否具有相同的分布列过滤条件。如果是这样，查询中的表名将被重写为与分布列值匹配的共同分布分片的名称。路由规划器隐式支持PostgreSQL支持的所有SQL特性，因为它会将完整的查询委托给另一个PostgreSQL服务器。因此，路由规划器使多租户SaaS应用能够在最小开销下使用所有SQL特性。</p><p>Logical planner 逻辑规划器通过构建多关系代数树来处理跨分片的查询。多关系代数形式化了两个在PostgreSQL中不可用的分布式执行原语，用于收集和重新分区数据。这种差异影响了路由规划器和逻辑规划器之间的分离。</p><p>逻辑规划器的目标是在结果在coordinator上合并之前，将尽可能多的计算推送到工作节点。这里有两种不同的逻辑规划策略：</p><ol><li><strong>逻辑下推规划器</strong>：检测连接树是否可以完全下推。这要求所有分布表之间具有共同分布的连接，并且子查询不需要全局合并步骤（例如，GROUP BY必须包含分布列）。如果是这样，规划器可以基本上不关心连接树中使用的SQL构造，因为它们完全委托给工作节点，分布式查询计划变得非常容易并行化。</li><li><strong>逻辑连接顺序规划器</strong>：确定涉及非共同分布连接的连接树的最佳执行顺序。它使用共同分布连接、广播连接和重新分区连接来评估分布表和子查询之间所有可能的连接顺序，并选择最小化网络流量的顺序。广播连接和重新分区连接会导致带有过滤器和投影的子计划被推送到子计划中。</li></ol><p>对于每个查询，Citus会按照最低到最高开销的顺序遍历这四个规划器。如果某个特定规划器可以为查询生成计划，Citus就会使用它。在特定场景下，相比执行而言，查询计划的生成在时间开销上很低。</p><h3 id="分布式查询执行器"><a href="#分布式查询执行器" class="headerlink" title="分布式查询执行器"></a>分布式查询执行器</h3><p>PG 的查询计划是一个由多个执行节点组成的执行树，每个节点都有一个返回一个元组的函数。 Citus 生成的 CustomScan 就是其中的一个节点。 PG 的执行器进入 CustomScan函数后，Citus 会执行各个子计划，然后把执行移交给 adaptive executor自适应执行器。自适应执行器可以通过单工作节点多路连接的方式并行执行查询任务。这种多路实现的方式需要管理过程中建立连接，以及并行处理过程中的额外开销。</p><p>自适应执行器需要权衡并行执行的延迟与各类开销。这里提出了一个“slow start”慢启动的方案。查询开始时，执行器对每个工作节点只建立一个连接，接下来，每 10ms，执行器会给每个工作节点的连接数（n）加一。如果有 t 个等待交给某个工作节点执行的任务没有被分配到可用的连接， 那么执行器就会为这个工作节点创建 min(n, t) 个连接，并放入连接池中。这种方案的原因在于，一个简单的内存中基于索引的查询往往只需要不到 1ms 的时间，所以一般来说节点上的所有任务会在执行器尝试打开连接之前完成。此外，分析型任务往往需要数百毫秒，尽管连接建立有一定的延迟，但是在整体的时间开销里几乎可以忽略。当然，这种方案下，执行器仍然需要管理与各个节点的连接数。</p><p>在执行连接上的任务分配时，由于每个连接到分片上执行查询时访问的是不同的数据，并在多语句事务的情况下保持未提交的写入和锁定。因此，对于每个连接，Citus会跟踪已访问的分片，以确保相同的连接将在同一事务中对同一组共同分布的分片进行任何后续访问。在开始执行语句时，如果在事务中已经访问了分片，则将任务分配给对应的连接，否则将其分配给工作节点的通用池。当连接准备就绪时，执行器首先从其队列中获取一个已分配的任务，否则从通用池中获取任务。</p><p>通过结合慢启动、共享连接限制和任务分配算法，自适应执行器可以处理各种工作负载模式，即使它们在单个数据库集群上并发运行，并支持复杂的交互式事务块而不损失并行性。</p><h3 id="分布式事务"><a href="#分布式事务" class="headerlink" title="分布式事务"></a>分布式事务</h3><p>Citus 中的事务主要分两种，一种是在 coordinator 上的事务，一种是在工作节点上的事务。对于仅仅涉及单个工作节点的事务，工作节点全权负责整个事务；对于涉及多个节点的事务则通过两阶段提交来保证原子性。单节点事务其实比较简单，我们主要看看多节点的事务如何基于两阶段提交来实现。</p><p>对于涉及多个节点的写事务，执行器在工作节点上开启事务块，并在提交时对它们执行两阶段提交（2PC）。PostgreSQL实现了准备事务状态的命令，以一种保留锁并在重新启动和恢复时保留状态的方式。这使得稍后可以提交或中止已准备好的事务。Citus使用这些命令来实现完整的2PC协议。</p><p>当协调器上的事务即将提交时，预提交回调通过所有与开启事务块的工作节点的连接发送“准备事务”命令。如果成功，协调器为每个已准备好的事务在Citus元数据中写入一个提交记录，然后本地事务提交，确保提交记录被耐久存储。在提交后和中止回调中，已准备好的事务将尽力提交或中止。</p><p>当一个或多个已准备好的事务无法提交或中止时，将使用Citus元数据中的提交记录确定事务的结果。后台守护进程定期比较每个工作节点上待处理的准备好的事务列表和本地的提交记录。如果存在已准备好的事务的提交记录（即：可见），则协调器已经提交，因此已准备好的事务也必须提交。反之，如果一个已结束的事务没有记录存在，那么已准备好的事务必须中止。当存在多个协调器时，每个协调器为其启动的事务执行2PC恢复。由于提交记录和已准备好的事务都存储在预写式日志中，这种方法对涉及的任何节点的故障是强大的。</p><p>另一个关键点在于如何处理分布式死锁，特别是在多语句事务之间。为了解决这个问题，可以使用死锁预防或死锁检测方法。死锁预防技术（例如Wound-Wait）需要一定百分比的事务重新启动。PostgreSQL具有交互式协议，这意味着在重新启动发生之前可能将结果返回给客户端，而且不希望客户端重试事务。因此，Wound-Wait对于Citus来说不太适用。为了保持与PostgreSQL的兼容性，Citus实现了分布式死锁检测，当事务陷入实际死锁时，会中止事务。</p><p>PostgreSQL已经在单节点上提供了死锁检测。Citus通过在协调器节点上运行的后台守护程序扩展了这一逻辑。该守护程序每2秒轮询所有工作节点，以获取其锁图中的边缘（进程a等待进程b），然后合并在同一分布式事务中参与的图中的所有进程。如果生成的图包含一个环路，那么将向属于环路中最年轻的分布式事务的进程发送取消命令，以中止事务。</p><p>除非存在实际死锁，否则在典型的（分布式）数据库工作负载中，只有少数事务会在等待锁。因此，分布式死锁检测的开销很小。当分布式死锁经常发生时，建议用户更改其事务中语句的顺序。</p><p>Citus中的多节点事务提供了原子性、一致性和持久性的保证，但不提供分布式快照隔离的保证。并发的多节点查询可能在一个节点上提交之前获取本地MVCC快照，而在另一个节点上提交之后获取快照。解决这个问题需要对PostgreSQL进行更改，使快照管理器可扩展。在实践中，我们在这四种工作负载模式中并没有发现对分布式快照隔离的强烈需求，客户目前也没有表达对此的需求。在多租户和CRUD应用程序中，大多数事务范围仅限于单个节点，这意味着它们在该节点上获得了隔离保证。分析应用程序之间的事务没有强依赖关系，因此对宽松的保证更具宽容性。</p><p>在某些混合场景中，分布式快照隔离可能很重要。然而，现有的分布式快照隔离技术由于需要额外的网络往返或等待时钟而具有显著的性能成本，这会增加响应时间并降低可实现的吞吐量。在同步的PostgreSQL协议的背景下，吞吐量最终受到#连接数&#x2F;响应时间的限制。由于从应用程序的角度来看，建立大量的数据库连接通常是不切实际的，因此低响应时间是实现高吞吐量的唯一途径。因此，如果将来实施分布式快照隔离，我们可能会将其作为可选项。</p><h3 id="其他场景的分布式处理"><a href="#其他场景的分布式处理" class="headerlink" title="其他场景的分布式处理"></a>其他场景的分布式处理</h3><p>除了简单的 SELECT 语句和其他 DML 命令外，Citus 还提供其他语句的支持</p><ul><li>DDL 命令在 PG 里面是在线处理的具有事务性的操作，Citus 同样通过加锁来保持相同的特性，并通过执行器将命令发送到 worker 节点</li><li>COPY 命令在 PG 里面可以被用来导入 CSV 格式的数据，这个过程在 PG 里面是单线程的实现，并需要更新索引、检查各类约束条件。在 citus 里面，coordinator 会在每一个分片和数据流上异步开启 COPY命令，这样写操作也可以并行执行。</li><li>跨分布表的INSERT…SELECT命令往往采用以下三种步骤之一去执行：<ul><li>如果SELECT操作在协调器上需要执行合并步骤，则该命令在内部作为分布式SELECT执行，然后将结果COPY到目标表中。</li><li>如果没有合并步骤，但源表和目标表不是共位的，则INSERT..SELECT在将SELECT结果插入目标表之前执行分布式重新分区。</li><li>如果源表和目标表是共位的，则INSERT..SELECT直接在并行的共位分片上执行。</li></ul></li><li>在Citus中，存储过程可以基于分布参数和一个共位的分布表被委托给工作节点，以避免协调器和工作节点之间的网络往返。工作节点可以在不进行网络往返的情况下在本地执行大多数操作，但在必要时也可以在工作节点之间执行分布式事务。这种方法有助于在分布式环境中优化存储过程的性能。</li></ul><h3 id="高可用和备份"><a href="#高可用和备份" class="headerlink" title="高可用和备份"></a>高可用和备份</h3><p>在Citus中，HA主要在服务器层使用现有的PostgreSQL复制进行处理。在HA设置中，集群中的每个节点都有一个或多个热备份节点，并使用同步、异步或quorum来复制其写前日志（WAL）。当一个节点失败时，集群协调器会提升一个备用节点，并更新Citus元数据、DNS记录或虚拟IP。整个故障切换过程需要20-30秒，在此期间涉及该节点的分布式事务会回滚。coordinator通常是托管服务中的控制面的一部分，但本地用户可以使用pg_auto_failover扩展来执行相同的功能。</p><p>备份也主要在服务器级别进行，通过创建周期性的磁盘快照或数据库目录的副本，并在每个服务器中将WAL持续存档到远程存储来实现。Citus支持定期创建一致的还原点，即每个节点的WAL记录。还原点是在将写操作阻塞到 coordinator上的提交记录表时创建的，这可以防止在创建还原点时进行中的两阶段提交。将所有服务器还原到相同的还原点可以保证在恢复的集群中，所有多节点事务要么完全提交要么中止，或者可以通过协调器在启动时执行2PC恢复来完成。</p><h2 id="性能测试"><a href="#性能测试" class="headerlink" title="性能测试"></a>性能测试</h2><p>Benchmark 部分，作者的实验基本围绕着不同场景下 PG 单机和 Citus 不同部署模型下的性能差距，包括 latency 、 QPS 、 TPS 等等。这里我就不详细展开了，贴几张图，感兴趣的同学可以直接看原文。</p><p>![[github_benchmark.png]]<br>![[data_warehouse_benchmark.png]]<br>![[tps_benchmark.png]]<br>![[ycsb_benchmark.png]]</p><h2 id="应用案例"><a href="#应用案例" class="headerlink" title="应用案例"></a>应用案例</h2><p>这部分介绍了 Citus 在微软内部的使用，这一场景主要是一个数据分析场景，数据来自全球数亿台 windows 设备，指标显示在一个名为“Release Quality View”（RQV）的实时分析仪表板上，该仪表板帮助Windows工程团队评估每个Windows版本的客户体验质量。</p><p>RQV的底层数据存储，代号为VeniceDB，由两个在Microsoft Azure上运行的超过1000核心的Citus集群提供支持，存储了超过一PB的数据。虽然对于VeniceDB评估了许多不同的分布式数据库和数据处理系统，但只有Citus能够满足与PB级VeniceDB工作负载相关的特定要求，包括：</p><ul><li>对于每天超过6百万次查询，p95下的小于一秒的响应时间</li><li>每天约10TB的新数据</li><li>在RQV中显示新的数据需要在20分钟内完成</li><li>具有高基数group by的嵌套子查询</li><li>高级二级索引（例如部分索引、GiST索引）以高效查找沿各个维度的报告</li><li>高级数据类型（例如数组、HyperLogLog）以在SQL中实现复杂的分析算法</li><li>通过增量聚合减少行数</li><li>在节点间进行原子更新以清理错误数据</li></ul><p>在Citus集群中，原始数据存储在名为measures的表中，该表按设备ID进行分布，并使用PostgreSQL中内置的分区功能按时间进行磁盘分区。使用COPY命令来并行化将传入的JSON数据导入分布式表。使用分布式INSERT..SELECT命令来执行设备级别的对传入数据进行预聚合，并将其放入几个具有不同索引的reports表中。reports表也按设备ID进行分布，并与measures表共位，以便Citus可以完全并行化INSERT..SELECT。</p><p>这里给出了一个典型的查询语句：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">SELECT ..., avg(device_avg)</span><br><span class="line">FROM ( </span><br><span class="line">  SELECT deviceid, ..., avg(metric) as device_avg</span><br><span class="line">  FROM reports WHERE ...</span><br><span class="line">GROUP BY deviceid, &lt;time period&gt; , &lt;other dimensions&gt; ) AS subq  </span><br><span class="line">GROUP BY &lt;time period&gt;, &lt;other dimensions&gt;;</span><br></pre></td></tr></table></figure><p>这些查询通过多个维度进行过滤（例如测量、时间范围、Windows版本），以找到数据的重要子集。嵌套的子查询首先通过设备ID对报告进行聚合，这对于按设备而不是按报告数量来衡量整体平均值是必要的。每个查询可能涉及数千万台设备，这使得按deviceid进行GROUP BY的计算变得具有挑战性。由于子查询按照分布列进行分组，Citus中的逻辑推送计划器认识到它可以将整个子查询推送到所有工作节点以进行并行化。然后，工作节点使用仅索引扫描按设备ID顺序读取数据，并最小化GROUP BY的磁盘I&#x2F;O和内存占用。最后，Citus通过在工作节点上计算部分聚合并在协调器上合并这些部分聚合来分发外部聚合步骤，以生成最终结果。</p><h2 id="相关工作"><a href="#相关工作" class="headerlink" title="相关工作"></a>相关工作</h2><p>这部分大体介绍了当前市面上常见的分布式数据库解决方案：</p><ul><li>针对MySQL的类似于 Citus 的方案Vitess，采取了和 Citus 相似的实现方案</li><li>基于 PG 的解决方案 Greenplum and Redshift，相比 Citus 而言对分析场景具有更好的支持，比如采用列存来实现快速 scan，通过数据 shuffle 来优化 join 性能等</li><li>Aurora同样也对 PG 进行了支持，通过分布式存储的实现，Aurora 采用了存算分离、共享存储的方案，这种方案的好处在于调用端不需要做许多分布式场景下的决策，可以直接把 Aurora 当做单机 DB 来使用。 Citus 则需要调用方对分布方案有足够的理解和干预。</li><li>Spanner ，CockroachDB  和 Yugabyte 主要面向需要分布式事务支持的场景。CockroachDB 和 Yugabyte 也部分支持 PostgreSQL 协议。与 Citus 相比，这些系统的一个显著的架构差异在于它们提供了分布式快照隔离，并使用了”等待-等待”（wound-wait）而不是死锁检测。分布式快照隔离的一个优点是它避免了数据建模的约束。Citus 用户需要使用邻近数据存储和引用表，以将事务范围限制到单个节点，以获得完整的 ACID 保证。另一方面，这些技术还能实现高效的连接和外键，因此它们对于扩展复杂的关系数据库工作负载是至关重要的。</li><li>TimescaleDB 是一个为时间序列数据优化的 PostgreSQL 扩展。它使用与 Citus 相似的钩子来引入“超级表”（hypertable）的概念，该表会根据时间自动进行分区。按时间对表进行分区对于限制索引大小以保持时间序列工作负载的高写入性能，以及通过时间范围进行分区修剪以加速查询是有用的。由于对 PostgreSQL 钩子的冲突使用，目前 Citus 和 TimescaleDB 不兼容，但 Citus 可以与 pg_partman 一起使用，后者是一个更简单的时间分区扩展。许多使用 Citus 的实时分析应用程序也会在分布式表的基础上使用 pg_partman，在这种情况下，各个分片会被本地分区，以获得分布式表和时间分区的双重优势。</li></ul><p>整体而言，Citus 的分布式解决方案需要用户直接介入数据切片、数据同步、数据存储等多个过程，对于使用者而言需要一定背景知识。这一实现方案的好处在于 Citus 可以快速发布支持最新版本 PG 的新版本。</p>]]></content>
    
    
    <summary type="html">概览 Citus 如何扩展 PostgreSQL 以支撑多租户、实时分析等场景的学习笔记</summary>
    
    
    
    <category term="论文阅读" scheme="https://liang7878.github.io/categories/%E8%AE%BA%E6%96%87%E9%98%85%E8%AF%BB/"/>
    
    
    <category term="数据库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    
    <category term="Paper Reading" scheme="https://liang7878.github.io/tags/Paper-Reading/"/>
    
  </entry>
  
  <entry>
    <title>“Azure设计模式 -- 如何设计一个请求速率限制器”</title>
    <link href="https://liang7878.github.io/posts/6f202b0d.html"/>
    <id>https://liang7878.github.io/posts/6f202b0d.html</id>
    <published>2023-10-27T12:22:30.000Z</published>
    <updated>2026-06-29T17:07:30.747Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>速记</strong></p><ul><li>速率限制不是简单拒绝流量，而是把需求端洪峰与服务端能力解耦，避免重试风暴。</li><li>常见实现：可靠队列 + Worker 控制节奏；多进程共享容量时用分布式锁分配配额。</li><li>限流仍要配合错误重试、配额划分和跨团队协调，才能落地在复杂业务中。</li></ul></blockquote><p>一提到限流算法，大家肯定就是要聊时间窗口、漏斗桶、令牌桶之类的，今天跟着Azure的文档来看看限流器在生产环境怎么搭建和使用。</p><h2 id="什么是Rate-limiting？"><a href="#什么是Rate-limiting？" class="headerlink" title="什么是Rate limiting？"></a>什么是Rate limiting？</h2><p>假如现在有一个线上的服务，由于业务的增长或者调用方的不正确操作产生了大量的流量，如果我们直接拒绝过量的请求，那么会出现以下的情况：</p><p>以Azure Cosmos DB为例，假设我们有一个应用现在要尝试将数据写入Cosmos DB：</p><ol><li>应用需要将10k条记录写入Cosmos DB，每条记录消耗10个请求单元，我们一共需要100k个请求单元来完成整个写入的工作</li><li>此时Cosmos DB仅有20k个请求单元可供服务</li><li>当10k的记录发送到DB时，由于负载容量有限，仅有2k条记录被成功写入，但8k条记录被拒绝</li><li>接着8k的记录被发送到DB,2k写入，6k拒绝</li><li>接着6k的记录被发送，2k写入，4k拒绝</li><li>接着4k的记录被发送，2k写入，2k拒绝</li><li>最后2k的记录被发送，全部记录写入完成</li></ol><p>可以发现，应用总共发送了30k的记录，但是只有10k的数据被写入DB。这产生了大量额外的请求开销。</p><p>与此同时，由于大量请求被拒绝，应用中需要处理20k个错误信息，这也会带来内存、存储等多方面的开销。</p><p>由于不知道DB端的限流策略，这种单纯重试的方式也无法给调用方一个预期多久可以完成所有数据的写入。</p><p>因此我们需要速率限制器，帮助我们控制一定时间周期内发送到我们服务中的请求数量。</p><h2 id="如何来做请求速率限制器"><a href="#如何来做请求速率限制器" class="headerlink" title="如何来做请求速率限制器"></a>如何来做请求速率限制器</h2><p>我们可以基于不同的指标来决定如何限制请求的速率：</p><ul><li>可以是请求的数量，比如每秒20个请求</li><li>可以是数据的大小，比如每分钟3个GB</li><li>也可以是操作的开销，比如每秒钟20k个请求单元</li></ul><p>以上指标都可以用来实现速率控制器。在我们日常的场景里，往往是应用端会接收到大量的请求，因此如何使用负载有限的服务端是应用端必须解决的问题，我们可以简单地将请求缓存在本地，但这也会有进程崩溃导致请求失败的可能性。为了避免这类风险，我们可以把请求放在一个可靠的消息队列服务中，由作业执行器来以一定的速率来读取请求并发送到后端服务，这样就实现了一个速率控制器，提交请求变成了将请求写入消息队列，作业执行器则只会在能够处理请求的时候将请求从消息队列取出。</p><p><img data-src="/posts/6f202b0d/durable_msging_service.png" alt="耐久消息服务架构图"></p><p>当你发送记录时，你用于释放记录的时间周期可能比服务进行限流的周期更加精细。系统通常根据你可以轻松理解和处理的时间段来设置限流。然而，对于运行服务的计算机来说，这些时间跨度可能相对较长，与其处理信息的速度相比。例如，系统可能以每秒或每分钟为单位进行限流，但通常代码处理的时间单位是纳秒或毫秒。</p><p>虽然不是必需的，但通常建议更频繁地发送少量记录以提高吞吐量。因此，你可以比每秒或每分钟批量释放记录更加精细地处理，以保持你的资源消耗（内存、CPU、网络等）以更均匀的速率进行，从而防止由于突发请求造成的潜在瓶颈。例如，如果一个服务允许每秒处理100次操作，速率限制器的实现可以通过每200毫秒释放20次操作来平衡请求，如下图所示。</p><p><img data-src="/posts/6f202b0d/rate_limited_flow.png" alt="限流策略处理流程图"></p><p>此外，有时需要让多个不协调的进程共享一个受限制的服务。为了在这种情况下实施速率限制，你可以逻辑上将服务的容量分区，然后使用分布式互斥系统在这些分区上管理独占锁。不协调的进程可以在需要容量时竞争这些分区上的锁。对于进程持有锁的每个分区，它被授予一定数量的容量。</p><p>例如，如果受限制的系统允许每秒500个请求，你可以创建20个分区，每个分区允许每秒25个请求。如果一个进程需要发出100个请求，它可能请求分布式互斥系统的四个分区。系统可能授予两个分区的独占锁，持续10秒。然后，该进程的速率限制将为每秒50个请求，任务将在两秒内完成，然后释放锁。</p><p>实现这种模式的一种方法是使用Azure Storage。在这种情况下，你可以在容器中为每个逻辑分区创建一个0字节的blob。然后，你的应用程序可以直接针对这些blob获取短时间（例如15秒）的独占租约。对于每个获得的租约，应用程序将能够使用该分区的容量。应用程序随后需要跟踪租约时间，以便在租约到期时停止使用其被授予的容量。在实现此模式时，通常希望每个进程在需要容量时尝试租用一个随机分区，以进一步降低延迟，你可能会为每个进程分配一小部分独占容量。因此，只有在需要超出其保留容量时，进程才会尝试获得对共享容量的租约。</p><p><img data-src="/posts/6f202b0d/azure_blob_storate.png" alt="Azure Blob 存储架构图"></p><p>在决定如何实施这种模式时，请考虑以下因素：</p><ol><li><p>尽管速率限制模式可以减少限制错误的数量，但你的应用程序仍然需要正确处理可能发生的任何限制错误。</p></li><li><p>如果你的应用程序有多个工作流程访问同一个受限制的服务，你需要将它们全部整合到你的速率限制策略中。例如，你可能支持将记录批量加载到数据库中，同时也支持在同一数据库中查询记录。你可以通过确保所有工作流程都通过相同的速率限制机制进行控制，来管理容量。或者，你可以为每个工作流程保留单独的容量池。</p></li><li><p>受限制的服务可能被多个应用程序使用。在某些情况下，可以协调这种使用（如上所示）。如果你开始看到比预期更多的限制错误，这可能是多个应用程序访问服务之间的竞争的迹象。如果是这样，你可能需要考虑在其他应用程序的使用降低之前，暂时减少你的速率限制机制施加的吞吐量。</p></li></ol><p>使用这种模式可以达到以下目的：</p><ol><li><p><strong>减少受限制服务引发的限制错误：</strong> 通过合理的速率限制策略，可以降低受限制服务引发的限制错误。逻辑上将服务的容量分区，并使用分布式互斥系统管理这些分区上的独占锁，可以确保请求在可用的容量内得到处理，从而减少限制错误的发生。</p></li><li><p><strong>减少与简单错误重试方法相比的流量：</strong> 与简单的错误重试方法相比，使用速率限制模式可以减少传输的数据量。通过根据可用容量调整请求的发送频率，可以避免将大量请求同时发送到服务，从而降低了网络流量。</p></li><li><p><strong>减少内存消耗：</strong> 速率限制模式允许在有处理能力的情况下再将记录出队，从而降低了内存的使用。只有当系统有足够的处理能力来处理记录时，才将它们从队列中移除，避免了在内存中保存大量未处理的记录，节省了内存资源。</p></li></ol><h2 id="Reference"><a href="#Reference" class="headerlink" title="Reference"></a>Reference</h2><p>[1] <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern">https://learn.microsoft.com/en-us/azure/architecture/patterns/rate-limiting-pattern</a></p>]]></content>
    
    
    <summary type="html">一提到限流算法，大家肯定就是要聊时间窗口、漏斗桶、令牌桶之类的，今天跟着Azure的文档来看看限流器在生产环境怎么搭建和使用。</summary>
    
    
    
    <category term="工作分享" scheme="https://liang7878.github.io/categories/%E5%B7%A5%E4%BD%9C%E5%88%86%E4%BA%AB/"/>
    
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/tags/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    <category term="Azure" scheme="https://liang7878.github.io/tags/Azure/"/>
    
    <category term="分布式" scheme="https://liang7878.github.io/tags/%E5%88%86%E5%B8%83%E5%BC%8F/"/>
    
  </entry>
  
  <entry>
    <title>大型分布式数据库底层是如何对数据进行建模的 -- 学一下 TiDB 的数据存储方案</title>
    <link href="https://liang7878.github.io/posts/f6a84eb2.html"/>
    <id>https://liang7878.github.io/posts/f6a84eb2.html</id>
    <published>2023-10-24T14:41:21.000Z</published>
    <updated>2026-06-29T17:07:30.758Z</updated>
    
    <content type="html"><![CDATA[<p>最近准备搞一些自建数据库相关的项目，偶然看到 TiDB的文档里面有介绍如何使用 Key-Value（键值对）来存储关系型数据库的表数据，我们跟着文档来过一遍这里的设计，也可以为我们自己的实现提供一些参考。</p><span id="more"></span><h2 id="数据存储"><a href="#数据存储" class="headerlink" title="数据存储"></a>数据存储</h2><p>数据库里面主要存在两种类型的数据，一是表中的数据，在结构上呈现出含有多个列的行结构，而是针对表中的数据构建的索引结构，常见的索引有 B+ Tree 索引，</p><p>由于 TiDB 的应用场景考虑到了 OLAP 和 OLTP 的混合场景，所以在将数据映射到键值对的时候需要考虑到以下问题：</p><ol><li>能够快速对单行或者多行进行增删改查等操作（OLTP）</li><li>能够高效完成全表扫描的任务（OLAP）</li></ol><p>这里所说的满足 OLAP 的场景需求，单纯通过数据组织到邻近区间的方式很难满足 OLAP 的需求，所以 TiDB 除了使用键值对的存储，还使用了 TiFlash 作为列存，印象中这里是直接通过 Raft 的 Listener 来同步数据同时来做数据转储的，不过这个不是今天的话题，我们继续回到正题。</p><p>首先我们定义几个不同的前缀以标记不同的数据：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">tablePrefix     = []byte&#123;&#x27;t&#x27;&#125;</span><br><span class="line">recordPrefixSep = []byte&#123;&#x27;r&#x27;&#125;</span><br><span class="line">indexPrefixSep  = []byte&#123;&#x27;i&#x27;&#125;</span><br></pre></td></tr></table></figure><p>以上前缀分别对应表、数据行和索引。</p><p>TiDB 的数据表中的行数据会采用如下规则来进行编码：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Key:   tablePrefix&#123;TableID&#125;_recordPrefixSep&#123;RowID&#125;</span><br><span class="line">Value: [col1, col2, col3, col4]</span><br></pre></td></tr></table></figure><p>我们来看个例子，假设我们此时有如下这个表：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">CREATE TABLE User (</span><br><span class="line">    ID int,</span><br><span class="line">    Name varchar(20),</span><br><span class="line">    Role varchar(20),</span><br><span class="line">    Age int,</span><br><span class="line">    PRIMARY KEY (ID),</span><br><span class="line">    KEY idxAge (Age)</span><br><span class="line">);</span><br></pre></td></tr></table></figure><p>同时这个表中有三行数据</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">1, &quot;TiDB&quot;, &quot;SQL Layer&quot;, 10</span><br><span class="line">2, &quot;TiKV&quot;, &quot;KV Engine&quot;, 20</span><br><span class="line">3, &quot;PD&quot;, &quot;Manager&quot;, 30</span><br></pre></td></tr></table></figure><p>假设这张表的 <code>TableID</code> 为 10，那么TiKV上的数据则是这样的结构：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">t10_r1 --&gt; [&quot;TiDB&quot;, &quot;SQL Layer&quot;, 10]</span><br><span class="line">t10_r2 --&gt; [&quot;TiKV&quot;, &quot;KV Engine&quot;, 20]</span><br><span class="line">t10_r3 --&gt; [&quot;PD&quot;, &quot;Manager&quot;, 30]</span><br></pre></td></tr></table></figure><p>这里将列的数据直接作为 value 来存储也是为了快速读取整行的数据。同时，由于 这些数据的 <code>key</code>具有相同的前缀，在底层存储中这些数据也会互相邻近（因为底层存储采用了 RocksDB，其中的 SSTable 会对 key 进行排序）。</p><p>在完成表的数据的存储之后，接下来就是如何实现高效的读。众所周知，在我们执行条件查询时，查询条件中会设定针对某个列的条件（比如查询主键为 1 的值），这个时候，由于 TiDB 不像 MySQL 或者 PostgreSQL 通过 B+ Tree组织数据，其在执行查询时天然无法快速基于某列的值来进行查询。 针对主键和唯一索引，TiDB 将主键的值加入到索引记录的 key 中，如下所示：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Key:   tablePrefix&#123;tableID&#125;_indexPrefixSep&#123;indexID&#125;_&#123;indexedColumnsValue&#125;</span><br><span class="line">Value: &#123;RowID&#125;</span><br></pre></td></tr></table></figure><p>假设主键索引 id 为 0，则上述表的主键索引的数据如下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">t10_i0_1 --&gt; 1</span><br><span class="line">t10_i0_2 --&gt; 2</span><br><span class="line">t10_i0_3 --&gt; 3</span><br></pre></td></tr></table></figure><p>可以发现，由于主键本身就是 <code>RowID</code>，所以其在功能上与表的数据有一定冲突（即可以直接通过表数据达到直接基于主键查询的效果）。但当主键不为<code>RowID</code>时，这个索引则能够快速定位特定行的数据。</p><p>唯一索引可以将 value 编入 key 中，非唯一索引则不能够采用这种方式，因为非唯一索引的值可以来自多个不同的行。因此，非唯一索引的数据格式如下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Key:   tablePrefix&#123;TableID&#125;_indexPrefixSep&#123;IndexID&#125;_&#123;indexedColumnsValue&#125;_&#123;RowID&#125;</span><br><span class="line">Value: null</span><br></pre></td></tr></table></figure><p>假设非唯一索引的 <code>IndexID</code>为 1，则上述例子的二级索引数据如下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">t10_i1_10_1 --&gt; null</span><br><span class="line">t10_i1_20_2 --&gt; null</span><br><span class="line">t10_i1_30_3 --&gt; null</span><br></pre></td></tr></table></figure><p>可以想象，我们可以通过前缀匹配的方式，快速查询到对应值的不同行的 <code>RowID</code>，进而读取到相应行的数据。</p><p>对于元数据信息，TiDB 也会将这些数据编辑成键值对的模式，存储到 TiKV 中。每个 <code>Database</code>&#x2F;<code>Table</code> 都被分配了一个唯一的 ID，这个 ID 作为唯一标识，并且在编码为键值对时，这个 ID 都会编码到 Key 中，再加上 m_ 前缀。这样可以构造出一个 Key，Value 中存储的是序列化后的元信息。</p><p>此外，TiDB 还用一个专门的键值对存储当前所有表结构信息的最新版本号。这个键值对存储在 PD Server 中，每次 DDL 操作的状态改变时其版本号都会加 1。其 Key 是 “&#x2F;tidb&#x2F;ddl&#x2F;global_schema_version”，Value 是类型为 int64 的版本号值。TiDB 采用 Online Schema 变更算法，有一个后台线程在不断地检查 PD Server 中存储的表结构信息的版本号是否发生变化，并且保证在一定时间内一定能够获取版本的变化。</p><h2 id="SQL引擎层"><a href="#SQL引擎层" class="headerlink" title="SQL引擎层"></a>SQL引擎层</h2><p>比如 <code>select count(*) from user where name = &quot;TiDB&quot;</code> 这样一个 SQL 语句，它需要读取表中所有的数据，然后检查 name 字段是否是 TiDB，如果是的话，则返回这一行。具体流程如下：</p><ol><li><strong>构造出 Key Range</strong>：一个表中所有的 RowID 都在 [0, MaxInt64) 这个范围内，使用 0 和 MaxInt64 根据行数据的 Key 编码规则，就能构造出一个 [StartKey, EndKey)的左闭右开区间。</li><li><strong>扫描 Key Range</strong>：根据上面构造出的 Key Range，读取 TiKV 中的数据。</li><li><strong>过滤数据</strong>：对于读到的每一行数据，计算 name &#x3D; “TiDB” 这个表达式，如果为真，则向上返回这一行，否则丢弃这一行数据。</li><li>**计算 Count(*)*<em>：对符合要求的每一行，累计到 Count(</em>) 的结果上面。</li></ol><p>可以发现，这个操作需要扫描大量的数据，如果由计算节点来执行<strong>过滤数据</strong>这一操作，我们会产生大量的网络通信开销。</p><p>所以现在经常会听到一套优化的方式叫做<strong>查询函数下推</strong>或者<strong>谓词下推</strong>或者各种下推，本质上就是将过滤数据的操作下放到存储层，以实现并发查询，提高查询效率，并降低通信开销。</p><h2 id="元信息管理"><a href="#元信息管理" class="headerlink" title="元信息管理"></a>元信息管理</h2><p>一开始的时候我以为 TiDB 的元信息管理和传统数据库的实现方式类似，是通过系统表来实现。看了文档之后才发现其实还有 PD Server。其实这里有不同的元数据信息，一部分放在 TiKV 里，比如<code>Database</code>和<code>Table</code>的元数据信息，通过特殊编码（给唯一 <code>ID</code>加上<code>m_</code>前缀的方式）来构成元信息的 <code>Key</code>，同时将元信息（表的定义和各种属性）作为<code>Value</code>来存储；另一部分放在 PD Server里，比如当前所有表结构信息的最新版本号，这个是全局的，其 Key 是 “&#x2F;tidb&#x2F;ddl&#x2F;global_schema_version”，Value 是类型为 int64 的版本号值。TiDB 采用 Online Schema 变更算法，有一个后台线程在不断地检查 PD Server 中存储的表结构信息的版本号是否发生变化，并且保证在一定时间内一定能够获取版本的变化。（这部分信息影响的是数据的序列化反序列化，因为是<code>Schema</code>信息，我猜的）。</p><p>整体来讲就是这些内容。总的来说，不同业务场景下都还是会有各种对数据模式进行定义的方式，需要结合场景来寻找最优的数据定义方案和索引方案，TiDB 的实现方案也可以在我们做数据存储系统设计时给我们提供一些参考。类似的设计还有图场景下对图数据结构的建模（比如以前 FB 的 TAO，比如现在各种图数据库）、还有向量数据库里面对向量数据的建模。如何通过底层实现来支持这些场景下的需求，这也是目前数据库开发领域的一个卷的方向。</p>]]></content>
    
    
    <summary type="html">解析 TiDB 如何用 Key-Value 为关系型数据建模：通过 t/r/i 前缀编码表行数据、主键与唯一/非唯一二级索引，兼顾 OLTP 与 OLAP，并介绍 TiKV/RocksDB 底层排序、TiFlash 列存、谓词下推以及 PD Server 元信息与 Online Schema 版本管理。</summary>
    
    
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    
    <category term="数据库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    
    <category term="源码分析" scheme="https://liang7878.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
    <category term="TiDB" scheme="https://liang7878.github.io/tags/TiDB/"/>
    
  </entry>
  
  <entry>
    <title>“《B 站大数据体系建设的技术选型与落地实践》直播总结 -- B站大数据离线平台架构演进与实践”</title>
    <link href="https://liang7878.github.io/posts/c8fa70e0.html"/>
    <id>https://liang7878.github.io/posts/c8fa70e0.html</id>
    <published>2023-10-14T15:31:11.000Z</published>
    <updated>2026-06-29T17:07:30.748Z</updated>
    
    <content type="html"><![CDATA[<p>这篇是上周五 B 站和 deeplus 技术分享的第一个 session，直播的时候我正在上班，基本全程错过，今晚趁这个时间把第一部分先看一下，我们一步一步来。</p><ul><li>离线平台整体架构介绍</li><li>存储架构改造</li><li>多机房架构建设</li><li>资源混部建设</li><li>计算引擎改造</li><li>未来展望和思考</li></ul><p>p.s. 这是在看完视频之后第三遍回顾整个分享的内容，其实分享中的细节很少，但是信息量很大，整个分享基本上涵盖了过去几年 b 站大数据平台的技术演进过程，其中一部分利用了社区的工作，一部分做了定制化的改造或者优化。可惜我本人不是做这个方向，不过很多东西也能够印证我自己工作领域的一些思路。</p><span id="more"></span><h3 id="离线平台整体架构介绍"><a href="#离线平台整体架构介绍" class="headerlink" title="离线平台整体架构介绍"></a>离线平台整体架构介绍</h3><p>首先是 B 站离线平台的整体架构，应用层主要有报表平台、查询平台、数据质量和开发平台，中间服务层通过 Dispatcher 做统一的路由，主要使用了 Presto Gateway（后面对接多套 Presto 集群）、 Kyuubi（处理 Spark 相关的查询） 和 HMS，引擎层使用了 Spark、Hive、Presto 和 Flink。ETL 查询由 Spark 承担，Ad-Hoc 查询通过 Presto。资源层由 Yarn 和 K8S 做资源管理。底层存储使用 HDFS&#x2F;Alluxio。权限管理整体使用Apache Ranger实现。</p><p><img data-src="/offline_arch.png" alt="哔哩哔哩离线数仓架构图"></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">Note: </span><br><span class="line">这里贴一些介绍，给不了解大数据平台的同学（包括我自己）</span><br><span class="line"></span><br><span class="line">Presto, or Presto database (PrestoDB), is an open-source, distributed SQL query engine that can query large data sets from different data sources, enabling enterprises to address data problems at scale. It gives organizations of all sizes a fast, efficient way to analyze big data from various sources including on-premise systems and the cloud. Presto also helps businesses query petabytes of data using their current SQL capabilities, without having to learn a new language.</span><br><span class="line"></span><br><span class="line">Presto 是一个Facebook开源的一套分布式 SQL 查询引擎，可以接入不同的数据源，提供统一的查询体验。这个系统最开始写出来是为了替代 Hive，因为 Hive 在数据量过大的情况下查询效率比较低，无法满足交互式查询的需求，所以 FB 的工程师们开发了 Presto，用于在 HDFS 上执行高效的实时查询。</span><br><span class="line"></span><br><span class="line">Presto-gateway 是在多个Presto集群前的一个有状态Load-balancer，Proxy和Router，它提供了透明的访问方法。</span><br><span class="line"></span><br><span class="line">Kyuubi 是网易数帆大数据团队贡献给 Apache 社区的开源项目。Kyuubi 主要应用在大数据领域场景，包括大数据离线计算、adhoc、BI等方向。Kyuubi 是一个分布式、支持多租户、兼容 JDBC 或 ODBC 的大数据处理服务。</span><br></pre></td></tr></table></figure><p>从<a href="https://dbaplus.cn/news-73-4481-1.html">这篇文章</a>来看，Kyuubi是用来提供多租户能力的。</p><p>这里其实我有一个困惑就是，为什么引擎层要用这么多不同的服务？我搜了一下，有下面这样一个对比的表格：</p><p><img data-src="/hive_spark.png" alt="Hive 与 Spark 作业时序示意图"></p><p>说到底是为了支持不同的查询场景。（还有个猜测是已经上线的业务已经一定程度上被依赖了，所以直接以兼容的方式演进）</p><p>目前 b 站大数据平台整体数据量为 1 EB，2 个机房，万台节点，元数据总量 150 亿，Spark&#x2F;Hive ETL作业数每天 28w，Presto 查询量每天 30w。</p><h3 id="存储架构改造"><a href="#存储架构改造" class="headerlink" title="存储架构改造"></a>存储架构改造</h3><p>存储层面临的挑战：<br>    - 文件数量快速增长导致单个 NS 接近负载极限，数据量增长不可控，集群水位过高，影响用户作业执行效率<br>    - 单一机房无法承担所有节点，异地机房的带宽又受到限制<br>    - 用户任务复杂，无法切割</p><p>这里的优化工作主要有三个方面，提升元数据层扩容效率，推动数据治理和建设多机房体系</p><h3 id="HDFS稳定性建设"><a href="#HDFS稳定性建设" class="headerlink" title="HDFS稳定性建设"></a>HDFS稳定性建设</h3><p>通过 NNProxy统一 NS 视图（之所以没有用 ViewFS 是因为其对客户端的依赖比较重），NNProxy 可以利用其路由管理功能向用户提供统一的完整的目录树服务，在 mount table 中配置路径到集群的映射信息，用户可以无感访问不同name node，对用户透明的 HDFS Federation上线。</p><p>Note: 这里的 NNProxy 说的应该是字节开源的 NameNodeProxy，下面是几种 Hadoop Federation 方案的对比</p><table><thead><tr><th></th><th><strong>NNProxy</strong></th><th><strong>ViewFS</strong></th><th><strong>WebHDFS</strong></th></tr></thead><tbody><tr><td><strong>Multiple language support</strong></td><td>Yes</td><td>No</td><td>Yes</td></tr><tr><td><strong>Unified APIs provided</strong></td><td>Yes</td><td>Yes</td><td>No</td></tr><tr><td><strong>Mount table stored in</strong></td><td>Zookeeper</td><td>Configuration</td><td>Configuration</td></tr><tr><td></td><td></td><td>Client-side configurations are not usually easy to change</td><td></td></tr><tr><td><strong>Client-side library</strong></td><td>No</td><td>Heavy</td><td>Light</td></tr><tr><td><strong>Call latency introduced</strong></td><td>Medium</td><td>No latency</td><td>High</td></tr><tr><td><strong>Centralized cache support</strong></td><td>Yes</td><td>Yes</td><td>No</td></tr></tbody></table><p>第二是元数据拆分，定制开发了 MergeFS，实时写入不同ns，新的数据写入新的 ns，开发了ns balancer支持元数据自动迁移。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line">这里抄一段 B 站自己文章里面对MergeFS 的介绍：</span><br><span class="line"></span><br><span class="line">由于HDFS集群存储数据量的迅猛增长，单个NameSpace已经无法满足元数据量的快速增长，我们在经历了HDFS 联邦机制后扩展成多NameSpace，满足了一段时间的需求。但是随着集群元数据量的指数级增长，特别是小文件数量的猛增，HDFS 联邦机制逐渐无法满足当时的需求。</span><br><span class="line"></span><br><span class="line">为了能够快速新增NameSpace以及让新增的NameSpace迅速接入已有的集群，负载新增的元数据，现有的联邦机制和社区版本的RBF也无法满足当前的要求，我们决定在RBF的基础上，深度定制开发，来解决主节点扩展性问题。</span><br><span class="line"></span><br><span class="line">当时元数据层的压力主要来源于3个方面：</span><br><span class="line"></span><br><span class="line">- 存量元数据数据量大，新增文件数量增长迅猛。</span><br><span class="line"></span><br><span class="line">- 新增NameSpace无法快速进行迁移，迁移效率不足。</span><br><span class="line"></span><br><span class="line">- 大量目录存在实时写入，历史的迁移方式需要停止写入。</span><br><span class="line"></span><br><span class="line">为了解决元数据层扩展能力不足的问题，经过调研社区3.0的HDFS Router和业界相关方案后，我们决定在社区3.3版本的HDFS Router 的基础上，进行定制开发MergeFs来解决集群元数据层扩展性能问题。</span><br><span class="line"></span><br><span class="line">在社区版本的HDFS Router 基础上，定制化开发MergeFS支持元数据迁移，MergeFS 支持按一个挂载点配置2个NameSpace，新写入数据会按规则路由到新增的NameSpace中，但历史数据仍然可见，通过这种方式，我们能迅速扩张新的NameSpace，缓解老NameSpace的写入压力。</span><br><span class="line"></span><br><span class="line">建设了NameSpace Balancer工具，能在业务低峰时期自动化的异步迁移老NameSpace的历史数据到新扩容的NameSpace中，迁移完成后收归掉挂载点，最终实现路径完全迁移到新的NameSpace中。</span><br><span class="line"></span><br><span class="line">基于HDFS元仓，不断分析出增长较快的目录，用于指导哪些数据需要迁移。</span><br><span class="line"></span><br><span class="line">在支持了接入层的MergeFS后，元数据扩张不再成为瓶颈，我们扩容了14组NameSpace，支持了90亿左右的元数据总量，迁移了54亿左右的元数据。与此同时，整个集群的元数据层QPS得到了极大的提升，整体QPS从50K/s上涨到177.8K/s，整个数据迁移工作对上层数据计算任务透明，极大的减少了迁移的工作量，迁移工作量从1人/week，下降到0.1人/week。</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>第三是对稳定性和性能做了优化，稳定性上因为有了 NNProxy，这里做了一个多维度的 RPC 限流（用户、作业、请求类型、 ns），对 Renew Lease 优化，对资源开销较大的操作做了 meta 缓存，另外就是 FileSystemNamespace 和 BlockPool 做了锁的拆分（提高了 50%的 QPS）， 和 INode 锁拆分（预计还能把 QPS 再提升 50%）。</p><p>下图介绍了如何拆分 ns，通过将一个挂载点映射成两个namespace，老的数据写到老的 namespace ，新写的数据写到新的 namespace，接着通过 snapshot 将数据从老的 ns 异步迁移到到新的 ns。同时可以通过元仓分析找出增长较快的目录来做 ns balancer 的迁移。</p><p><img data-src="/merge_fs.png" alt="MergeFS 存储架构图"></p><p>下图是ns拆分的成果，首先 QPS 有了明显的上涨，NS 数量从 4 组扩容到了 30+组。</p><p><img data-src="/merge_fs_result.png" alt="MergeFS 存储节省效果图"></p><p>考虑到 ns 的容量瓶颈，他们上线了 Observer来分担读的压力，基于 RBF 架构，计算引擎通过 CallerContext 透传是否进行 Observer 读请求。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Note：这里我看了一下所谓的 Observer NameNode解决方案，其实本质上就是一个读写分离。</span><br></pre></td></tr></table></figure><p>Alluxio 缓存读加速，热表&#x2F;热分区直接从 Alluxio 读，缓存加速。基于元仓分析出热表，自动添加热表挂载点，加载表&#x2F;分区数据到 Alluxio 集群，上线后热表读性能提升 2-3 倍。</p><p>Note：为什么 Alluxio 可以加速呢？</p><h3 id="HDFS-容量挑战"><a href="#HDFS-容量挑战" class="headerlink" title="HDFS 容量挑战"></a>HDFS 容量挑战</h3><p><img data-src="/hdfs_capacity.png" alt="HDFS 容量规划趋势图"></p><p>首先上线了冷备压缩的解决方法，通过每日分析Fsimage 文件，将 HDFS 审计日志入仓，结合 Hive 的元仓信息得到整个 HDFS 文件信息宽表，为数据治理提供数据指导。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Note: FSImage 是 NameNode 中关于元数据的镜像，一般称为检查点的镜像；会在内存和磁盘中各保存一份；</span><br></pre></td></tr></table></figure><p>通过 EC的解决方案降低存储成本，通过更小的存储副本降低成本，“the default 3x replication scheme in HDFS has 200% overhead in storage space and other resources (e.g., network bandwidth).” “a natural improvement is to use Erasure Coding (EC) in place of replication, which provides the same level of fault-tolerance with much less storage space. In typical Erasure Coding (EC) setups, the storage overhead is no more than 50%.”</p><p>基于 HDFS 元仓自动化数据转化。</p><p>冷数据定义：<br><img data-src="/cold_data.png" alt="冷数据分层存储策略示意图"></p><h3 id="多机房架构建设"><a href="#多机房架构建设" class="headerlink" title="多机房架构建设"></a>多机房架构建设</h3><p>多机房建设的背景</p><p><img data-src="/multi_datacenter.png" alt="多数据中心整体部署架构图"></p><p>解决方案</p><p><img data-src="/multi_datacenter_solution.png" alt="多数据中心解决方案流程图"></p><p>单元化改造</p><p><img data-src="/unit.png" alt="单元化部署拓扑图"></p><p>数据如何迁移？</p><p><img data-src="/data_migration.png" alt="数据迁移流程图"></p><p>数据如何路由？</p><p><img data-src="/data_router.png" alt="数据路由架构图"></p><p>这里提到了可以通过 IP 位置感知来决定将任务路由到对应机房，这部分的工作我没太明白为什么要这么做。</p><h3 id="资源混部建设"><a href="#资源混部建设" class="headerlink" title="资源混部建设"></a>资源混部建设</h3><p>混部架构的背景和挑战</p><p><img data-src="/mix_deployment.png" alt="混合部署方案示意图"></p><p>B站离线平台资源调度侧的主要挑战有两个方面:</p><ol><li><p>随着业务的不断增长，离线集群规模快速膨胀，用户对资源的需求在持续增大，主集群长期处于Pending较高的状态，资源需求超过交付量</p></li><li><p>出于降本增效的考虑，消解Pending的方法不能仅靠物理机的增加了，而是需要在物理机整体数量不变的基础上通过超卖来提升集群整体的资源利用率。</p></li></ol><p>为了应对上述挑战，调度侧在向内与向外两个方向上进行了积极的探索。“向内”聚焦于单台物理机，通过超配的方式不断提高单台物理机的利用率，使得单台节点能够处理更多的任务；“向外”与云平台部门合作，共同探索混部技术的落地，到目前为止，已经完成了离线超配，离在线混部、在离线混部等集群建设以及潮汐混部的技术实现，使得不同集群间的资源能够被更充分地调动。</p><p>超配组件所依赖的主要理论是“用户申请的资源量一般大于用户真实使用的资源量”。根据这个理论，实现超配组件的主要思路是，根据当前机器的实际负载情况，向调度组件虚报一定的资源量，使得更多的任务能够被调度到该台机器上运行。然而，这种做法也带来了一定的风险。在极端情况下，机器上运行的大部分任务的用户申请量会接近于用户真实使用的资源量。这种情况下，超配组件需要及时发现并响应，驱逐一定量的任务以保证机器整体的稳定运行。因此，超配组件必须具备智能管理的能力，能够根据机器实际的负载情况和任务的资源需求，动态调整超配量，以保证机器整体的稳定性和可靠性。同时，超配组件还应该具备良好的容错性和监控机制，能够及时发现和处理机器故障或异常情况，保障业务的连续性和稳定性。总之，超配组件虽然能够带来更高的资源利用率，但也需要合理使用和管理，以避免带来潜在的风险和损失。</p><p><img data-src="/overload.png" alt="集群过载保护流程图"></p><p>潮汐混部</p><p><img data-src="/tide.png" alt="潮汐资源调度曲线图"></p><h3 id="计算引擎改造"><a href="#计算引擎改造" class="headerlink" title="计算引擎改造"></a>计算引擎改造</h3><p><img data-src="/compute_bg.png" alt="计算平台背景结构图"></p><p>升级 Spark，这个过程主要做了一个 AB Test，预估迁移后的收益。</p><p><img data-src="/ab_test.png" alt="A/B 测试平台架构图"></p><p>基于历史信息的任务参数优化</p><p><img data-src="/hbo.png" alt="HBO 资源调度策略示意图"></p><p>数据组织</p><p><img data-src="/data_strcture.png" alt="数据结构优化示意图"></p><p>智能诊断</p><p><img data-src="/auto_diagnose.png" alt="自动诊断平台组件图"></p><p>HMS 优化</p><p><img data-src="/hms.png" alt="Hive Metastore 高可用架构图"></p><h3 id="未来展望和思考"><a href="#未来展望和思考" class="headerlink" title="未来展望和思考"></a>未来展望和思考</h3><p><img data-src="/future.png" alt="后续规划路线图"></p><h3 id="Reference"><a href="#Reference" class="headerlink" title="Reference"></a>Reference</h3><ul><li>[1] <a href="https://hive.apache.org/">https://hive.apache.org/</a></li><li>[2] <a href="https://kyuubi.apache.org/">https://kyuubi.apache.org/</a></li><li>[3] <a href="https://ranger.apache.org/">https://ranger.apache.org/</a></li><li>[4] <a href="https://github.com/bytedance/nnproxy">https://github.com/bytedance/nnproxy</a></li><li>[5] <a href="https://lrting.top/backend/6009/">https://lrting.top/backend/6009/</a></li><li>[6] <a href="https://hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-hdfs/ObserverNameNode.html">https://hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-hdfs/ObserverNameNode.html</a></li><li>[7] <a href="https://hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-hdfs/HDFSErasureCoding.html">https://hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-hdfs/HDFSErasureCoding.html</a></li><li>[8] <a href="http://blog.itpub.net/70027827/viewspace-2954254/">http://blog.itpub.net/70027827/viewspace-2954254/</a></li><li>[9] <a href="https://github.com/lyft/presto-gateway">https://github.com/lyft/presto-gateway</a></li><li>[10] <a href="https://www.cnblogs.com/163yun/p/16833191.html">https://www.cnblogs.com/163yun/p/16833191.html</a></li><li>[11] <a href="https://ahana.io/learn/comparisons/hive-vs-presto-vs-spark/">https://ahana.io/learn/comparisons/hive-vs-presto-vs-spark/</a></li><li>[12] <a href="https://dbaplus.cn/news-73-4481-1.html">https://dbaplus.cn/news-73-4481-1.html</a></li><li>[13] <a href="https://bilibili.com/read/cv15897101/">https://bilibili.com/read/cv15897101/</a></li></ul>]]></content>
    
    
    <summary type="html">这篇是上周五 B 站和 deeplus 技术分享的第一个 session，直播的时候我正在上班，基本全程错过，今晚趁这个时间把第一部分先看一下，我们一步一步来。</summary>
    
    
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/tags/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    <category term="大数据" scheme="https://liang7878.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
    <category term="OLAP" scheme="https://liang7878.github.io/tags/OLAP/"/>
    
    <category term="数据仓库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E4%BB%93%E5%BA%93/"/>
    
    <category term="广告" scheme="https://liang7878.github.io/tags/%E5%B9%BF%E5%91%8A/"/>
    
    <category term="查询" scheme="https://liang7878.github.io/tags/%E6%9F%A5%E8%AF%A2/"/>
    
  </entry>
  
  <entry>
    <title>读腾讯技术工程《一文讲透 Redis 分布式锁安全问题》</title>
    <link href="https://liang7878.github.io/posts/5a8a6c8d.html"/>
    <id>https://liang7878.github.io/posts/5a8a6c8d.html</id>
    <published>2023-10-11T13:59:46.000Z</published>
    <updated>2026-06-29T17:07:30.754Z</updated>
    
    <content type="html"><![CDATA[<h2 id="为什么需要分布式锁？"><a href="#为什么需要分布式锁？" class="headerlink" title="为什么需要分布式锁？"></a>为什么需要分布式锁？</h2><p>在单机环境下编写多线程程序时，为了避免多个线程同时操作同一个资源，我们往往会通过加锁来实现互斥，以保证同一时间只有一个操作者对某个资源执行操作，在单机多进程的情况下，如果们想操作同一个共享资源，我们也可以通过操作系统提供的文件锁和心好凉来实现互斥，这些都是单台机器上的操作。而在分布式环境下，如果不同机器上的不同进程需要同时操作某一个共享资源，我们同样也需要这样一个统一的锁来实现互斥。这个时候，我们就需要一个平台来提供这样一个互斥的能力，通常我们会采用一些能够提供一致性的服务，比如 ZooKeeper、 etcd 来满足对一致性要求较高的场景下的互斥需求，当然，也有些服务会用数据库，比如 MySQL，来实现互斥，然而在某些高并发业务场景下，我们通常会采用 Redis来实现。</p><span id="more"></span><h2 id="如何使用-Redis-来实现分布式锁？"><a href="#如何使用-Redis-来实现分布式锁？" class="headerlink" title="如何使用 Redis 来实现分布式锁？"></a>如何使用 Redis 来实现分布式锁？</h2><p>最简单的方式，使用 SETNX 命令，这个命令会实现 Set if not exist，在执行时，只有成功设置值才会返回 1，否则返回0。那么在某个客户端通过 SETNX 加锁之后，其他的客户端无法加锁。之后释放锁可以通过 DEL 命令删除这个 key 即可。这种方式存在的问题在于，如果加锁成功的客户端没有正确释放锁，那么其他客户端就有可能永远拿不到这个锁，这就是“锁饥饿”。</p><p>那么如何避免这种情况呢？最简单的方法就是加一个过期时间（lease，租期），预估加锁后执行操作的时间，在达到该时间后如果持有锁的客户端没有释放锁，那么锁就被自动释放。在 Redis 里面实现时，我们可以给这个 key 加一个过期时间，那么无论客户端是否能够主动释放锁，只要一到过期时间，这个 key 就会被自动清理，锁就会自动释放。</p><p>在老的 Redis 版本中，SETNX 操作和 Expire 操作是两条命令，这样就有可能出现我们加租期的操作没法正常完成，从而还会存在无法自动释放锁的情况。而在新的 Redis 版本中，Redis 扩展了 SET 命令，可以直接在 SET 时指定 EX（Expire，过期时间）和 NX（If not exist，如果不存在才 SET），这样就保证了加锁和设置自动释放这两个操作的原子性。</p><p>这里提到了另外一个情况，如何保证加锁和解锁是由同一个客户端来执行，其实很简单，加锁的时候加个唯一标志就行了，可以直接写个 lua 脚本来实现。“因为 Redis 处理每一个请求是「单线程」执行的，在执行一个 Lua 脚本时，其它请求必须等待，直到这个 Lua 脚本处理完成，这样一来，GET + DEL 之间就不会插入其它命令了。”</p><h2 id="Redis-集群下的分布式锁"><a href="#Redis-集群下的分布式锁" class="headerlink" title="Redis 集群下的分布式锁"></a>Redis 集群下的分布式锁</h2><p>前面提到的场景完全是单个 Redis 实例下的情况，在使用 Redis 时，我们往往采用主从集群+哨兵的模式来部署，在主节点异常时，哨兵可以实现自动切换，把从库转为主库，这个过程中存在一个问题，如果加锁时的写入操作没有同步到从库时主库就挂了，那这个锁就会在主从切换时丢失，怎么解决这个问题呢？这里提到了 Redlock（红锁）方案。</p><p>Redlock 的方案基于两个前提：1. 不再部署从库和哨兵实例，只部署主库；2. 主库要部署多个，官方推荐至少 5 个实例。这里部署的 Redis 就不是什么集群了，就是五个相互独立的 Redis 实例。Redlock 主要有五个步骤：</p><ol><li>客户端先获取「当前时间戳 T1」</li><li>客户端依次向这 5 个 Redis 实例发起加锁请求（用前面讲到的 SET 命令），且每个请求会设置超时时间（毫秒级，要远小于锁的有效时间），如果某一个实例加锁失败（包括网络超时、锁被其它人持有等各种异常情况），就立即向下一个 Redis 实例申请加锁</li><li>如果客户端从 &gt;&#x3D;3 个（大多数）以上 Redis 实例加锁成功，则再次获取「当前时间戳 T2」，如果 T2 - T1 &lt; 锁的过期时间，此时，认为客户端加锁成功，否则认为加锁失败</li><li>加锁成功，去操作共享资源（例如修改 MySQL 某一行，或发起一个 API 请求）</li><li>加锁失败，向「全部节点」发起释放锁请求（前面讲到的 Lua 脚本释放锁）</li></ol><p>听描述，这里居然是顺序发起加锁请求，我看了一下原文档描述也是加锁 sequentially，不过这里其实 in parallel 的话是不是效率更高呢？</p><p>整个方案的逻辑其实挺简单，加锁时取大多数，通过时间戳进行加锁超时检查，解锁时全量解锁。文中介绍了一下 Redis 的作者 Antirez 和分布式专家 Martin Kleppmann 关于 Redlock 的讨论中的一些重点，我们可以跟着过一遍：</p><p><strong>首先是 Martin 对于 Redlock 的质疑</strong></p><p>使用分布式锁的目的是什么？Martin 认为一种是为了效率，避免重复的工作浪费资源，这种情况即使出现锁失效也无伤大雅，第二种是为了正确性，避免并发场景下多方操作同一份数据导致的数据错误、丢失等问题。如果是为了效率，那么用原始方案就行了，偶尔锁失效也没事儿，但是如果是为了正确性，Redlock 是无法满足足够的安全性要求。</p><p>锁在分布式场景下会遇到什么问题呢？其实就是分布式系统中的三个主要问题：NPC（N：Network Delay，网络延迟，P：Process Pause，进程暂停（GC）C：Clock Drift，时钟漂移）这里举了一个例子，在某个客户端拿到锁之后如果发生进程暂停，比如 GC，那么在 Redis 上所有锁过期之后，其他客户端仍然会重复加锁，发生锁冲突。</p><p>另一方面，这个机制对时钟的正确性有强依赖，但事实上 time drift 是在数据中心里面经常发生的情况。下面是反例：</p><ol><li>客户端 1 获取节点 A、B、C 上的锁，但由于网络问题，无法访问 D 和 E</li><li>节点 C 上的时钟「向前跳跃」，导致锁到期</li><li>客户端 2 获取节点 C、D、E 上的锁，由于网络问题，无法访问 A 和 B</li><li>客户端 1 和 2 现在都相信它们持有了锁（冲突）</li></ol><p>于是 Matrin 提出了一个 fecing token 的方案，来保证正确性，流程如下：</p><ol><li>客户端在获取锁时，锁服务可以提供一个「递增」的 token</li><li>客户端拿着这个 token 去操作共享资源</li><li>共享资源可以根据 token 拒绝「后来者」的请求</li></ol><p>Martin 表示，一个好的分布式锁，无论 NPC 怎么发生，可以不在规定时间内给出结果，但并不会给出一个错误的结果。也就是只会影响到锁的「性能」（或称之为活性），而不会影响它的「正确性」。</p><p>Martin 的结论：<br>1、Redlock 不伦不类：它对于效率来讲，Redlock 比较重，没必要这么做，而对于正确性来说，Redlock 是不够安全的。<br>2、时钟假设不合理：该算法对系统时钟做出了危险的假设（假设多个节点机器时钟都是一致的），如果不满足这些假设，锁就会失效。<br>3、无法保证正确性：Redlock 不能提供类似 fencing token 的方案，所以解决不了正确性的问题。为了正确性，请使用有「共识系统」的软件，例如 Zookeeper。</p><p>下面是 Redis 作者 Antirez 的回应：</p><p>首先是时钟问题，Redlock 并不要求准确的时钟，只要时钟误差不要超过锁失效时间即可。关于进程暂停的问题，如果进程暂停发生在检查时间戳差值的步骤之前，那么在检查时间戳时就能发现锁已经失效，如果发生在其之后，那么其他锁服务也会遇到同样的问题，这并不是 Redlock 本身的问题。</p><p>对于 fencing token 的方案，Redis 作者提出了两个问题，第一，这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。第二，即使 Redlock 没有提供 fencing token 的能力，但 Redlock 已经提供了随机值，利用这个随机值，也可以达到与 fencing token 同样的效果。大概流程如下：</p><ol><li>客户端使用 Redlock 拿到锁</li><li>客户端在操作共享资源之前，先把这个锁的 VALUE，在要操作的共享资源上做标记</li><li>客户端处理业务逻辑，最后，在修改共享资源时，判断这个标记是否与之前一样，一样才修改（类似 CAS 的思路）</li></ol><p>相比 fencing token，这个方法只能保证互斥，并不能保证操作的顺序性。</p><h2 id="基于-ZooKeeper-的分布式锁"><a href="#基于-ZooKeeper-的分布式锁" class="headerlink" title="基于 ZooKeeper 的分布式锁"></a>基于 ZooKeeper 的分布式锁</h2><p>文章下面介绍了基于 ZooKeeper 的分布式锁。</p><ol><li>客户端 1 和 2 都尝试创建「临时节点」，例如 &#x2F;lock</li><li>假设客户端 1 先到达，则加锁成功，客户端 2 加锁失败</li><li>客户端 1 操作共享资源</li><li>客户端 1 删除 &#x2F;lock 节点，释放锁</li></ol><p>这里使用的是临时节点，要求客户端和 ZK 的连接不断开，只要断开，临时节点就会自动删除，从而释放锁。这里本质上是什么呢？本质上其实是 ZK 的客户端通过不断的心跳来维持 session，从而实现 lease 的续约。但是这里仍然也存在进程暂停导致的锁失效的问题。</p><h2 id="基于-etcd-的分布式锁"><a href="#基于-etcd-的分布式锁" class="headerlink" title="基于 etcd 的分布式锁"></a>基于 etcd 的分布式锁</h2><p>实现流程如下：</p><ol><li>客户端 1 创建一个 lease 租约（设置过期时间）</li><li>客户端 1 携带这个租约，创建 &#x2F;lock 节点</li><li>客户端 1 发现节点不存在，拿锁成功</li><li>客户端 2 同样方式创建节点，节点已存在，拿锁失败</li><li>客户端 1 定时给这个租约「续期」，保持自己一直持有锁</li><li>客户端 1 操作共享资源</li><li>客户端 1 删除 &#x2F;lock 节点，释放锁</li></ol><p>这个 lease续期 和 ZK 里面的心跳类似，这里就不多赘述。其实本质上也会受到上面问题的影响。</p><p>但是从功能层面而言，ZK 和 etcd 能够提供 Watch 机制，即监听节点的变化，从而感知锁的状态。</p><p>作者最后介绍了一下自己的理解，从应用层面，作者个人倾向于常规 Redis 主从+哨兵的模式来实现分布式锁，正确性可以参考 fencing token 的实现，通过业务层面进行兜底，可以通过版本号（乐观锁）来避免锁失效时的数据错误。</p>]]></content>
    
    
    <summary type="html">在单机环境下编写多线程程序时，为了避免多个线程同时操作同一个资源，我们往往会通过加锁来实现互斥，以保证同一时间只有一个操作者对某个资源执行操作，在单机多进程的情况下，如果们想操作同一个共享资源，我们也可以通过操作系统提供的文件锁和心好凉来实现互斥，这些都是单台机器上的操作。而在分布式环境下，如果不同机器上的不同进程需要…</summary>
    
    
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    
    <category term="数据库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    
    <category term="编程进阶" scheme="https://liang7878.github.io/tags/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="分布式" scheme="https://liang7878.github.io/tags/%E5%88%86%E5%B8%83%E5%BC%8F/"/>
    
  </entry>
  
  <entry>
    <title>读《云原生分布式存储基石：etcd 深入解析》</title>
    <link href="https://liang7878.github.io/posts/a2161837.html"/>
    <id>https://liang7878.github.io/posts/a2161837.html</id>
    <published>2023-10-09T15:59:02.000Z</published>
    <updated>2026-06-29T17:07:30.727Z</updated>
    
    <content type="html"><![CDATA[<p>最近一个月时间一直都在忙着搬家，国庆又带老婆回家待了几天，一直都没什么时间静下心来看看书，终于国庆过完了，又开一个新坑。这本书是华为做容器服务的团队写的，etcd 的源码大家多多少少都看过一些，这本书系统过了一下整个 etcd 的设计和一些重要的设计亮点和实现原理，对于我们自己对 etcd 的理解也能有一个梳理和印证。</p><p>本书分为三个大部分，分布式基础、 etcd 实战以及源码分析，总共十二章。</p><h2 id="分布式基础"><a href="#分布式基础" class="headerlink" title="分布式基础"></a>分布式基础</h2><p>分布式系统的设计目标主要有以下几个方面:</p><ul><li>可用性：可用性是分布式系统的核心需求，其用于衡量一个分布式系统持续对外提供服务的能力。可用性方面，主要是通过系统冗余（备份、多节点）来保证容灾。</li><li>可扩展性：增加机器后不会改变或极少改变系统行为，并且能获得近似线性的性能提升。这一点对于业务增长来说十分重要。</li><li>容错性：系统发生错误时，具有对错误进行规避以及从错误中恢复的能力。数据中心能够出现的错误往往超过你的想象，怎么在出现错误时提供稳定的服务，这一点在设计时也需要深入思考。</li><li>性能：对外服务的响应延时和吞吐率要能满足用户的需求。这点就不多说了，尤其是 ToC的业务，系统响应往往决定了用户的留存。</li></ul><p>这里又要老生常谈 CAP 了，我们复习一下，CAP 分别是一致性（Consistency）、可用性（Availability）和分区容忍性（Tolerance to the partition of network）。分布式环境下网络分区是常态，所以分布式系统往往是AP 或者 CP，要么提高可用性降低一致性需求，要么要求强一致性但是可用性会降低。一般除了一些特定的场景（比如金融业务）会要求 CP，其他基本都是以 AP 为主，满足最终一致性即可。</p><p>我们重点聊聊一致性，首先什么是一致性，在分布式场景下，用通俗的话来说，就是怎么保证多个副本的数据是否一致，或者说系统对外呈现的状态是否一致。对于一致性，可以分别从客户端和服务端两个不同的视角来理解。从客户端来看，一致性主要是指多并发访问时如何获取更新过的数据的问题。从服务端来看，则是更新如何复制分布到整个系统，以保证数据最终的一致性。因此，可以从两个角度来查看一致性模型：以数据为中心的一致性模型和以用户为中心的一致性模型。</p><p>以数据为中心的一致性模型常见的四种语义如下（强 → 弱）：</p><ol><li><strong>强一致性（Strong Consistency）</strong>：任意时刻所有副本对读操作都返回最新写入的值，etcd 通过 Raft 日志同步来实现这一点。</li><li><strong>顺序一致性（Sequential Consistency）</strong>：保证所有操作遵守同一全序，但写入值传播可延迟，读到旧数据是允许的。</li><li><strong>因果一致性（Causal Consistency）</strong>：只要求具有因果关系的操作按顺序传播，独立事件的顺序可以不同副本各自决定。</li><li><strong>可串行化一致性（Serializable Consistency）</strong>：所有操作可映射为某个串行执行次序，常用于数据库事务模型。</li></ol><p>从用户角度，还可以定义读己之所写（Read-your-writes）、会话一致性（Session Consistency）等保证，对 API 设计和客户端缓存策略很重要。</p><p>以用户为中心的一致性模型主要是指实际业务需求中需要满足针对某个用户业务的一致性，比如最终一致性，即允许一个不一致性窗口，只要最终所有副本达到一致性即可。</p><p>当同一份数据存在多个副本时，我们通过复制状态机来管理它们。复制状态机（Replicated State Machine）是一种分布式系统的设计模式，用于确保在多个节点上保持相同的状态副本。在复制状态机模型中，多个服务器节点通过相互通信和协作来保持一致的状态，并且客户端可以向任意一个节点发送请求，系统会确保处理请求的顺序和结果在所有节点上都是一致的。</p><p>复制状态机的核心思想是将分布式系统中的状态看作是一个状态机，这个状态机会接收客户端的请求并且按照特定的顺序进行处理，从而达到一致的状态。为了实现复制状态机，通常会使用一种称为共识算法（Consensus Algorithm）的技术，例如Paxos、Raft等。这些共识算法可以确保系统中的节点在面对故障和网络分区等问题时，依然能够保持一致的状态。</p><p>FLP（Fisher, Lynch, Paterson）定理是分布式计算领域的一个基本定理，指出在异步网络中，当存在一个或多个节点发生故障时，不可能设计一个算法，能够在有限的时间内，保证所有节点对一个共享的变量（例如确定某个决策）达成一致的协议。简而言之，FLP定理指出，在异步网络中，不可能同时满足一致性（Consistency）、可用性（Availability）和分区容忍性（Partition Tolerance）这三个分布式系统设计的基本要求。</p><p>具体来说，FLP定理的内容是：在一个异步网络中，如果有一个或多个节点可能发生故障，且网络不提供时钟同步（即消息传递没有上限，消息可能在任何时间间隔内被传递），那么在有限的时间内不可能设计一个分布式算法，能够确保所有节点对某个共享的变量达成一致的协议。</p><p>这个定理的结论有重要的实际意义，它告诉我们，在异步网络环境下，无法设计一个能够在所有情况下都保证一致性的分布式算法。因此，在实际系统设计中，需要根据具体需求和系统特性，权衡一致性、可用性和分区容忍性，选择适当的系统模型和算法。根据FLP定理，实际的一致性协议（Paxos、Raft等）在理论上都是有缺陷的，最大的问题是理论上存在不可终止性！至于Paxos和Raft协议在工程的实现上都做了哪些调整（例如，Paxos和Raft都通过随机的方式显著降低了发生算法无法终止的概率），从而规避了理论上存在的哪些问题，下文将会有详细的解释。</p><p>书中接下来讲到了 Paxos 、 Raft算法，这两个协议在网上都有大量的介绍，我们不多赘述，只简单说一下 Raft，Raft把一致性问题分解成了领袖选举（leader election）、日志复制（log replication）、安全性（safety）和成员关系变化（membership changes）这几个子问题，我们在面试时候如果聊到 Raft 可以从这几个角度逐一介绍。</p><h2 id="实战篇"><a href="#实战篇" class="headerlink" title="实战篇"></a>实战篇</h2><p>为什么使用 etcd？在聊到 etcd 的时候，我们往往会聊到类似的其他系统，比如 zookeeper，与 zookeeper 相比，etcd 更加稳定可靠。在服务发现的实现上，etcd使用的是节点租约(Lease)，并且 支持Group(多key);而ZooKeeper使用的是临时节点。etcd支持稳定的watch，而不是ZooKeeper一样简单的单次触发式 (one time trigger)watch。etcd支持MVCC(多版本并发控制)，因为有协同系统需要无锁操作。 etcd 支持更大的数据规模，支持百万到千万级别的 key。 etcd 性能更好，在 3 台八核云服务器上部署的 etcd v3 可以实现每秒数万次的写操作和数十万次的读操作。</p><p>etcd 的官方定义是“A distributed, reliable key-value store for the most critical data of a distributed system.”本质上是为了提供可靠的分布式键值存储，用来存储分布式系统中的最关键的数据。 etcd 的常见使用场景有：服务发现、分布式锁、分布式数据队列、分布式通知和协调、主备选举等。（p.s.我自己就是做服务发现相关的业务，不过是在一个没有强一致性的环境，在这种场景下怎么保证服务发现的一致性其实也是一个很有意思的问题，我们在生产环境做了大量的 workaround，这个以后有机会可以聊一下）</p><p>etcd 基于 Raft 协议，通过日志复制来保证数据的强一致性。写请求会先提交到 Leader，写入 WAL 后再复制到其他成员，只有在多数派确认后才算提交。etcd 能够容忍集群中 <code>(n-1)/2</code> 个节点故障。为了平衡磁盘使用：</p><ul><li>每条写入都会进入 WAL；默认每 10,000 条写操作触发一次快照，快照完成后旧 WAL 可以清理。</li><li>后台还有 <code>compactor</code> 周期性移除过旧的 MVCC 版本，降低 Watch 需要追溯的历史长度。</li></ul><h2 id="实战篇的操作建议"><a href="#实战篇的操作建议" class="headerlink" title="实战篇的操作建议"></a>实战篇的操作建议</h2><ol><li><strong>租约与 KeepAlive</strong>：用 Lease 管理临时节点（服务发现、分布式锁）。客户端要妥善处理续约失败与重连逻辑。</li><li><strong>Watch 最佳实践</strong>：Watch 首次返回当前 <code>revision</code>，随后才流式推送增量更新；如果消费端落后到 <code>compact revision</code> 之前，要自动补全全量同步。</li><li><strong>读写路径取舍</strong>：强一致读（<code>serializable=false</code>）需访问 Leader，延迟敏感场景常通过代理缓存或使用只读副本。</li><li><strong>备份恢复</strong>：使用 <code>etcdctl snapshot save/restore</code> 搭配定期 <code>compact</code>；快照流程运行期间要关注磁盘写放大的影响。</li></ol><h2 id="源码分析亮点"><a href="#源码分析亮点" class="headerlink" title="源码分析亮点"></a>源码分析亮点</h2><ul><li><strong>Raft 内核</strong>：<code>raft</code> 包将网络&#x2F;存储解耦，RawNode 暴露 <code>Ready</code> 结构，上层负责发送消息、落盘和应用状态机。</li><li><strong>MVCC 存储</strong>：<code>mvcc.Store</code> 将 Key 版本化 (<code>key + revision</code>)，为事务、历史查询和 Watch 提供基础；底层使用 BoltDB B+Tree。</li><li><strong>API 层调度</strong>：gRPC 服务入口会把请求放入 <code>apply</code> 协程队列，串行执行保证状态机一致；<code>WatchableStore</code> 负责向订阅者推送事件。</li><li><strong>集群管理</strong>：Learner 节点帮助平滑扩容；成员变更采用两阶段：先添加 Learner 同步历史，再升级成投票节点。</li></ul><h2 id="读完后的-Checklist"><a href="#读完后的-Checklist" class="headerlink" title="读完后的 Checklist"></a>读完后的 Checklist</h2><ul><li>监控指标：<code>etcd_disk_wal_fsync_duration_seconds</code>、<code>etcd_network_client_grpc_received_bytes_total</code>、<code>db_total_size_in_bytes</code> 等用来判断磁盘&#x2F;网络瓶颈。</li><li>线上巡检：对 <code>slow fdatasync</code> 日志和 <code>apply took too long</code> 告警保持敏感，必要时扩容或调整 compaction。</li><li>客户端 SDK：Watch&#x2F;Lease 都要考虑断线重连、指数退避；事务必须配合幂等写或补偿逻辑，避免重复提交。</li></ul><p>整体而言，这本书的价值在于把分布式基础 → 场景实践 → 源码细节串成一条线，有助于构建自己的 etcd 心智模型。读完后再回头看官方文档或源码，会更容易抓住重点。</p>]]></content>
    
    
    <summary type="html">《云原生分布式存储基石：etcd 深入解析》读书笔记，串联分布式基础到源码：CAP/FLP 与一致性模型、复制状态机与 Raft/Paxos 共识，etcd 的 Lease 租约、Watch、MVCC、WAL 与快照 compaction，以及 raft 内核、BoltDB 存储、Learner 成员变更等源码亮点与运维监控实践。</summary>
    
    
    
    <category term="技术书籍" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E4%B9%A6%E7%B1%8D/"/>
    
    
    <category term="数据库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="源码分析" scheme="https://liang7878.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
  </entry>
  
  <entry>
    <title>读《程序员的 README》</title>
    <link href="https://liang7878.github.io/posts/11e2c87a.html"/>
    <id>https://liang7878.github.io/posts/11e2c87a.html</id>
    <published>2023-09-09T13:58:30.000Z</published>
    <updated>2026-06-29T17:07:30.743Z</updated>
    
    <content type="html"><![CDATA[<p>最近在挺多地方见到网友推荐这本书，看到微信阅读上面已经有这本书的中文版，抽了点时间看了一下，都说这本书常读常新，我读下来感觉其中的一些观点的确对于程序员的成长有一定的指导作用，尤其是对于新进入职场的新手程序员和一些瓶颈期的程序员，与我工作这几年所沉淀的方法论也有一定的重合。我们系统过一遍，看看书中是如何指导程序员的入门和成长的。</p><p>我们按照以下几个部分来过一遍这本书：</p><ul><li>整体发展路径</li><li>如何步入自觉阶段</li><li>如何做好项目变更</li><li>如何写好代码</li><li>如何管理依赖项</li><li>如何进行测试</li><li>Code Review</li><li>软件交付</li><li>On-Call</li><li>技术设计流程</li><li>构建可演进的架构</li><li>敏捷开发</li><li>如何与管理者合作</li><li>职业生涯规划</li></ul><span id="more"></span><h2 id="整体发展路径"><a href="#整体发展路径" class="headerlink" title="整体发展路径"></a>整体发展路径</h2><p>对于一个工程师而言，想晋级的话我们必须具备以下四个方向的能力：技术知识 （立身之本，我们需要理解我们的系统，熟悉如何写代码、如何在线上维护我们的代码）、执行力（如何用技术解决问题、如何 On-Call，经常参加技术讲座、阅读小组等）、沟通能力（很多人觉得程序员只要写好代码就好了，但是如何以建设性的方式定义问题、提出问题，如何帮助别人解决问题，如何文档化我们的工作，如何撰写设计文档并征求反馈意见等，这些问题对于一个程序员的精进非常重要）、领导力（从新手程序员到技术专家甚至技术经理的重要能力，我们需要能够在一定的工作范围能自主独立完成工作，处理好各类问题，积极参与项目和季度的规划，帮助团队内其他成员成长等，这些对于我们构建自己影响力都十分重要）。</p><p>对于任何一个新人而言，我们可以做的最简单的事情是将我们所经历的（或者说所观察到的、学习到的）流程、内容等各类东西文档化，这样不仅能够帮助我们熟悉整个团队的工作流程，同时也可以帮助我们构建自己的影响力。同时要和 manager 确认我们参加团队和公司的各种重要会议，以获取相应的信息。（公司的组织架构决定了决策信息是自上而下流动的，获取更多的信息能够拥有超过周围人的视野，以做出更加符合公司导向的决策，进而获得各种机会）</p><p>这里提到两个点：坎宁安定律和自行车棚效应，坎宁安定律（Cunningham’s law）是说“在互联网上获得正确答案的方式不是问问题，而是发布一个错误的答案”。团队里的同事在看到你的文档之后会帮忙修正一些内容，“重点并不是要写一份完美的文档，而是要写得足够多，以引发讨论，充实细节”。我们不应该把这些评论和纠正当做对我们个人的批评。（这里其实有一个小问题，涉及到我们个人的声誉，即我们写出来的东西是否经得起一定的推敲，如果写了过多错误的内容，这对我们个人的声誉也会有一定的影响，所以我们可以有理解的错误，但我们不应该有过多“明显的”、“粗浅的”、“因为不细心而导致的”错误，这个是我的个人观点，书上并没有提到这些）。另一个是自行车棚效应（bike-sheddling, Law of triviality），团队往往对于真正重要的决定草草通过，而对于一些无关紧要的事情吹毛求疵。</p><p>在你的成长过程中，持续学习是至关重要的。了解如何编译、测试和部署代码。阅读那些提交代码的请求和代码评审意见。不要害怕询问更多的信息。多报名参加技术讲座、午餐会、阅读小组、导师计划，诸如此类。多和老板 1:1，理解老板的期望并谈谈你自己的目标，这些都很重要。</p><p>我们要积极参与各种代码 review 、项目设计和季度规划，这些有助于我们获取更多的信息，进而增加自己的影响力。我们经常出现一些情况，参加了一个会议，但我们并没有真正的参加，即一场会议下来我们并不知道发生了什么，这样既浪费了时间也浪费了我们获取信息的机会。</p><p>我们要学习如何交付代码，学习如何维护自己在线上的 feature，这个过程能够帮助我们理解我们的业务场景，理解客户的需求是什么。</p><p>接着我们会开始负责一个小的项目，做项目规划，设计相对复杂的系统，这个过程中我们要沉淀对于团队或者公司的工作流的观察和见解，思考哪些是有效的、哪些是可改进的、哪些是无效的，和老板们谈谈自己的看法，同时也可以和老板们谈谈自己的长期规划。</p><p>完成上面这些步骤，我们就可以说我们在团队内能够胜任一些工作，贡献自己的价值了。从我个人理解而言，能够稳定达到以上表现，基本就是 senior 及以上的工程师水平了，后续的进一步发展也只是在这些的基础上进一步加深和扩宽自己的领域，基本的方法论是不变的。</p><h2 id="如何步入自觉阶段"><a href="#如何步入自觉阶段" class="headerlink" title="如何步入自觉阶段"></a>如何步入自觉阶段</h2><p>能力的4个阶段：“无意识的无能力”(unconscious incompetence)、“有意识的无能力”(conscious incompetence)，“有意识的有能力”(conscious competence)和“无意识的有能力”(unconscious competence)。我想大多数新手工程师都是在第二阶段甚至是第一阶段，两者的区别在于是否知道自己的努力方向。</p><p><strong>软件工程领域在不断地发展。无论你是一名刚毕业的学生还是一名经验丰富的老手，如果你不学习，你就会落后。</strong></p><p>每周都花一些时间去阅读团队文档、设计文档、代码、work item、书籍、论文和技术网站。在读代码时，如果可以的话我们应该尽量使用 IDE 去读，通过调试来跟踪和理解代码的运行逻辑。通过读团队的文档、work item 和 PR，我们可以知道整个团队目前在做什么工作。</p><p>如何提出问题对于个人成长也十分重要，我们使用这样三个步骤：做研究，提出明确的问题，并恰当地安排解决你的问题所需的时间。首先我们必须要自己研究一下，在自己设定的时间限制之内如果没有解决问题，我们可以记录下我们研究的整个过程，在提问时提供给对方，这样对方能知道你做出了足够的努力，也能了解到你到底是哪里没有弄清楚。注意提问时不要打扰他人，通过邮件来提问是一个不错的方式，你也可以礼貌地发一个消息给对方，在一定时间内没得到回复的话，可以友好地 ping 一下。反过来，如果别人在向你提问时，你也可以尽量集中批量处理，避免不断地被别人打断，而浪费自己的大块时间。</p><p>有两种典型的成长障碍：冒充者综合征和邓宁-克鲁格效应。</p><p>患有冒充者综合征的人无法将自己的成功归因于自己的能力，并总是担心有朝一日会被他人识破自己其实是骗子这件事。他们坚信自己的成功并非源于自己的努力或能力，而是凭借著运气、良好的时机，或别人误以为他们能力很强、很聪明，才导致他们的成功。对于这种情况，我们应该多和别人交流，获取正面的反馈，这样慢慢就能够意识到自己的成绩，并建立信心。</p><p>邓宁-克鲁格效应（Dunning-Kruger effect）则完全相反，即过于自信，认为自己的想法是正确的，批评公司的技术栈，即井底之蛙。解决的办法有很多：保持开放的心态，多和大佬交流，在遇到不同意见时多问问为什么会有这样的决策，培养权衡利弊的心态，而不是非黑即白。我自己的经验是，我们依然可以利用上面的坎宁安定律，多输出自己的观点和想法，这样能够在与别人交流的过程中意识到自己的不足。</p><h2 id="如何做好项目变更"><a href="#如何做好项目变更" class="headerlink" title="如何做好项目变更"></a>如何做好项目变更</h2><p>大家如果看看各个公司的内部代码就会意识到，企业的代码质量是参差不齐的，大型项目的代码用“屎山”来形容毫不为过。原因是什么？我们可以理解一下软件的熵（software entropy）这个概念，即软件代码会慢慢走向无序的状态。原因有很多，某些程序员代码写的不好或者业务场景不断变化等。我们可以通过一些方法保持代码的整洁，避免熵增，比如代码风格检测等。</p><p>另一个则是技术债（technical debt），经常出现的情况是我们现在要赶着交付一个项目了，所以某些地方就不得不妥协，或者说我们在上线之前并不知道某些场景，所以在写代码时候没考虑到。其实这些都是无可避免的，但是我们可以想办法解决。讨论技术债的模版如下：<br>1.按事实陈述情况；<br>2.描述技术债的风险和成本；<br>3.提出解决方案；<br>4.讨论备选方案（不采取行动也是备选方案）；<br>5.权衡利弊。<br>说白了，有些技术债不一定要解决，取决于解决它带来的收益是什么。</p><p>在做代码变更的时候，我们要保证几点：</p><ol><li>在做变更时要为我们变更的功能增加测试，小步前进。</li><li>保证过手的代码要比之前更干净。</li><li>不要一次性做很大的修改，最好慢慢修改，多提交PR，慢慢来，这样既方便 review，又可以避免忽略某些问题，捅大娄子。我个人其实犯过这个错误，在写项目的时候，一个大 PR 看起来是很厉害，但其实一堆小 PR 才更好 track 和 review。</li><li>不要为了重构而重构，重构一定要能带来价值，有些代码能干掉的就不要留下来。</li><li>用好 git（也有的是 svn），用一个标准模版来提交我们的 PR。如果没有既定的规则，可以遵循克里斯·比姆斯的建议。用一个空行将标题与正文分开。标题行限制在50个字符以内。标题行要大写。不要以句号结束标题行。在标题行中使用命令式语气。将正文限制在72个字符之内。用正文解释修改的内容和原因，而不解释如何修改。</li></ol><p>做技术的人很本能地追求最新的技术，但是在做技术选型时我们应该尽量保守，选择比较成熟的方案。不要追求特立独行而特立独行，要学会思考和理解公司内的实现方案，思考其现状的原因，这样才不会无缘无故地搞一些自定义方案。对重构保持谨慎，思考清楚之后再做重构，因为重构的过程往往会超过我们的预期。</p><h2 id="如何写好代码"><a href="#如何写好代码" class="headerlink" title="如何写好代码"></a>如何写好代码</h2><p>写代码这部分其实没什么技术内容，主要是编程习惯。</p><p>要做好防御性编程，主要包括：</p><ul><li>避免空值</li><li>对不可变的变量要及时声明</li><li>做好类型检查</li><li>对输入进行验证，我的经验是，最好所有方法都验证一下（哪怕是 private 方法，因为 private 方法可能被后面在当前文件内做变更的人调用到）</li><li>用好异常，异常能够返回很多信息，在抛异常和抓异常时最好都有明确定义，这样会安全一些</li><li>“早抛晚捕”，就是说尽可能在接近错误的地方抛异常</li><li>退避重试，不要不停地重试，尽量以退避的方式重试，避免不断重试，另外为了避免惊群，要给重试的时间间隔加一些随机的延时。</li><li>构建幂等系统，这个就不说了，避免重试产生错误</li><li>及时释放资源，资源及时释放能够避免泄露。</li></ul><h2 id="如何管理项目依赖"><a href="#如何管理项目依赖" class="headerlink" title="如何管理项目依赖"></a>如何管理项目依赖</h2><p>在现有的代码上增加一个依赖似乎是一个简单的决定。“不要重复自己”(Don’t repeat yourself，DRY)是一个通常被教导的原则。</p><p>一个好的版本管理方案，其版本都具有以下特点：</p><ul><li>唯一性(unique)：版本不应该被重复使用。构件会被分发、缓存，并被自动化工作流拉取。永远不要在现有版本下重新发布更改的代码。</li><li>可比性(comparable)：版本应该帮助人们和工具对版本的优先顺序进行推断。当一个构建依赖于同一构件的多个版本时，可以使用优先顺序来解决冲突。</li><li>信息性(informative)：版本信息区分了预先发布的代码和已发布的代码，将构建流水号与构件相关联，并设置了稳定性和兼容性的合理预期。</li></ul><p>语义版本管理(semantic versioning，SemVer)是版本管理中最常用的方案之一。官方的SemVer规范可在其网站中找到。该规范定义了3个数字：主版本号、次版本号和补丁版本号（有时也称作微版本号）。这3个数字被合并为“主版本号.次版本号.补丁版本号”的版本号格式。</p><p>如何避免各种循环依赖呢？首先是隔离依赖项，虽然我们经常说 DRY，但是必要情况下，我们要敢于复制代码，避免奇怪的依赖关系。在添加依赖项时，我们应该按需要添加，而不应该一股脑地全都加入到依赖列表中。</p><h2 id="如何进行测试"><a href="#如何进行测试" class="headerlink" title="如何进行测试"></a>如何进行测试</h2><p>常见的测试主要有以下几种：</p><ul><li>单元测试（Unit Testing）：单元测试是对软件系统中最小可测试单元进行测试的过程。这个“最小可测试单元”通常是指一个函数、方法或者类。单元测试旨在验证单个单元的功能是否按照设计达到预期的结果。它通常由开发人员编写，用于确保代码的每个部分都能够独立地、正确地运行。单元测试通常是自动化的，可以使用各种单元测试框架进行执行。</li><li>集成测试（Integration Testing）：集成测试是将已经经过单元测试的模块或组件组合在一起，测试它们在一起工作的能力。集成测试的目标是确保各个模块在组合后能够正确地相互协作，不仅仅是各个单元独立运行正确。集成测试可以帮助发现模块之间的接口问题、数据传递问题等。</li><li>系统测试（System Testing）：系统测试是对整个软件系统进行测试的过程。它的目标是验证系统是否符合需求规格和用户期望。系统测试通常是在模拟生产环境下进行的，包括各种功能测试、性能测试、安全性测试等。系统测试旨在确认整个系统的功能和性能，以及它们是否满足了用户的需求。</li><li>性能测试（Performance Testing）：性能测试是用来评估系统在不同工作负载下的性能表现。性能测试可以包括负载测试（测试系统在不同用户并发访问下的性能）、压力测试（测试系统在极端工作负载下的性能）、容量测试（测试系统在正常工作负载下的性能，评估系统的容量）等。性能测试帮助开发人员和系统管理员了解系统的性能极限，并确保系统在大规模用户使用时仍然能够提供足够的性能。</li><li>验收测试（Acceptance Testing）：验收测试是在软件交付给用户或客户之前进行的测试。它的目的是确认软件是否符合用户的需求，并且是否准备好被部署和使用。验收测试通常由最终用户、客户或项目相关的利益相关者执行，用于验证系统是否满足约定的需求标准和质量标准。验收测试可以包括用户验收测试（User Acceptance Testing，UAT）、alpha测试、beta测试等。</li></ul><h2 id="代码评审（Code-Review）"><a href="#代码评审（Code-Review）" class="headerlink" title="代码评审（Code Review）"></a>代码评审（Code Review）</h2><p>从你的代码上得到的那些批评性的评论可能让你很难接受。切记应该保持一些情感上的距离——这些评审意见是针对代码的，而不是针对你个人的，而且这甚至都不算是你的代码，将来整个团队会拥有这些代码。</p><p>不要羞于要求别人评审你的代码。如果你没有得到任何反馈，请向团队报告（但不要催促）</p><p>在 review 别人的代码时，不一定只要盯着别人代码里面存在的问题，如果遇到对你有启发的好的代码，也可以通过 comment 让别人知道。</p><h2 id="软件交付"><a href="#软件交付" class="headerlink" title="软件交付"></a>软件交付</h2><p>我们应该尽可能频繁地发布，这样发生回滚时产生的变更也比较小</p><h2 id="On-Call"><a href="#On-Call" class="headerlink" title="On-Call"></a>On-Call</h2><p>On-Call工作是依照优先级分类来进行的。P0、P1、P2，依此类推。将工作按类别进行优先级排序有助于界定任务的紧迫性。具体的类别名称和含义因公司而异，但P0任务是大的任务。谷歌云的支持优先级梯队提供了一个如何定义优先级的例子（你可以在谷歌云的技术支持页面找到相关说明）。</p><ul><li>P1：严重影响(critical impact)——服务在生产环境中无法使用。</li><li>P2：高影响(high impact)——服务的使用受到严重损害。</li><li>P3：中等影响(medium impact)——服务的使用部分受损。</li><li>P4：低影响(low impact)——服务完全可用。</li></ul><p>事故处理是On-Call人员最重要的职责。大多数开发人员认为处理事故是为了解决生产问题。解决问题确实很重要，但在关键事故中，第一个目标是减轻问题的影响并恢复服务。第二个目标是捕捉信息，以便以后分析问题是如何发生以及为什么发生的。确定事故的原因，证明它是罪魁祸首，并解决根本问题——只是你的第三个目标。<br>事故响应分为以下5个阶段。</p><ul><li>分流(triage)：工程师必须找到问题，确定其严重性，并确定谁能修复它。</li><li>协同(coordination)：团队（以及潜在的用户）必须得到这个问题的通知。如果On-Call人员自己不能解决这个问题，他们必须提醒那些能解决的人。</li><li>应急方案(mitigation)：工程师必须尽快让事情稳定下来。缓解并不是长期的修复，你只是在试图“止血”。问题可以通过回滚一个版本、将故障转移到另一个环境、关闭有问题的特性或增加硬件资源来缓解。</li><li>解决方案(resolution)：在问题得到缓解后，工程师有一些时间来喘口气、深入思考，并为解决问题而努力。工程师将继续调查问题，以确定和解决潜在的问题。一旦眼前的问题得到解决，事故也就得到了解决。</li><li>后续行动(follow-up)：对事故的根本原因——为什么会发生，进行调查。如果事故很严重，就会进行正式的事后调查，或进行回顾性调查。建立后续任务，以防止那个（或那些）根本原因的再次出现。团队要寻找流程、工具或文档中的任何漏洞。在所有的后续任务完成之前，相应事故的处理不应该被认为已经结束了。</li></ul><p>重要的是快速解决问题，不要逞英雄</p><h2 id="技术设计流程"><a href="#技术设计流程" class="headerlink" title="技术设计流程"></a>技术设计流程</h2><p>你的首要任务是定义和理解你要解决的那个（或那些）问题。你需要了解问题的边界，以便知道如何解决它，并避免构建错误的东西。</p><p>通过实验可以验证一些想法，不要写测试，也不要花时间打磨代码，这一步最重要的是进行验证</p><p>尽可能安排大块时间来进行设计，只有这样才能进行深入的思考</p><p>设计文档是为了传达你的想法，形成变更的指导方针，如果变更有以下三个特点，那么我们就需要写文档：</p><ol><li>该项目将需要至少一个月的工程时间。</li><li>这一变更将对软件的扩展和维护产生长期的影响。</li><li>该变更将显著影响其他团队。</li></ol><p>下面是一个可用的模板：</p><ul><li>概要；</li><li>现状与背景；</li><li>变更的目的；</li><li>需求；</li><li>潜在的解决方案；</li><li>建议的解决方案；</li><li>设计与架构；</li><li>系统构成图；</li><li>UI&#x2F;UX变更点；</li><li>代码变更点；</li><li>API变更点；</li><li>持久层变更点；</li><li>测试计划；</li><li>发布计划；</li><li>遗留的问题；</li><li>附录</li></ul><p>要了解团队的设计评审流程，多和团队交流，在交流过程中让别人知道你的想法，进而得到反馈。不要让设计文档成为别人了解你想法的第一步</p><p>多为团队的设计工作贡献力量</p><h2 id="构建可演进的架构"><a href="#构建可演进的架构" class="headerlink" title="构建可演进的架构"></a>构建可演进的架构</h2><p>复杂系统有两个特点：高依赖性和高隐蔽性。高依赖性导致软件依赖其他 API 或者代码行为，导致系统很难修改。高隐蔽性使我们很难预测变更的副作用。</p><p>YAGNI,You ain’t gonna need it。保持代码简单的方式之一是避免什么代码都写出来。避免过早优化，避免不必要的灵活抽象模型，以及避免最小可行产品(minimum viable product，MVP)所不需要的产品特性——你需要那些可以获得用户反馈的最低限度的功能集。</p><p>最小惊讶原则：不要让用户感到惊讶，不要让开发者感到惊讶，请使用惯用的代码风格和开发模式</p><p>高内聚、低耦合，对专业领域知识进行封装</p><p>在做数据管理时，尽量不要采用无模式的方式，最好定义好明确的 schema，降低程序的隐蔽性。</p><h2 id="敏捷计划"><a href="#敏捷计划" class="headerlink" title="敏捷计划"></a>敏捷计划</h2><p>敏捷开发是一种软件开发模型，被广泛采用于快速交付优质软件的场景。了解核心理念和常见的敏捷过程的目标，如冲刺计划、每日站会、评审和回顾，将有助于你有效地实践它们。（当然这不是 996）</p><p>Scrum 是一种敏捷（Agile）软件开发框架，用于管理和协作团队开发复杂产品。Scrum 强调迭代和增量的开发，它能够帮助团队更好地应对需求变化，提高团队的灵活性和适应性。以下是 Scrum 框架的主要特点和角色：</p><p><strong>Scrum 框架的特点：</strong></p><ol><li><p><strong>迭代开发：</strong> Scrum 将项目分解成小的、可管理的部分，每个部分通常为一个迭代，被称为“冲刺”（Sprint）。每个冲刺通常持续两周到一个月，期间团队专注于完成预定的任务。</p></li><li><p><strong>角色明确：</strong> Scrum 定义了明确的角色，包括产品负责人（Product Owner）、Scrum Master 和开发团队（Development Team），每个角色有特定的职责和权力。</p></li><li><p><strong>自组织团队：</strong> Scrum 鼓励团队自组织，决定如何完成任务，提高团队的责任感和自主性。</p></li><li><p><strong>仪式：</strong> Scrum 拥有一系列仪式，包括每日站会（Daily Standup）、冲刺计划会（Sprint Planning Meeting）、冲刺评审会（Sprint Review Meeting）和冲刺回顾会（Sprint Retrospective Meeting）。这些仪式帮助团队保持协作，确保团队成员了解项目的当前状态。</p></li><li><p><strong>产品待办列表和冲刺待办列表：</strong> 产品待办列表（Product Backlog）是项目需求的总列表，冲刺待办列表（Sprint Backlog）则是每个冲刺的任务列表。</p></li></ol><p><strong>Scrum 的角色：</strong></p><ol><li><p><strong>产品负责人（Product Owner）：</strong> 产品负责人是客户、用户和团队之间的桥梁。他们负责定义产品特性、功能和优先级，并且在每个冲刺结束时评估和接受完成的工作。</p></li><li><p><strong>Scrum Master：</strong> Scrum Master 是团队的敏捷教练，负责确保团队遵循 Scrum 流程，帮助团队解决问题，去除障碍，并持续改进团队的效率和质量。</p></li><li><p><strong>开发团队（Development Team）：</strong> 开发团队是一组跨职能的专业人员，他们负责实际开发工作，包括设计、编码、测试等。开发团队的成员通常是自组织的，能够完成团队所承担的各项任务。</p></li></ol><p>Scrum 提供了一种结构化的方法来管理和推动软件开发项目，使团队更加灵活、适应性更强，有助于更好地满足客户需求，提高项目的成功率。</p><h2 id="与管理者合作"><a href="#与管理者合作" class="headerlink" title="与管理者合作"></a>与管理者合作</h2><p>（重点来了。。。）</p><p>大家虽然老是嘲讽向上管理，但是永远永远永远都要保持和你的管理者的沟通，多和老板聊天，有助于你了解整个工作的方向，让老板了解你的工作进展和存在的问题。多从老板和其他同事那里得到反馈，也要多给予反馈，老板不怕你菜，老板比较怕惊喜（其实多数是惊吓）。</p><p>OKR框架是公司定义目标和衡量其是否成功的一种方式。在OKR框架中，公司、团队和个人都定义了目标（目的），并为每个目标附上衡量标准（关键结果）。每个目标都附有3到5个关键结果，它们是标志着目标达成的具体指标。</p><p>OKR通常被设定得比合理值略高，以创造“达成”或“延伸”目标的条件。这种理念意味着你不应该百分之百地达成目标的OKR，这是一个表明你设定的目标还不够高的迹象。大多数OKR的实施以60%到80%的成功率为预期目标，这意味着只有60%到80%的目标应被实现。如果你达成了80%以上的目标，你就会丧失进取心；如果低于60%，你设置的目标就不太现实或者你的表现没有达到预期。（为什么不把OKR设定为100%，并奖励超额完成的个人呢？多个进取型的目标可以让你灵活地决定在实施过程中舍弃哪个，而不需要像100%的完成率所预期的那样精确。）请确保你了解你的公司是把OKR当作必须达成的目标，还是有一定预期失败率的附加了进取心的目标！</p><h2 id="职业生涯规划"><a href="#职业生涯规划" class="headerlink" title="职业生涯规划"></a>职业生涯规划</h2><p>成为 T 型人才，这些人既是通才（在一系列广泛的有价值的事情上有很高的技能——T的顶端横线），也是专家（在某个垂直领域中成为佼佼者——T的竖线）。</p><p>参加各种训练营，加入感兴趣的项目。</p><p>主导你自己的晋升，和老板讨论一下他的期望，看看自己离晋升还有哪些可改进的地方。</p><p>学会自我调节，不要过度劳累，保持健康规律的工作节奏。</p><hr><p>对于新手程序员，这本书真的很值得看一遍，梳理提到的很多正确的事情浅显易懂而又显而易见，只要实践就能得到成果。知易行难，知行合一是一辈子的修行。</p>]]></content>
    
    
    <summary type="html">《程序员的 README》读书笔记，面向新手与瓶颈期工程师梳理成长方法论：技术/执行/沟通/领导力四项能力、应对软件熵与技术债、代码评审、测试分层、依赖与语义化版本管理、On-Call 事故响应、技术设计文档、可演进架构、敏捷 Scrum 与向上管理及 OKR。</summary>
    
    
    
    <category term="书籍阅读" scheme="https://liang7878.github.io/categories/%E4%B9%A6%E7%B1%8D%E9%98%85%E8%AF%BB/"/>
    
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/tags/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    <category term="书籍阅读" scheme="https://liang7878.github.io/tags/%E4%B9%A6%E7%B1%8D%E9%98%85%E8%AF%BB/"/>
    
  </entry>
  
  <entry>
    <title>读《OceanBase 数据库源码解析》</title>
    <link href="https://liang7878.github.io/posts/b0cb3be9.html"/>
    <id>https://liang7878.github.io/posts/b0cb3be9.html</id>
    <published>2023-08-29T13:31:16.000Z</published>
    <updated>2026-06-29T17:07:30.727Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>速记</strong></p><ul><li>Shared-Nothing + Paxos 复制组是 OB 的基础，RootService 负责元数据和负载均衡。</li><li>存储层采用 LSM Tree，行&#x2F;块缓存兼顾 OLTP 与 OLAP，配合 WAL 与快照保证恢复。</li><li>多租户通过 Resource Unit&#x2F;Pool 隔离资源，高可用依赖日志复制、备份与两地三中心部署。</li></ul></blockquote><p>这本书才出来几个月的时间，刚好看到微信读书上已经有了，这两天就花一点时间把这本书读完，话不多说，开始。</p><p>这本书整个有九章，各章节如下：</p><ul><li>OceanBase 概述</li><li>OceanBase 的架构</li><li>OBServer</li><li>存储引擎</li><li>SQL 引擎</li><li>事务引擎</li><li>高可用</li><li>多租户</li><li>安全管理</li></ul><span id="more"></span><p>我们一章一章过一遍。</p><p>p.s. 这本书内容真的很多，值得对着代码精读一遍。</p><h2 id="OceanBase-概述"><a href="#OceanBase-概述" class="headerlink" title="OceanBase 概述"></a>OceanBase 概述</h2><p>OceanBase不需要过多赘述，大家都很熟悉了，下面是 OB的整个发展时间脉络：<br><img data-src="/posts/b0cb3be9/ob_timeline.png" alt="OceanBase 发展时间线图"></p><p>里面有几个比较有意思的时间节点，一个是 14 到 15 年加入了 Paxos，另一个是 16到 18 年做了多租户，可以发现，这么一个复杂系统也是从最简单的基础功能实现开始的。</p><p>OB 的特性如下：</p><ul><li><strong>透明可扩展</strong>: OB整体做了存算分离，在总控的帮助下可以按需伸缩，伸缩后自动做负载均衡。</li><li><strong>极致高可用</strong>： 通过Paxos来实现的高可用，可以保证数据的一致性和完整性。支持单机房、双机房、两地三中心、三地务中心部署。支持基于日志复制的主备库</li><li><strong>混合事务和分析处理</strong>：同时支持 OLAP 和 OLTP</li><li><strong>多租户</strong>：支持单集群多租户，基于租户来实现资源隔离</li><li><strong>高兼容性</strong>：支持 MySQL和 Oracle 语法</li><li><strong>完整自助知识产权</strong>：好</li><li><strong>高性能</strong>：准内存数据库，基于 LSM Tree 实现读写性能优化，支持强一致性事务</li><li><strong>安全性</strong>： 支持各类安全需求。</li></ul><p>第一章剩下的部分基本就是介绍怎么编译安装 OB 的，这里就不多赘述了</p><h2 id="OceanBase-的架构"><a href="#OceanBase-的架构" class="headerlink" title="OceanBase 的架构"></a>OceanBase 的架构</h2><p>OB整体采用 Shared-Nothing 的架构，集群中节点互相独立，具有比较高的扩展性。</p><p>几个关键概念：</p><ul><li>分区（Partition）：数据分布的基本单元，即数据分片，OB 可以基于范围、Hash、列表分区，也可以组合分区。</li><li>副本（Replica）：分区的副本，同一个分区的多个副本共同组成 Paxos 复制组，其中有一个副本是主副本，负责所有的写操作。</li><li>OBServer：逻辑服务器，一台物理机上可以部署一台或多个 OBServer，每个 OBServer 都包含 SQL 引擎、事务引擎和存储引擎</li><li>可用区（Zone）:其实是 Availability Zone，一个 OB 集群由多个可用区组成，一个可用区多个 OBServer，为了提高可用性，一般会把同一分区的不同副本放在不同的可用区，以提高可用性。总控服务负责整个集群的资源调度，一般由多个可用区提供总控服务（一主多备）。</li><li>地域（Region）：一个 Region 包含一个或多个可用区，不同 Region 相距较远。这里提到两地三中心的部署模式，主城市两个机房，每个机房各两个副本，副城市一个机房，维护一个副本，这样的好处是可以在城市内完成 Paxos 的共识</li></ul><p>OB 集群架构如下图：</p><p><img data-src="/posts/b0cb3be9/ob_arch.png" alt="OceanBase 三层架构示意图"></p><p>两地三中心如下图：</p><p><img data-src="/posts/b0cb3be9/2r3c.png" alt="OceanBase 2R3C 架构示意图"></p><p>OB的源码文件目录结构如下：</p><p><img data-src="/posts/b0cb3be9/folders.png" alt="OceanBase 目录结构视图"></p><p>主要的内核代码都在 <code>src</code> 目录下，包括 election(分布式选举)、clog（事务日志、Paxos）、archive（日志归档）、rootserver（总控）、share（公共组件）、sql（SQL 引擎）、storage（存储引擎）、observer（OBServer 的主干过程）</p><p>OBServer工作目录下通常有admin、bin、etc、etc2、etc3、lib、log、run、store这九个目录，但这九个目录并非都是必须安装的。在启动OBServer时必须要确保存在的是etc、log、run、store，同时store下应该有clog、ilog、slog、sstable这四个子目录。其中需要注意的是 store 目录，包含 clog（事务日志）、ilog（日志索引）、slog（存储日志）、sstable(基线数据)</p><p>OceanBase Database Proxy(ODP) 是 OB 的代理服务器，主要负责接收并转发 SQL 请求、路由管理、连接管理等功能。</p><h2 id="OBServer"><a href="#OBServer" class="headerlink" title="OBServer"></a>OBServer</h2><p>OBServer由 OBD（OceanBase Deployer）负责启动，启动后会基于传入参数进行选主，OBServer 的 <code>main</code>函数主要执行以下启动工作：</p><ol><li>解析命令行参数。</li><li>创建运行状态、日志、配置目录，即2.3节所述的数据目录下run、log、etc子目录。</li><li>调用start_daemon函数将OBServer进程从一个普通进程转变成守护进程，并将守护进程的PID（进程号）写入运行状态目录下的observer.pid文件。</li><li>设置运行日志管理器OB_LOGGER，日志文件是log下的observer.log，还会根据解析的命令行参数设置日志级别，日志的最大文件大小为256×1024×1024，之后便开始记录运行日志。</li><li>如果守护进程启动成功，则开始启动OBServer的实例（线程）。<ol><li>ObServer类的get_instance方法得到一个ObServer实例。</li><li>调用init方法根据命令行参数、日志配置初始化ObServer。</li><li>调用start方法运行OBServer进程。</li><li>调用wait方法等待停止信号，发现停止信号后调用stop方法停止ObServer中各个子系统。</li><li>如果wait结束，调用destroy方法关闭OBServer进程。</li></ol></li></ol><p>OBServer中各个子系统的生命周期也和OBServer本身一样由初始化（init方法）、启动（start方法）、等待停止（wait方法）、停止（stop方法）组成，它们分别会在OBServer的相应阶段被调用。</p><p>OBServer 的子系统如下图所示：</p><p><img data-src="/posts/b0cb3be9/observer_subsystem.png" alt="Observer 子系统组成图"></p><p>网络子系统的各种初始化内容不多赘述，比较重要的内容是其中初始化了几个不同的请求队列，负责缓存和处理请求：</p><ol><li>租约队列lease<em>queue</em>：接收租约相关的请求。</li><li>DDL队列ddl<em>queue</em>：接收DDL请求。</li><li>MySQL队列mysql<em>queue</em>：接收除DDL之外的MySQL请求。</li><li>诊断队列diagnose<em>queue</em>：接收诊断相关的请求。</li></ol><p>与之对应的还有四个队列守护进程，线程名分别为：LeaseQueueTh、DDLQueueTh、MysqlQueueTh和DiagnoseQueueTh。队列守护线程的工作循环里会不断地弹出队列中的请求，然后用与队列绑定的请求Handler来处理请求。这种处理有效控制流量，避免大量请求把系统打崩。</p><p>多租户环境的资源隔离通过线程管理来实现，首先基于系统中 CPU 核数和配置信息得到工作线程的初始数量：（可用的核心数+服务器租户的虚拟核心数）×默认每核心线程数+为系统级租户预留的线程数。工作线程的最大数量是在在初始数量的基础上加上可用CPU核心数的16倍之后与硬上限 4096 取较小值。除了系统级租户所占用的线程外，OB 会产生一个叫做<code>MultiTenant</code>的守护线程，用于定时对租户列表进行遍历，调用租户的 timeup 方法，该方法可以简称租户拥有的工作线程，当工作线程不足时就会从工作线程池中为租户获取工作线程。</p><p>OB中的线程整体可以分为三种类型：</p><ol><li>I&#x2F;O线程用在网络子系统中，用于取下MySQL或RPC端口上到达的包，然后包装成内部的请求形式供工作线程消费，I&#x2F;O线程及其管理机制在网络子系统初始化时建立。</li><li>工作线程代表各租户处理各种请求，例如SQL请求和RPC请求，工作线程及其管理机制在多租户环境初始化时建立。</li><li>后台线程是各个子系统执行特定任务的代理，例如用于分区的转储、合并、迁移等任务的DAG线程，它们随着各个子系统的初始化而产生。</li></ol><p>线程的关键属性和方法如下：</p><ol><li><code>start()</code>：启动线程的方法，在这个方法中会调用pthread_create函数创建这个对象所代表的线程，同时将现成的入口函数指向__th_start方法。</li><li><code>__th_start()</code>：所属线程的入口函数，在其中会通过系统调用获取当前进程的进程ID以及线程ID并保存在pid<em>和tid</em>两个属性中，最后会调用runnable_属性中对象的run接口来执行线程的自定义主函数。</li><li><code>get_pid()</code>：从pid_属性中返回当前线程进程ID的方法。</li><li><code>get_tid()</code>：从tid_属性中返回当前线程ID的方法。</li><li><code>pid_</code>：保存已启动线程所属的进程ID。</li><li><code>tid_</code>：保存已启动线程的线程ID。</li><li><code>runnable_</code>：用于嵌入一个Runnable对象（也可能是其子类对象），runnable_中的对象可以在Thread对象实例化或者用start方法启动线程时作为参数传入，以实现线程主函数的可定制化。</li></ol><p>OB 的 handler 主要分两种：ObMySQLHandler和ObRpcHandler，分别执行用户请求和内部请求，这部分具体实现不多赘述。</p><p>OB 的 session 管理基于 session ID实现，具体方式如下：</p><ol><li>每一个会话都会得到一个全局唯一的会话ID，客户端将保持该会话ID。</li><li>会话信息保存在服务器端，并以会话ID为标识。</li><li>客户端发送请求时会携带其会话ID，OBServer根据请求中的会话ID将请求在相应的会话“环境”中执行。</li></ol><p>总控服务主要负责元数据管理、集群资源管理、版本合并管理、执行管理命令等功能。</p><ul><li>元数据管理：RS通过心跳机制监控集群中各个OBServer的存活状态，并同步更新系统表，以及进行异常处理。同时也通过心跳向其他OBServer传输配置变更、模式变化等多种信息。<strong>all_root_table存放所有系统表的分区信息，</strong>all_tenant_meta_table存放所有用户表的分区信息，这些信息也由RS统一管理，其他OBServer执行请求时可以通过RS服务获取这些信息来定位要操纵的数据。</li><li>集群资源管理：集群资源管理包括Leader管理、分区负载均衡、资源单元(Resource Unit)负载均衡等任务。</li><li>版本合并管理：不同于小版本冻结（转储）由各个OBServer自行处理，大版本冻结（合并）由RS协调发起，是一个由RS和所有分区Leader组成的两阶段分布式事务。某个分区无主会导致大版本冻结失败。合并可以由业务写入（转储达到一定的次数，由全局参数控制）触发，也可以定时触发（例如每日合并，一般设置于业务低峰期）或手动触发。</li><li>执行管理命令：RS是管理命令执行的入口，包括BOOTSTRAP命令、ALTER SYSTEM命令和其他DDL命令。BOOTSTRAP是系统的自举过程，主要用于创建系统表、初始化系统配置等。DDL是指创建表、创建索引、删除表等动作，DDL不会被优化器处理，而是作为命令直接发送到RS，DDL产生的模式变更保存于系统表并更新到内存，然后产生新的版本号通知所有在线的OBServer，OBServer再刷新获得新版本的模式。</li></ul><p>OceanBase支持了一套检测机制，通过该机制发现RS的异常，并通过运维命令强制切换RS来恢复。RS异常包括RS无主、RS上任失败、RS线程卡住、快照点回收异常、配置项异常、工作线程满、DDL线程满等，主要分为两类：一类是RS可以正常服务请求；另一类是RS不可服务。前一类可以通过停服的方式隔离异常RS并将RS切到其他机器来解决；后一类包括RS上任失败、队列满等异常，可以通过外部工具来强制切换RS服务，新RS上任后再隔离原来的异常机器。</p><p>配置子系统不多赘述，主要负责管理各种配置项。</p><h2 id="存储引擎"><a href="#存储引擎" class="headerlink" title="存储引擎"></a>存储引擎</h2><p>先看图, OBServer 的存储引擎架构如下图所示：</p><p><img data-src="/posts/b0cb3be9/storage.png" alt="OceanBase 存储引擎组件关系图"></p><p>这里用了典型的 LSM tree 的实现，老生常谈就不多说了。</p><p>为了提高对数据的访问速度，OceanBase存储引擎构建了两种缓存：行缓存(Row Cache)和块缓存(Block Cache)。基线数据的I&#x2F;O单位是块(Block)，存储引擎采用块缓存提高I&#x2F;O利用率。对于行的操作会利用块缓存中的块和增量数据构造出“行”形式的数据，为了避免重复施行这种构造，构造形成的行会被放在行缓存中重复使用。除缓存的数据形式不同之外，两种缓存的使用场景也有所不同。OLTP业务大部分操作为小查询，存储引擎优先使用行缓存来回答查询，从而避免了解析整个数据块的开销，达到了接近内存数据库的性能。而对于涉及数据量很大的OLAP业务，则优先使用块缓存。</p><p>OB的元数据存储在系统表中，系统表统一存放在系统(SYS)租户中，未区分不同租户产生的元数据，这些系统表中都有一个tenant_id列用来标识该条元数据的归属，这些系统表的名称都以“<strong>all”为前缀。同时，为了方便各租户使用自己的元数据，在各租户中也定义有一些从系统表导出的视图，这些视图的名称都以“</strong>tenant”为前缀。很明显，这些视图都是系统表的简单行列子集视图，可以直接在其上进行修改操作，因此它们也被归入到“系统表”的类别之中。此外，为了方便对元数据的查看，OceanBase还提供了一些比较复杂的只读视图，它们被称为“虚拟表”，其名称以“<strong>all_virtual”或者“</strong>tenant_virtual”为前缀。</p><p>系统表初始化过程在 OB的初次启动时执行，一个OceanBase集群第一次被启动时，需要首先进行自举操作(Bootstrap)形成初始的系统表结构并且将集群中各个服务器节点加入到集群之中，通常这一动作是由OBD发起。</p><p>OBD在启动集群时会通过检查节点数据目录的clog子目录是否存在来判断是否需要进行自举动作，如果需要进行自举，则OBD会向集群发送一系列的SQL命令完成自举：</p><ol><li>OBD首先会发送一个BOOTSTRAP命令进行基本的自举</li><li>基本自举完成后，OBD还会发出若干ADD SERVER命令将RS列表中的多个节点注册到集群中</li></ol><p>BOOTSTRAP 命令中第一个目标节点接收到请求后，会调用<code>ObService::bootstrap()</code>方法进行处理，方法分为预备阶段和自举阶段，预备阶段主要是为了创建一号表（__all_core_table），自举阶段姿势负责创建其他系统表，由<code>ObBootstrap::execute_bootstrap()</code>接手处理。<code>ObBootstrap::execute_bootstrap()</code>如下图所示:</p><p><img data-src="/posts/b0cb3be9/bootstrap.png" alt="OceanBase 启动流程示意图"></p><p>OB 提供多版本模式服务，各节点上都缓存有模式数据的副本，但对于模式的修改则由RootService所在的节点实施，在完成模式修改之后由RootService将新的模式版本通知其他节点，它们将会刷新各自的模式缓存。由于系统运行中会由于DDL操作导致模式版本发生变化，不同时刻开始的操作（事务）将会看到（需要）不同版本的模式信息，这套模式服务准确来说应该被称为“多版本模式服务”。</p><p>为了实现对模式的修改，OceanBase在多版本模式服务中提供了<code>ObSchemaService</code>类作为DDL命令操纵模式的接口。<code>ObSchemaService</code>是一个接口类，目前它仅有一个实现：<code>ObSchemaServiceSQLImpl</code>类。<br><code>ObSchemaServiceSQLImpl</code>的作用是根据外部模块的调用，返回操纵相应数据库对象的SQL服务类的对象，外部模块再利用SQL服务对象的方法完成DDL操作。</p><p>模式数据是整个数据库系统运行期间会频繁访问的信息，为了避免反复地从持久化存储中读出系统表数据，OceanBase在多版本模式服务中设置了模式缓存，被访问过的模式被驻留在位于内存中的缓存区域用于加速后续的模式访问。由于OceanBase的分布式数据库特性，集群中每个节点上都会有访问模式数据的需求，因此每个节点上都有自己的模式缓存。虽然多个节点上的模式缓存形成了多副本，但整个集群中只有RootService节点才能通过执行DDL语句修改模式信息，这些缓存之间实际是一主多从的关系，非RootService节点上的缓存会随着RootService节点的缓存变化而刷新，因此不会出现缓存不一致的问题。</p><p>模式缓存的刷新主要分为主动刷新和被动刷新。RootServer执行完DDL操作并且更新自身的模式缓存时，会产生新的模式版本号。模式版本号可以看成是一种流水号，新的模式版本号是从前一个版本号加1形成。产生新的模式版本号之后，RootServer并不采用广播的方式通知其他节点，而是等待其他节点报告心跳（续租）时随着响应信息返回给这些节点。当其他模块想要获取完整模式（会指定其模式版本）时，如果所在节点模式缓存中无法找到对应版本的完整模式，会实时触发SQL从系统表构造指定版本的完整模式，并放入到当前节点的模式缓存中。</p><p>OceanBase在物理上将数据划分成多种粒度层次进行组织。如下图所示，该层次中最粗的管理粒度是SSTable，每台OBServer上的SSTable仅对应一个物理文件block_file，这意味着OceanBase的存储引擎会将当前节点上所有表中的数据都“塞”进这个物理文件中。SSTable由若干宏块(MacroBlock)组成，宏块又由若干体积更小的微块(MicroBlock)构成，微块中则包含着数据行(Row)，这也就是关系数据库操纵数据的基本单元。</p><p><img data-src="/posts/b0cb3be9/physical_store.png" alt="物理存储布局示意图"></p><p>数据行有两种组织结构：稀疏格式和平面格式。</p><p>稀疏格式如下，头部有各种标志位、索引位置、事务 ID、行的状态 等各种信息。在访问行中某列的时候，会先找到该列的索引位置，然后局域索引位置来读取该列的数据。</p><p><img data-src="/posts/b0cb3be9/sparse_row.png" alt="稀疏行存储格式图"></p><p>平面格式如下，平面格式的行被用于持久化存储即SSTable中，可以认为稀疏行被从MemTable转存到SSTable后就变成了平面格式的行。相对于稀疏格式，平面格式的行中没有列ID数组。这是因为稀疏格式的行中并不一定会保存所有的列值，因此需要附加列ID数组来表明行中存放了哪些列值。而平面格式的行中会包含所有的列值，故而不需要用列ID数组标记列值的存在性。正因为这种“全部包含”的特性，平面格式的行历史上也被称为稠密行。</p><p><img data-src="/posts/b0cb3be9/plane_row.png" alt="平面行存储格式图"></p><p><img data-src="/posts/b0cb3be9/sparse_plane_row.png" alt="稀疏平面行存储格式图"></p><p>微块是OceanBase进行读取的最小单位，由头部信息、行数组和行索引三部分构成。其中头部信息由通用块头部和微块头部组成。通用块头部存储的是数据块长度、校验信息之类的数据，微块头部存储的是微块中行列的各种信息。微块的中间部分是连续的稠密行，其中每一行的具体位置由微块最后的行索引数组确定。</p><p>多个微块组成了宏块，宏块同样包含头部信息、数据区（多个微块）、微块索引三大部分，不过在宏块的最后还可能存在一段填充区域，用于将宏块的尺寸对齐成固定的2MB。相对于微块一般不超过16KB的大小，宏块显得很大，这是因为OceanBase将宏块作为写数据的最小的单元，较大的宏块尺寸有利于更多地累积数据修改，更好地发挥磁盘的吞吐性能。</p><p>SSTable由若干个宏块组成，它表示表在某台服务器上的基线数据，也可以理解为表落在这台服务器上的分区（也可能是分区的副本），每个逻辑节点上的SSTable和表之间是一对一的关系，但同一个表由于分区的关系可能会在集群中多个节点上都拥有SSTable。SSTable仍然是一个对数据进行分组的逻辑单位，最终一个节点上的所有SSTable（来自不同的表）都集中存放于一个物理文件block_file中。SSTable在存储引擎中由ObSSTable类表达，其结构如下图所示：</p><p><img data-src="/posts/b0cb3be9/macro_block_arch.png" alt="宏块架构组成图"></p><p>位于SSTable中的是相对静态的数据（称为基线数据），按多版本并发控制(MVCC)的说法，SSTable中是数据行的旧版本。对数据行进行修改（插入、更新、删除）产生的新版本数据行则会首先被放入MemTable中，等到此类修改累积到一定程度后再从MemTable中逐渐转移到SSTable变成新的基线数据。这里其实存在一个读放大的问题，在读取某一行数据的时候，要先从 SSTable 中读取基线数据，接着再去 MemTable 中读取更新的数据，好处是基线数据只在事务启动时读取一次，之后可以完全从 MemTable 中读取和写入。</p><p>MemTable 中有两个索引：B-Tree 和 Hash表，索引中保存的是指向行的新版本数据的指针。MemTable中这两种索引有各自的优势，因此服务于不同的操作：</p><ol><li>在B-Tree中进行查找时需要经历一条从根节点到叶子节点的路径，对于单点查找来说可能代价较高。不过，由于B-Tree中索引的数据是有序的，能够提高搜索的局部性，因此只有在进行范围查找时，才会使用B-Tree作为支撑。</li><li>在Hash表中的查找需要针对搜索键值计算出Hash桶号才能从桶中找到目标数据，因此Hash表仅适合等值查询（单点查找）。MemTable中涉及的操作也有这类查询的用武之地：①插入一行数据的时候，需要先检查此行数据是否已经存在，检查冲突时会使用Hash表；②事务在插入或者更新一行数据时，需要找到此行并对其进行上锁，防止其他事务修改此行，此时也会使用Hash表。</li></ol><p>在SSTable和MemTable的基础之上，OceanBase的存储引擎还引入一些更高层的存储组织概念：分区组、表组等，这些存储组织单位之间的关系如下图所示：</p><p><img data-src="/posts/b0cb3be9/store_component_relation.png" alt="存储组件关系示意图"></p><p>表组(Table Group)是一种介于表的逻辑分组和物理分组之间的组织结构，它被用来将相关联的经常要联合在一起查询的表（例如有外键关联的表）集合起来。之所以说它是一种逻辑分组，是因为表组并没有在物理上用一个文件或者一个目录将表组中的表数据组织在一起，它的存在仅仅表明同一个表组中的表之间存在比较强的关联查询需求。而表组的物理分组特点则体现在它确实会影响表中数据的物理分布位置：由于表组内的表经常会被用来进行关联查询（连接查询），因此为了避免经常进行跨节点的数据交换，会将表组中的表按照统一的分区方式（典型的是按照连接键）进行分区，这样表组内多个表中相关联的行都会存放在位于同一个节点的分区中。</p><p>而分区组(Partition Group)则是由表组和节点两个维度交叉产生的一种逻辑分组，即一个表组内的表在同一个节点上的所有分区就形成一个分区组。</p><p>OceanBase-CE支持对数据的压缩，压缩是以微块为单位进行的。OB支持 LZ4、Snappy、zlib、zstd 这几种压缩算法。</p><p>OceanBase的存储引擎采用了LSM Tree的设计思想，将数据划分成基线(SSTable)和增量(MemTable)两部分。基线数据是静止状态且位于空间易于扩展的外部存储设备（磁盘）上，但增量数据是动态变化的且位于空间难以扩展的内存中。尽管MemTable仅存储了数据的变化，在一个繁忙的系统中增量数据的体量仍会以可观的速度增加，如果不对其做一些控制，MemTable会很快耗尽节点的内存。</p><p>因此，当MemTable的内存使用达到一定阈值（由freeze_trigger_percentage控制）时，就需要将MemTable中的数据存储到磁盘上以释放内存空间，这个过程称为转储(Minor Compaction)。在转储之前首先需要保证将要被转储的MemTable不再进行新的数据写入，这个过程就是所谓的冻结(Freeze)。冻结会阻止当前活跃的MemTable进行新的写入，同时会生成新的活动MemTable。冻结MemTable的动作可能会发生多次，因此对于一个表分区来说，始终会有唯一的活动MemTable，但是可能会有多个已冻结MemTable。已冻结MemTable仍然占据着内存空间，因此当内存消耗较大时，有必要腾空这些已冻结MemTable，这个操作会将已冻结MemTable转储到文件中形成Mini SSTable。</p><p>基线数据和增量数据分离的设计还会带来另一个问题：如果任由MemTable中的数据一直累积（包括被转储的部分），那么行的操作链就变得很长，获取最新版本行的操作就需要在基线版本的基础上应用更多的修改操作，这显然会使得操作行的代价变大。为了解决这类问题，OceanBase会在合适的时间执行合并操作，包括Minor Compaction和Major Compaction。前者是将转储形成的Mini SSTable合并形成Minor SSTable，后者是将Minor SSTable合并到最终的基线SSTable中，两者的最大区别是：Minor Compaction是节点级别的操作，仅影响本节点上的内存数据和转储数据；Major Compaction是集群级别的操作，它会导致集群上所有的节点都把转储的数据合并到基线数据上。通过这两种Compaction可以尽量缩短行操作链，提高行操作的性能。</p><p>由于SSTable外加多版本的MemTable设计，数据行的读取路径可能会变得很长，为了提高基线行数据的读取效率，OceanBase在SSTable的上层引入了多层Cache机制。<br>设计Cache的目的是缓存SSTable中频繁访问的数据，分为：</p><ol><li>Block Cache（块缓存）：用于缓存SSTable中的原始数据块。</li><li>Block Index Cache（块索引缓存）：用于缓存微块索引。</li><li>Row Cache（行缓存）：用于缓存一个个完整的数据行。</li><li>Bloom Filter Cache（布隆过滤器缓存）：用于缓存布隆过滤器，布隆过滤器可以快速回答那些结果为空集的查询。</li></ol><p>由于SSTable在非合并期间都是只读的，所以不用担心Cache失效的问题。当对行的读请求来临时，首先尝试通过缓存中的布隆过滤器检查目标行是否真正存在，对于存在的行将尝试从Row Cache中获取行，如果没有命中则利用块索引缓存计算目标行所在的微块；然后尝试从Block Cache中获取这个微块，如果没有命中微块就会通过存储引擎利用磁盘I&#x2F;O读取微块数据并放入Block Cache中。通过这样多级缓存的设计，绝大部分单行操作在基线数据中只需要一次缓存查找就能确定能否找到，性能相对较高。</p><h2 id="SQL-引擎"><a href="#SQL-引擎" class="headerlink" title="SQL 引擎"></a>SQL 引擎</h2><p>和其他数据库一样，SQL 引擎分为解析器、优化器、执行器三个部分。当SQL引擎接收到了SQL请求后，经过语法解析、语义分析、查询重写、查询优化等一系列过程后，再由执行器来负责执行。与集中式数据库系统不同的是，分布式数据库系统的查询优化器会依据数据分布生成分布式执行计划。查询优化其实就是老生常谈了，算子下推、智能连接、分区裁剪，还有并行处理、任务拆分、动态分区、流水调度、任务裁剪、子任务结果合并、并发限制等优化。SQL 引擎的结构如下：</p><p><img data-src="/posts/b0cb3be9/sql_engine_arch.png" alt="SQL 引擎架构组成图"></p><p>解析器构建语法树，基于语法树查询计划缓存，没查到就去做语义分析、查询重写、查询优化生成执行计划，生成好计划后将其加入计划缓存，然后执行器执行计划。各种数据库都是大同小异，就不多说了。</p><p>计划缓存有两种淘汰方式，一是当计划缓存占用的内存达到了需要淘汰计划的内存上限时，对计划缓存中的执行计划自动进行淘汰，二是通过发出SQL命令的方式手动删除缓存中的指定计划。</p><p>缓存计划失效有以下几种情况，一是数据库对象的模式发生变化，二是计划中涉及的数据库对象的统计信息重新被收集，那么该计划就会失效。OceanBase会在SSTable合并时统一进行统计信息的收集，因此每次进行合并后，实际上计划缓存中所有计划都会失效。还有一种缓存计划的失效场景，它发生在缓存计划被执行后进行执行统计信息更新时，其基本思想是将同一个SQL语句的缓存计划中性能表现较差的设置为失效，但这些设置为失效的计划并没有被立刻从缓存中清除，而是等到下次从缓存中获取同一SQL语句的计划时顺便将它们清除。这种基于计划的实际运行性能进行优胜劣汰的做法也是业界SQL计划管理器(SQL Plan Management，SPM)技术的一种典型做法。基于性能将执行计划设置为失效（过期）的情况有以下几种：</p><ol><li>计划执行超时或者执行计划的会话被杀死。</li><li>计划平均执行时间超过5ms且不稳定，即计划满足以下情况之一：<ol><li>计划的速度低于同类计划（属于同一个SQL语句且使用相同的参数值）的平均速度。</li><li>本地计划访问的行数超过同类计划的平均访问行数。</li><li>本地计划的平均访问行数增长太快（超过第一次执行时的10倍）。</li><li>分布式计划的平均查询执行时间增长太快（超过第一次执行时的2倍）。</li></ol></li><li>计划的本次访问行数超过了一个阈值EXPIRED_PLAN_TABLE_ROW_THRESHOLD（100行）。</li><li>计划第一次执行访问的行数为0，但本次执行访问的行数不为0。</li><li>计划第一次执行访问的行数不为0，且本次执行访问的行数超过了第一次的2倍。</li></ol><p>查询重写主要按照以下步骤来执行：</p><ol><li>简化。</li><li>子查询合并。</li><li>ANY&#x2F;ALL优化。</li><li>集合操作重写。</li><li>视图合并。</li><li>WHERE条件子查询提升。</li><li>半连接转换为内连接。</li><li>查询下推。</li><li>消除外连接。</li><li>连接消除。</li><li>外连接LIMIT下推。</li><li>全外连接优化。</li><li>OR扩展优化。</li><li>聚集改写为窗口函数。</li><li>窗口GROUP BY处理。</li><li>聚集处理。</li><li>投影剪枝。</li><li>谓词移动。</li></ol><p>上面的步骤没啥好解释的，各家数据库都是大差不差。</p><p>OceanBase的计划生成可以分为两个阶段：第一阶段（将产生一个初始的逻辑计划，第二阶段则在初始逻辑计划的基础上进行改造，重点是考虑逻辑计划涉及的表数据的位置分布信息，将初始逻辑计划改造为一种可以在多个节点上并行执行的形式。</p><p>在逻辑计划的生成过程中，选取路径是一个必不可少的环节。访问路径决定了查询涉及的基表数据是以何种顺序和方向汇聚并形成最终的结果关系，可以说访问路径表达了逻辑计划中数据的流动方向和提取（访问）数据的方式，例如对查询“SELECT*FROM A,B,C,D”来说，先连接A和C然后再连接B最后连接D就体现了访问路径的数据流向。总体来说，访问路径就是逻辑计划的主干，逻辑计划是在访问路径的基础之上进行了更细致的包装形成的数据结构。</p><p>优化器会使用<code>ObSelectLogPlan::generate_raw_plan()</code>方法来为一个SELECT查询生成初始的计划，该过程总体可以分为三个主要步骤：</p><ol><li><code>ObSelectLogPlan::generate_plan_for_set()</code>：如果查询中涉及集合操作，则递归调用generate_raw_plan方法为各分支子查询生成计划。</li><li><code>ObSelectLogPlan::generate_plan_for_plain_select()</code>：为查询中的单表生成路径，然后基于单表的路径用动态规划或者线性算法生成连接路径。</li><li><code>ObLogPlan::get_current_best_plan()</code>：根据代价模型和排序要求选择最优路径形成计划。</li></ol><p>整体上，优化器生成路径的方法都是自底向上的构建方法，即先构造出查询中涉及的基表的单表路径，然后逐步将它们两两连接起来形成越来越“大”的连接路径，最终形成最上层的连接表。因此，优化器首先会生成所有的单表路径。</p><p>连接路径的生成有动态规划(Dynamic Programming)和线性(Linear)两种算法，如果连接中涉及的表的数目不超过10个（由DEFAULT_SEARCH_SPACE_RELS定义），则采用动态规划算法生成连接路径，否则采用线性算法生成连接路径。</p><p>OceanBase的连接路径生成采用了System-R的动态规划算法，考虑到的因素包括每一个表可能的访问路径、查询感兴趣的顺序（例如ORDER BY要求的顺序）、可能的连接算法（NESTED-LOOP，BLOCK-BASED NESTED-LOOP，SORT-MERGE等）以及不同表之间的连接选择率等。由于要考虑连接类型、连接顺序等多方面的可能性，动态规划算法产生的访问路径数量会比较大，而且随着参与连接的基表数目增加，需要考虑的访问路径的数量会呈几何增长。因此，当参与连接的基表数目比较多时，动态规划算法需要耗费比较多的时间才能选择出一个性能比较好的连接路径。如果将优化所消耗的时间考虑在内，这个性能较好的连接路径带来的性能优势可能也会被抵消殆尽。所以，在参与连接的基表数目很多时（超过十个），更需要的是一种能更快选出较优路径方法，即便它产生的路径不是那么优秀，这种方法就是接下来的线性算法。</p><p>连接路径生成的线性算法的最大不同在于其预处理步骤<code>ObLogPlan::preprocess_for_linear()</code>，在预处理步骤中会遵从查询语句中对表的连接要求，将具有连接关系的基表先连接起来构成连接表。经过这种预处理之后，后续的连接路径生成过程中需要考虑的可能性就会减少，之后仍然会采用类似动态规划算法的方式从基表以及预处理步骤中创建的连接表出发构造更高层的连接表，但由于很多低层的连接表已经在预处理步骤中遵循连接要求“定向”生成了，花费在构造这些低层连接表上的时间就被大大降低。</p><p>OceanBase将优化器的优化过程分成串行优化与并行优化两个阶段。</p><p>在生成计划的过程中，经常需要计算路径的代价，用于淘汰没有优势的路径，这些代价计算都按照OceanBase代价模型的假设进行。OceanBase的代价模型考虑了CPU代价（例如处理一个谓词的CPU开销）和I&#x2F;O代价（例如顺序、随机读取宏块和微块的代价）。当然，作为一种分布式数据库，OceanBase的代价模型中还会考虑跨节点数据传输导致的代价，将一条路径中所有的CPU代价、I&#x2F;O代价和网络传输代价累加起来就得到该路径的总代价。</p><p>屋里计划的执行入口在<code>ObMPQuery::response_result</code>方法中，里面区分了三种情况：DML语句的同步执行、DML语句（增删查改）的远程异步执行、非DML语句执行。</p><ol><li>DML语句的同步执行方式是本地执行计划的执行方式，这包括非分布式计划以及查询执行器中执行的分布式计划片段。在这种执行方式下，ObMPQuery::response_result会建立一个同步计划驱动(ObSyncPlanDriver)来执行计划，然后由同步计划驱动的response_result方法采用同步的方式￼推进执行计划的执行，调用ObResultSet的get_next_row方法一个个地取出执行计划的行并返回。</li><li>DML语句的远程异步执行是分布式执行计划的执行方式：由一个ObServer实例作为查询控制器(Query Controller)，分布式计划被分发给集群中其他OBServer（称作查询执行器，Query Executor）执行，然后它们将自己的局部结果传送给控制器，控制器将收到的结果行返回给需求者。在这种方式下，控制器将分布式计划发布出去后，会注册一些回调函数，当远程执行器的结果到达时将触发回调函数来完成最后的结果返回。对于每一个远端执行器来说，其执行收到的分布式计划的过程和DML语句的同步执行方式类似，不同之处在于其产生的结果行需要返回给查询控制器。</li><li>非DML语句执行方式用于执行CREATE TABLE之类的非DML语句，OceanBase内部也把这类语句称为命令(Command，CMD)。这类语句的执行特点是返回的结果并不是标准的行集合，例如可能是一种简单的字符串。这意味着这类语句的执行是一次调用，然后获得整个语句的执行结果。因此，ObMPQuery::response_result会建立一个同步命令驱动(Ob-SyncCmdDriver)或者异步命令驱动(ObAsyncCmdDriver)来执行命令语句。</li></ol><p>当用户提交的SQL语句需要访问的数据位于两个及以上节点时，就会启用并行执行，会执行如下步骤：</p><ol><li>用户所连接的这个节点（会话所在地）将承担查询协调者(Query Coordinator，QC)的角色。</li><li>QC预约足够的线程资源。</li><li>QC将需要并行的计划拆成多个子计划(Data Flow Operation，DFO)，每个DFO包含若干个串行执行的操作符。例如，一个DFO里包含扫描分区、聚集、发送操作符，另外一个DFO里包含收集、聚集操作符等。</li><li>QC按照一定的逻辑顺序将DFO调度到合适的节点上执行，该节点会临时启动一个辅助协调者(Sub Query Coordinator，SQC)，SQC负责在所在节点上为各个DFO申请执行资源、构造执行上下文环境等，然后启动DFO在相应节点上并行执行。</li><li>当各个DFO都执行完毕，QC会串行执行剩余部分的计算。例如，一个并行的COUNT聚集，最终需要QC将各个节点上的计算结果做一个SUM运算。</li><li>QC所在线程将结果返回给客户端。</li></ol><p>并行执行框架的运作过程如下图所示，计划进入执行阶段后，本地调度器会先按照 Volcano 模型从计划树的顶层操作符开始迭代执行，每次迭代都会从顶层操作符返回一个结果行，在计划树执行过程中，如果遇到与并行执行相关的操作符，就会触发并行执行，由一个叫做 QC 的线程来执行。并行执行操作符会将其下的子计划通过 RPC 发送到相关节点上，子节点上会产生一个 SQC来协调执行本地的子计划。</p><p>在OceanBase中用并行度(Degree Of Parallelism，DOP)的概念指定用多少个线程来执行一个DFO，参与执行DFO的线程被称为并行执行工作者(Parallel Executing Worker，PX Worker)。目前OceanBase可以通过“parallel”这个Hint来指定并行度。DOP是个集群级的概念，因此还需要将DOP确定的线程数分配到参与执行DFO的多个节点上。</p><p>为了将这种划分能力进行抽象和封装，OceanBase的并行执行框架引入了Granule的概念。每个扫描任务称为一个Granule，这个扫描任务既可以是扫一整个分区，也可以是扫分区中的一个范围。</p><p><img data-src="/posts/b0cb3be9/parallel_execution.png" alt="并行执行框架组成图"></p><p>分区的划分需要一个合适的粒度，既不能太大也不能太小。粒度过大，容易出现PX Worker工作量不均衡；粒度过小，会多次出现前一个Granule执行完毕后切换到下一个Granule的动作，这样做的累积开销比较大。目前OceanBase中使用了一个经验值来实现对分区的划分：每个PX Worker可以拿到13个Granule是最合适的。划分形成的Granule被串成一个链表，由SQC产生的PX Worker将从链表上抢Granule任务执行。</p><p>在一对有关联关系的DFO中，子DFO作为生产者分配了M个PX Worker线程，父DFO作为消费者分配了N个PX Worker线程，那么它们之间的数据传输就需要用到M×N个网络通道。<br>为了对这种网络通信进行抽象，OceanBase中引入了数据传输层(Data Transfer Layer，DTL)的概念，将任意两点(PX Worker)之间的通信连接用通道(Channel)的概念来描述。通道分为发送端和接收端，为了防止发送端无节制地向通道中发送数据导致接收端累积的数据占据过多内存，DTL中加入了流量控制逻辑对发送端向通道中发送数据的行为加以限制：每个通道的接收端预留了三个槽位来保存接收到的数据，当槽位被数据占满时会通知发送端暂停发送数据，当有接收端数据被消费腾出空闲槽位时也会通知发送端继续发送。</p><p>并行框架实现的关键点在于：1. PX 操作符的实现，即 QC 的实现；2. SQC 的实现；3. PX Worker 的实现。</p><p>首先是 PX 操作符的实现。在PX框架中，QC以操作符的形式出现，因此要弄清楚并行执行的起点，就需要理解PX操作符的实现方式。在OceanBase目前的PX框架中，有两种PX操作符：ObPxFifoCoordOp和ObPxMSCoordOp，它们都是ObPxCoordOp的子类。两种PX操作符的主要差异在于它们接收数据的策略不同，ObPxFifoCoordOp顾名思义是采用先进先出(First In First Out，FIFO)的策略，而ObPxMSCoordOp名字中的MS表示Merge Sort，即它会先对输入完成排序再向上层节点输出行。</p><p>接着是 SQX 的实现。在OceanBase的并行执行框架中，SQC是所在节点上执行DFO的管理者和调度器，该节点上参与DFO执行的所有PX Worker都在它的管理之下。SQC实际上并不是一个独立的线程，而是以一个对象的形式出现。之所以不将SQC实现为一个单独的线程，是因为虽然SQC是调度器，但是当它将PX Worker驱动起来以后其实工作并不繁重，如果让SQC独占一个线程会让它在大部分时间里都处于空转的状态，既占用了RPC处理线程，又长时间不退出（因为PX Worker有大量数据需要返回），这可能会导致RPC处理线程被耗尽。<br>SQC的所有动作都是由消息驱动的。QC以RPC的方式发出一个DFO到某个节点后，其上的RPC处理器就会初始化SQC并尝试驱动PX Worker开始执行任务。初始化SQC的消息将由<code>ObInitSqcP::process()</code>来处理，其处理步骤包括：</p><ol><li>建立一个ObPxSqcHandler对象来操纵SQC。</li><li>调用SQC的pre_acquire_px_worker方法获取所需的PX Worker线程数量并准备管理它们的数据结构。</li><li>真正建立起所需的通道将这个SQC和其QC联系起来。</li></ol><p>最后是 PX Worker 的实现。SQC所在的节点上会有一个线程池(ObPxPool)，对于需要线程数大于1的情况，<code>Ob-PxSubCoord::dispatch_tasks()</code>会通过<code>ObPxSubCoord::dispatch_task_to_thread_pool()</code>把要运行的任务包装成一个<code>ObPxThreadWorker</code>对象，然后调用该对象的run方法将这个任务包装成一个<code>PxWorkerFunctor</code>，在提交到线程池同时也启动了对应的PX Worker。<br>在由<code>PxWorkerFunctor</code>表达的任务进入到线程池后，线程池会调度一个空闲的线程充当PX Worker来执行该任务，即执行<code>PxWorkerFunctor::operator()</code>方法来运行指定的任务。</p><h2 id="事务引擎"><a href="#事务引擎" class="headerlink" title="事务引擎"></a>事务引擎</h2><p>分布式事务同样需要维持事务的四大特性：原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)，简称为ACID特性。</p><ol><li>原子性：OceanBase数据库是一个分布式系统，分布式事务操作的表或者分区可能分布在不同机器上，OceanBase数据库采用两阶段提交协议保证事务的原子性，确保多台机器上的事务要么都提交成功要么都回滚。</li><li>一致性：事务必须是使数据库从一个一致性状态变到另一个一致性状态，一致性与原子性是密切相关的。OceanBase中事务一致性的问题要比集中式数据库系统更复杂：由于OceanBase采用了多副本的架构，数据的修改只要在多数副本上得到确认即可认为成功，因此在少数派副本和多数派副本之间可能在一定时间窗口内存在不一致。为了提高性能，OceanBase允许应用（用户）在自己的事务中主动降低一些一致性要求以便去读取那些少数派副本上的数据，从而能提高读取的性能。</li><li>隔离性：OceanBase-CE天然运行在MySQL兼容模式下。在MySQL模式下，事务能够采用读已提交(Read Committed，RC)隔离级别和可重复读(Repeatable Read，RR)隔离级别。结合前述的一致性要求，事务可以实现相当灵活的数据版本读取需求。</li><li>持久性：对于单个机器来说，OceanBase通过REDO日志记录了数据的修改，通过WAL机制保证在宕机重启之后能够恢复出来。事务一旦提交成功，事务数据一定不会丢失。对于整个集群来说，OceanBase数据库通过Paxos协议将数据同步到多个副本，只要多数派副本存活数据就一定不会丢失。</li></ol><p>事务是与会话密切关联在一起的，每次从一个客户端连接到OceanBase开始到这个连接从OceanBase断开为止，其间发生在该连接上的所有数据库操作（命令）被称为一个“会话”。在一个会话中，客户端可能会执行多个事务，不过在会话存续的任一时刻仅有一个事务运行（称为活跃事务）。反过来，一个事务必定也仅存在于一个会话中。事务被进一步分解为一个或者多个语句(Statement)，事务和语句之间是一种一对多的关系，一个事务可以包含多个语句，而每一个语句则仅属于一个事务。OceanBase管理事务的核心就是维持会话、事务、语句的状态以及它们之间的关联，并根据SQL语句在各个节点上的执行情况适时推进它们的状态变化。在会话描述类ObBasicSessionInfo中有多个属性共同描述了会话中进行着的事务：</p><p><img data-src="/posts/b0cb3be9/session_info_1.png" alt="会话信息结构示意图（一）"></p><p><code>trans_consistency_type_</code>属性描述当前事务的一致性类型，其有两种类型：<code>CURRENT_READ</code>和<code>BOUNDED_STALENESS_READ</code>。<code>CUR-RENT_READ</code>会使得事务在进行数据操作时在目标数据所在分区的Leader节点上，由于OceanBase的架构设计要求数据修改操作一定是在Leader上完成后再同步到其他多数派副本上，因此从Leader上读到的肯定是最新版本的可见数据。<code>BOUNDED_STALENESS_READ</code>类型顾名思义是指“有界脏读”，即按照某种限制允许事务读取旧版本的数据。例如在创建索引时就可以允许使用这种一致性类型，这种一致性类型另外一种可能的用途是用来实现闪回查询，即查询过去某个时间点的数据，这种服务在很多基于 MVCC 的数据库产品中都有提供。</p><p><code>consistency_level_</code>属性描述当前事务的一致性级别，分为强一致性(STRONG)和弱一致性(WEAK)两种，所谓弱一致性是指在某些场景下用户可以接受查询到一些并不太具有一致性的数据，这些不一致的数据是由于OceanBase多副本之间同步的时间差导致的，但是最终这些副本之间还是会达到一致。很明显，只有在只读的场景下，弱一致性事务才可行，涉及写操作或者可能涉及写操作的事务一定会使用强一致性事务完成，这样才能保证前述的多副本间最终的一致性。事实上BOUNDED_STALENESS_READ类型的读操作也属于一种弱一致性读，因此只有在一致性级别为WEAK时才可能发生。</p><p><code>trans_flags_</code>属性中用一个整数的多个位描述了当前事务的一些标志，例如：<code>IS_IN_TRANSACTION</code>表示会话正在运行一个事务，即有活跃事务存在；而<code>HAS_HOLD_ROW_LOCK</code>表示事务持有行锁。<br><code>trans_result_</code>属性描述了会话中当前已执行过的事务的结果状态，尤其是分布式事务中在其他节点上执行的局部事务的执行情况，以便于在事务结束后及时释放各参与节点上占用的资源。</p><p><img data-src="/posts/b0cb3be9/session_info_2.png" alt="会话信息结构示意图（二）"></p><p>会话中有关事务最核心的部分是trans<em>desc</em>属性，它是一个ObTransDesc类的对象，它描述了会话中活跃事务的详情，其结构如上图所示：</p><ol><li><p><code>trans_id_</code>：事务的ID，由事务所在的服务器（<code>ObAddr</code>，包括IP地址和端口号）以及一个本地流水号（整数）组合而成。</p></li><li><p><code>trans_param_</code>：事务的参数，包括事务的隔离级别(<code>isolation_</code>)、访问模式（<code>access_mode_</code>，表示只读或者读写）、是否自动提交(<code>autocommit_</code>)、一致性类型（<code>consistency_type_</code>属性）。</p></li><li><p><code>participants_</code>：事务的参与分区构成的数组，每一个元素都代表一个<code>ObPartitionKey</code>（包括表的ID、分区的ID、二级分区ID等）。</p></li><li><p><code>stmt_participants_</code>：和<code>participants_</code>类似，但保存的是当前语句的参与分区信息</p></li><li><p><code>participants_pla_</code>：参与分区的Leader数组，每一个元素对应于某个参与分区的Leader所在节点(<code>ObAddr</code>)，与<code>participants_</code>相互呼应。</p></li><li><p><code>stmt_participants_pla_</code>：<code>和participants_pla_</code>类似，但保存的是当前语句的参与分区的Leader信息。</p></li><li><p><code>trans_expired_time_</code>：事务超时时间，OceanBase没有分布式死锁检测机制，因此对于死锁的消除只能依靠事务超时时间，当事务超时时会自动中止事务，由事务的发起者自动重试。</p></li><li><p><code>sql_no_</code>：当前执行语句的内部编号，这个SQL编号是一个流水号，每开始执行一个新的语句都会使得这个编号加1，而且事务中的保存点技术也相当依赖这个字段。</p></li><li><p><code>need_rollback_</code>：指示客户端尽快回滚。</p></li><li><p><code>session_id_</code>：当前会话的ID，由于分布式事务的存在，事务的状态并非仅由协调者节点上的事务信息组成，还包括分布在其他节点上的局部事务状态，这些组成部分之间需要用会话的ID以及事务的ID关联起来，因此每个事务的数据结构中需要记录所属的会话ID。</p></li><li><p><code>can_elr_</code>：表示当前事务是否可以采用提前解行锁(Early Lock Release，ELR)技术。</p></li><li><p><code>is_local_trans_</code>：标志着整个事务是否为一个纯粹的本地事务，即所有参与分区的Leader都在事务的发起节点上，这种事务不涉及分布式事务的部分，因此可以更快速地完成提交。</p></li><li><p><code>is_fast_select_</code>：表示当前事务是一个快速SELECT，主要服务于实现游标(<code>Cursor</code>)读操作。</p></li><li><p><code>trans_type_</code>：是一个<code>TransType</code>类的实例，表示事务的类型，可能的值有：</p><ul><li><code>SP_TRANS</code>：单分区(Single Partition)事务，并不是说所有仅涉及一个分区的事务都是单分区事务，只有该分区的Leader和事务的发起者在同一个节点的事务才能称为单分区事务，否则就会被认为是分布式事务。</li><li><code>MINI_SP_TRANS</code>：属于单分区事务的一种特殊情况，即事务中仅涉及一个点查询（仅涉及一个目标行的查询）且<code>autocommit</code>配置参数被设置为1。</li><li><code>DIST_TRANS</code>：其他非以上两种特殊情况的事务都被认为是分布式事务。</li></ul></li><li><p><code>is_all_select_stmt_</code>：表示当前事务所执行过的语句是否都是SELECT（只读）语句，用于事务提交时的优化。</p></li><li><p><code>session_</code>：指向所属会话结构的指针。</p></li><li><p><code>last_end_stmt_ts_</code>：记录上一个语句完成执行的时间戳，结合事务超时技术防止事务长期空闲占用资源。</p></li><li><p><code>trx_idle_timeout_</code>：事务的超时时长，结合<code>last_end_stmt_ts_</code>字段的值就能判断一个事务当前是否已经闲置过长时间，如果发现此类情况则强制回滚事务节省系统资源。</p></li></ol><p>事务的管理和控制本质上是通过在会话、事务、语句等状态数据结构上进行修改来实现。SQL引擎在执行一个SQL语句时可以区分为非事务控制语句和事务控制语句。非事务控制语句（即常规的DML语句、DDL语句以及DCL语句）被SQL引擎执行时对于事务的影响取决于系统中的自动提交设置（autocommit参数）以及当前会话所处的事务状态。对于打开了自动提交的会话，每一个非事务控制语句都会运行在一个单独的事务（称为隐式事务）中，事务的生命周期和对应的SQL语句相同。而对于自动提交关闭的会话，则需要使用显式事务，即用事务开始（START TRANSACTION或BEGIN）语句和事务结束(END&#x2F;COMMIT&#x2F;ROLLBACK)语句标记出事务的边界。这种情况下，如果没有标记事务的开始位置，则认为上一个事务结束之后的位置就是新事务的开始。这些事务边界标定语句称为事务控制语句，它们被用于显式控制事务的状态或者决定事务对数据的修改是否最终生效。另外，显式事务会覆盖自动提交设置，即在自动提交打开的情况下，通过BEGIN开始的显式事务中的SQL语句不会在执行完成时立刻提交，而是在显式事务结束时随整个事务一并提交或者回滚。</p><p>事务启动主要有两种方式，显式事务启动和隐式事务启动。对于显式事务启动来说，<code>BEGIN</code> 语句的执行将会启动事务。由于<code>BEGIN</code>这类事务控制语句用于操纵显式事务，因此<code>ObSqlTransControl</code>类中服务于BEGIN语句执行的方法是<code>explicit_start_trans</code>。<code>ObSqlTransControl</code>中有另一个重载过的<code>explicit_start_trans</code>方法，两者存在调用关系。总体上，两个<code>explicit_start_trans</code>方法合作为事务准备好<code>ObStart-TransParam</code>信息，然后在确保当前会话没有处于事务中（检查<code>IS_IN_TRANSACTION</code>标志）的前提下，通过<code>ObSqlTransControl::start_trans()</code>启动事务，启动事务成功后将会话设置为处于事务中。</p><p>当自动提交设置打开且没有使用事务控制语句启动显式事务时，每一个被执行的SQL语句都会作为一个单语句的隐式事务执行。DML语句导致隐式事务启动的过程如下，当每一个DML语句被执行时（与是否隐式事务无关），都会调用<code>ObResultSet::open_plan()</code>开始执行计划，在该方法中首先会使用<code>ObResultSet::auto_start_plan_trans()</code>确保该DML语句运行在一个事务中，即如果会话当前已经处于一个运行事务中￼则继续使用该事务（不开启新事务而是加入现有事务），否则启动一个隐式事务。完成事务启动（加入）之后才真正调用执行器的<code>execute_plan</code>方法执行DML语句对应的执行计划。计划执行结束之后，如果执行计划还涉及其他节点上的分区，则调用<code>ObResultSet::start_participant()</code>启动相应节点上的局部事务。</p><p>语句被作为单一语句事务需要同时满足以下主要条件：</p><ol><li>SELECT语句且不是SELECT FOR UPDATE；</li><li>会话不处于运行事务中，即事务ID无效且没有IS_IN_TRANSACTION标志；</li><li>自动提交设置打开</li></ol><p>在事务发起节点（协调者）启动其本地语句（事务）之后，会调用一系列<code>start_participant</code>方法通知语句所涉及的各个参与者在各自掌握的分区数据上开启局部事务，这些局部事务联合协调者节点上的全局事务一起完成整个分布式事务的工作。</p><p>OceanBase支持语句级的原子性，即一条语句的操作要么都成功要么都失败，不会存在部分成功部分失败的情况。<br>当一条语句执行过程中没有报错，那么该语句所做的修改都是成功的，如果一条语句执行过程中报错，那么该语句执行的操作都会被回滚，这种情况称为语句级回滚。语句级回滚有如下特点：</p><ol><li>语句回滚时仅回滚本语句的修改，不会影响当前事务该语句之前语句所做的修改：例如，一个事务有两条UPDATE语句，第一条UPDATE语句执行成功，第二条UPDATE语句执行失败，则只有第二条UPDATE语句会发生语句级回滚，第一条UPDATE语句所做的修改会被保留。</li><li>语句级回滚的效果等价于语句没有被执行过，即语句执行过程中涉及的全局索引、触发器、行锁等均属于语句的操作，语句回滚需要将这些操作都回滚到语句开启之前的状态。</li></ol><p>常见的语句级回滚有以下几种：</p><ol><li>INSERT操作出现主键冲突</li><li>单条语句执行时间过长导致超时</li><li>多个事务存在行锁冲突导致思索，事务被死锁检测机制强制结束</li></ol><p>这里同时还聊到了全局时间戳，可以说全局时间戳是分布式事务的核心。OceanBase的MVCC设计严重依赖于各种版本信息：事务的提交版本、快照版本等，这些版本实际上就是一个个时间戳。为了保持版本之间的可比较性，大部分情况下，这些时间戳的获取渠道是一致的（从同一个时钟获取），在OceanBase中这个渠道通过全局时间戳服务实现。<br>OceanBase会为系统中的每一个租户（系统租户除外）启动一个全局时间戳服务(Global Timestamp Service，GTS)，事务提交时通过本租户的GTS服务获取事务版本号，保证全局的事务顺序。系统租户使用的是本地时间戳服务(Local Timestamp Service，LTS)，因此OceanBase不推荐外部操作使用系统租户。</p><p>全局时间戳由一个三副本的 GTS provider 提供，全局时间戳来源于leader的 本地时间戳。Leader 发生改选时，会将其已经授权的最大时间戳同步到心得 leader，以免发生回退。同时通过 lease 也能够保证新旧 leader 之间无重叠。GTS 的优化主要有两种，一是对于单节点事务不去获取全局时间戳，二是可以将多个事务获取全局时间戳的操作合并，批量获取。</p><p>Save Point 也是事务实现中的重要部分。OceanBase中对提交和回滚的支持依赖于存储引擎中MemTable与SSTable相结合的设计：事务对数据行的修改作为增量保留在内存中，在事务最终提交或者回滚之前，这些修改都处于一种“待定”的状态，事务提交或者回滚的工作实际上就是将属于相应事务的修改的状态“确定化”。如果事务最终提交，那么属于该事务的“待定”修改都被加上该事务的提交版本号，表示这些修改已经生效；如果事务最终被回滚，那么归属于该事务的“待定”修改可以直接从MemTable中移除。而回滚到保存点的操作效果则介于事务的提交和回滚之间：回滚到某个保存点需要放弃该保存点之后的所有修改，但需要保留保存点之前做出的修改，即部分回滚事务。因此，回滚到保存点被实现为从内存中(MemTable)清除在该保存点之后产生的所有“待定”修改，但是在该保存点之前发生的修改仍处于“待定”状态，因为它们还可能被后面的ROLLBACK TO语句、COMMIT语句或者事务级ROLLBACK语句进一步处理。</p><p>在OceanBase的实现中，事务执行过程中有一个SQL序列(SQL Sequence)，它根本上就是一个整数值，该值在事务执行过程中是递增的，每一个新开始的语句都会导致该序列向前推进一步，同时该语句中会保留执行该语句时的序列值，该值被称为SQL编号（通常写作sql<em>no</em>）。每一个语句执行过程中产生的修改数据也会被关联上该语句的SQL编号，而在事务中定义的保存点也会获得一个SQL编号，这样根据要回滚到的保存点的SQL编号，就能找到MemTable中哪些修改是在该保存点之后产生的，在执行回滚时仅需要将这些修改从MemTable中移除即可。</p><p>Redo日志是OceanBase用于宕机恢复以及维护多副本数据一致性的关键组件。Redo日志是一种物理日志，它记录了数据库对于数据的全部修改历史，具体地说记录的是一次写操作后的结果。从某个持久化的数据版本开始逐条回放Redo日志可以还原出数据的最新版本。<br>OceanBase的Redo日志有两个主要作用：</p><ol><li>宕机恢复：与大多数主流数据库相同，OceanBase遵循WAL(Write-Ahead Logging)原则，在事务提交前将Redo日志持久化，保证事务的原子性和持久性（ACID中的“A”和“D”）。如果OBServer进程退出或所在的服务器宕机，重启OBServer会扫描并回放本地的Redo日志用于恢复数据。宕机时未持久化的数据会随着Redo日志的回放而重新产生。</li><li>多副本数据一致性：OceanBase采用Multi-Paxos协议在多个副本间同步Redo日志。对于事务层来说，一次Redo日志的写入只有同步到多数派副本上时才能认为成功。而事务的提交需要所有Redo日志都成功写入。最终，所有副本都会收到相同的一段Redo日志并回放出数据。这就保证了一个成功提交的事务的修改最终会在所有副本上生效并保持一致。Redo日志在多个副本上的持久化使得OceanBase可以提供更强的容灾能力。</li></ol><p>OB的 Redo log 主要有两种：</p><ol><li>Clog：全称是提交日志(Commit log)，用于记录Redo日志的日志内容，位于store&#x2F;clog目录下，文件编号从1开始并连续递增，文件ID不会复用，单个日志文件的大小为64MB。这些日志文件记录数据库中的数据所做的更改操作，提供数据持久性保证。</li><li>ilog：全称是索引日志(Index log)，用于记录相同分区相同日志ID的已经形成多数派日志的提交日志的位置信息。ilog位于store&#x2F;ilog目录下，文件编号从1开始并连续递增，文件ID不会复用，单个日志文件的大小非定长。这个目录下的日志文件是Clog的索引，本质上是对日志管理的一种优化，ilog文件删除不会影响数据持久性，但可能会影响系统的恢复时间。ilog文件和Clog文件没有对应关系，由于ilog针对单条日志记录的内容会比Clog少很多，因此一般场景下ilog文件数目也比Clog文件数目少很多。</li></ol><p>OceanBase的分布式事务中存在三种组成部分：</p><ol><li>调度器(Scheduler)：OceanBase分布式事务的事务协调器不一定运行在发起相应分布式事务的节点上，用户或者应用所连接的节点被称为调度器或者调度者。可以认为调度者在全局事务运行的前期大约等效于一个传统意义上的“事务协调器”，因为在全局事务结束（提交或者回滚）前各局部事务的信息都由调度器持有和维护。当然，由于调度器知晓参与到全局事务中各节点的位置，这些信息也使得调度器能在全局事务执行期间根据收到的SQL语句向各参与节点发出控制指令。</li><li>协调器(Coordinator)：OceanBase分布式事务的协调器在全局事务结束时发挥作用，例如它会在全局事务提交时，担负起两阶段提交协议中的协调者角色。总体来说，调度器和协调器共同完成了全局事务层面的事务控制工作。</li><li>参与者(Participant)：OceanBase分布式事务的参与者和前述的概念一致，即运行局部事务的节点都被称为参与者，由于调度器和协调器所在的节点也包含数据，因此它们也可能同时充当了参与者的角色，甚至有可能同一个节点同时承担三种角色。</li></ol><p>MVCC的基本原则是：数据被修改时，修改并不是就地(In-place Update)进行，即修改不是直接应用在目标数据上，而是将目标数据标记为已删除，然后插入一个更新后的新数据，被标记删除的数据被称为“旧版本”，新插入的数据则称为“新版本”。通常来说，MVCC机制下产生的同一个数据的新旧版本之间在逻辑上或者物理上构成一个按产生顺序排列的版本链，每一个版本上也会关联产生&#x2F;删除该版本的事务信息（例如事务提交版本号）。在MVCC机制中版本链的支持下，当并发事务访问同一个数据时，不同的事务将会看到版本链中的不同数据版本，每个事务看起来都运行在自己“专属”的版本上，因此不会发生冲突。</p><p>在OceanBase的MVCC机制中，可能的三种基本并发控制包括：读读、读写以及写写并发。</p><ol><li>读读并发控制: 读读并发是指两个并发事务对同一个数据的访问方式都是读操作，由于数据不会被它们修改，所以即便没有MVCC机制的支持，读读并发的事务之间也不会产生冲突。在OceanBase的访问控制机制中，没有对读读并发做特殊的处理。</li><li>读写并发控制: 读写并发是指两个并发事务访问同一个数据，其中一个事务对该数据执行读操作，而另一个事务对该数据执行写操作。在MVCC机制中，执行写操作的事务会把该数据的当前版本标记为删除然后插入一个新版本，旧版本在物理上依然存在，执行读操作的事务仍然能够读取该数据的当前版本完成自己的执行。因此在OceanBase的访问控制机制中，读写并发是不会产生冲突的。不过，由于还要考虑写写并发控制，因此OceanBase中的写操作事务需要对要写的数据加上排他锁。OceanBase的读操作对访问的数据是不加锁的，所以读不会阻塞写。不过如果用了SELECT…FOR UPDATE语法，则不会是快照读，会尝试加锁（共享锁），直到事务提交或者回滚才释放，这个时候就跟并发写有冲突。</li><li>写写并发控制: OceanBase中的写操作会先在数据上申请加排他锁（即行锁），如果已经有其他锁存在则需要在队列里等待。行锁保证了每个时刻最多只能有一个事务修改这个行，行锁释放的时候通知等待队列里的第一个事务，这个队列避免了锁等待的争抢。同时，根据6.2.1节所述，OceanBase会在行上维护一个链表，记录历史修改和提交版本信息。当然，处于锁等待队列中的事务不会无限制等待下去，每个SQL语句执行有个超时机制，由变量ob_query_timeout控制，默认为10s。如果事务为了执行一个SQL语句等待了过长时间导致超过这个时间限制，事务会报告“lock wait timeout”。这个报错信息是取自MySQL，在MySQL里变量innodb_lock_wait_timeout会控制锁等待超时时间。</li></ol><p>OceanBase的锁机制使用了以数据行为级别的锁粒度。同一行不同列之间的修改会导致同一把锁上的互斥；而不同行的修改是不同的两把锁，因此是无关的。OceanBase的读取操作是不加锁的，因此可以做到读写不互斥，从而提高用户读写事务的并发能力。为了避免在内存中维护大量的锁信息，OceanBase的行锁被实现为存储在行本身中的方式。不过，行锁的等待队列仍然需要被维持在内存中，这样才能在锁被释放的时候唤醒等待队列中的事务。需要注意的是，虽然OceanBase的并发控制机制中读写不冲突，但是在事务提交过程中，为了维护事务的一致性快照，会有短暂的读写互斥，称之为Lock for Read。</p><p>OceanBase的锁存储在行上，从而减少内存中单独维护锁信息所需要的开销。事务对行加锁时，行应该是存在于MemTable中。在每一行的ObMvccRow实例中，都有一个row_lock_属性(ObLatch)用于对该行加锁。ObLatch可以认为是一种低层次的锁，其是latch_属性中的lock_属性(uint32_t)，lock_属性的值包含多方面的信息：<br>1)最高位（WAIT_MASK所标记的位）：是否有事务在等待这个锁，如果为1则ObLatch中的等待队列里有事务正在等待当前这个锁。<br>2)次高位（WRITE_MASK所标记的位）：是否加上了写锁，如果为1表示已经有事务持有了这个数据上的写锁。<br>3)低30位：除两个标志位之外，其他位表示的是持锁事务，但并非事务的ID，而是事务产生的MVCC上下文(ObMvccCtx)的ID。<br>在内存中，当事务获取到行锁时，该事务就是所谓的行锁持有者。当事务尝试获取行锁时，会通过对应的事务标记发现自己不是行锁持有者而放弃并等待或发现自己是行锁持有者后获得行的使用权利。当事务释放行锁后，就会在所有事务涉及的行上解除对应的事务标记，从而允许之后的事务继续尝试获取行锁。<br>当数据被转储到SSTable中后，在宏块内部的数据上记录着对应的事务标记。其余事务依旧需要通过事务标识来辨识是否可以允许访问对应的数据。与内存中的锁机制不同的是，由于SSTable不可变的特性，无法在事务释放行锁后，立即清除宏块内部的数据上的事务标记。当然依旧可以通过事务标识找到对应的事务信息，进而确认事务是否已经解锁。</p><p>OceanBase当前主要依赖超时回滚机制来解决死锁问题，目前存在三种超时机制：</p><ol><li>锁超时机制：依赖于锁超时时间，它由配置参数ob_trx_lock_timeout设置，默认与语句超时时间相同。若事务等待锁的时间超过锁超时时间，则会回滚对应的语句，并返回锁超时对应的错误码。此时，由于某一个循环依赖中的资源依赖已经消失，因此就不再存在死锁。以事务T2获取资源A超时为例，只要事务T2结束，那么资源B上的锁就会被释放，事务T1就可以获取到对应的资源B完成执行。</li><li>语句超时机制：依赖于语句超时时间，它由配置参数ob_query_timeout设置，默认为10s。若语句执行的时间超过语句超时时间，则会回滚对应的语句，并返回语句超时对应的错误码。此时，由于某一个循环依赖中的资源依赖已经消失，因此就不再存在死锁。</li><li>事务超时机制：依赖于事务超时时间，它由配置参数ob_trx_timeout设置，默认为100s。若事务执行的时间超过事务超时时间，则会回滚对应的事务，并返回事务超时对应的错误码。此时，由于某一个循环依赖中的资源依赖已经消失，因此就不再存在死锁。</li></ol><p>OceanBase支持两种隔离级别：读已提交和可串行化。读已提交的功能和问题不再赘述，这里再细说一下可串行化隔离级别。可串行化的定义是让并发的事务执行的效果跟按某种顺序串行执行效果一样。通常的实现方法就是事务期间访问的数据全程加锁（共享锁或排他锁），以防止事务期间访问的数据被其他事务修改了。这样做的并发太低，所以OceanBase在实现可串行化隔离级别的时候实际选用了快照读的策略，整个事务访问的数据是同一个快照版本。这样由于减少了读写并发冲突，整体并发的能力提上去了。不过OceanBase的可串行化级别可能有写偏序(Write Skew)问题，因此OceanBase的可串行化级别并不是真正的可串行化，而是快照隔离级别(Snapshot Isolation)。</p><h2 id="高可用"><a href="#高可用" class="headerlink" title="高可用"></a>高可用</h2><p>在OceanBase中，数据的高可靠机制主要有多副本的容灾复制、回收站和备份恢复等，备份恢复是保护用户数据的最后手段。</p><p>简单来说 OB Server 的集群中，数据是以分区为单位存储并提供高可用的能力。一个分区有多个副本，不同副本在不同区中，其中一个主副本来接收写操作。主从副本之间通过 multi-paxos 来实现副本之间的数据一致性。</p><p>分区的多个副本通过选举协议选择其中一个作为主副本(Leader)，在集群重新启动时或者主副本出现故障时，都会进行这样的选举。选举服务依赖集群中各台机器时钟的一致性，<strong>每台机器之间的时钟误差不能超过200ms</strong>，集群的每台机器应部署NTP或其他时钟同步服务以保证时钟一致。选举服务有优先级机制保证选择更优的副本作为主副本，优先级机制会考虑用户指定的Primary Zone以及机器的异常状态等。</p><p>Paxos 没什么好说的，基本满世界都是 Paxos 相关的文章。</p><p>除了各种软硬件异常导致的失效，数据库受损的另一大主因是误操作。OceanBase对此提供了对象闪回机制作为防护手段，即当数据库对象被删除时并不是从物理上直接删除，而是被转移到回收站中。如果之后发现数据库对象是被误删除，那么就可以从回收站中将数据库对象重新恢复。这种从回收站中恢复被删除数据库对象的操作称为对象闪回(Flashback)。这种操作其实也是业界常用操作，数据删除一般等一段时间后才完全生效，以避免用户误删的情况。</p><p>OceanBase内建支持集群级别的物理备份以及租户级别的恢复。物理备份由基线数据、日志归档数据两种数据组成，因此物理备份由日志归档和数据备份两个功能组合而成：</p><ol><li>日志归档指的是日志数据的自动归档功能，OBServer会定期自动将日志数据归档到指定的备份路径；</li><li>数据备份指的是备份基线数据的功能，该功能分为全量备份和增量备份两种：<ul><li>全量备份是指备份所有的需要基线的宏块：如第4章4.2节所述，OceanBase将数据切分为大小为2MB的宏块，宏块是数据文件I&#x2F;O的基本单位，一个SSTable就由若干个宏块构成。</li><li>增量备份是指备份上一次备份以后新增和修改过的宏块。</li></ul></li></ol><h2 id="多租户"><a href="#多租户" class="headerlink" title="多租户"></a>多租户</h2><p>从租户用途来看，OceanBase中的租户可以分成系统租户和普通租户两种。</p><ol><li><p>系统租户： 系统租户也称为SYS租户，是OceanBase的系统内置租户。系统租户主要有以下几个功能：</p><ul><li>系统租户承载了所有租户的元信息存储和管理服务。例如，系统租户下存储了所有普通租户系统表的对象元数据信息和位置信息。</li><li>系统租户是分布式集群集中式策略的执行者。例如，只有在系统租户下，才可以执行轮转合并、删除或创建普通租户、修改系统配置项、资源负载均衡、自动容灾处理等操作。</li><li>系统租户负责管理和维护集群资源。例如，系统租户下存储了集群中所有OBServer的信息和Zone的信息。<br>系统租户在集群自举过程中创建，系统租户信息和资源的管理都是在RootService服务(RS)上完成，RS位于系统租户下__all_core_table表的主副本上。</li></ul></li><li><p>普通租户<br>普通租户可以看作是一个数据库实例代名词，其中仅包括用户级别的数据。普通租户由管理员通过系统租户根据业务需要来创建，普通租户具备一个实例所应该具有的所有特性：</p><ul><li>可以创建自己的用户。</li><li>可以创建数据库(Database)、表(Table)等各种数据库对象。</li><li>有自己独立的系统表和系统视图。</li><li>有自己独立的系统变量。</li><li>数据库实例所具备的其他特性。</li></ul></li></ol><p>OceanBase中，使用资源单元(Resource Unit)、资源池(Resource Pool)和资源单元配置(Resource Unit Config)三个概念，对各租户的可用资源进行定义。</p><ol><li>资源单元(Resource Unit)。资源单元是一个容器。实际上，副本是存储在资源单元之中的，所以资源单元是副本的容器。每个资源单元描述了位于一个节点上的一组计算和存储资源，可以视为一个轻量级虚拟机，包括若干CPU、内存、磁盘资源等。资源单元是为租户分配资源的最小单位，一个租户在同一个节点上最多有一个资源单元。资源单元也不能跨节点，每个资源单元一定会被放置在资源足以容纳它的节点上。同时资源单元也是集群负载均衡的一个基本单位，当集群内的节点下线前，其上的资源单元必须迁移到其他节点上，如果集群内其他节点的资源不足以容纳这些资源单元，会导致节点下线无法成功。</li><li>资源池(Resource Pool)。资源池是资源单元的集合，一个资源池由具有相同资源单元配置(Resource Unit Config)的若干个资源单元组成。一个资源池只能属于一个租户，一个租户可以拥有若干个资源池，这些资源池的集合描述了这个租户所能使用的所有资源。资源池中会定义资源池属于哪些Zone以及在每个Zone上的资源单元数量，OceanBase系统会在Zone内根据负载为每个资源单元选择一个节点放置，受制于一个节点最多承载一个租户的一个资源单元，因此资源池定义的每Zone单元数不能超过Zone中的节点数。</li><li>资源单元配置(Resource Unit Config)。资源单元配置是对资源单元中所拥有资源的描述，包含资源单元所属的资源池信息、使用资源的租户信息、资源单元的配置信息（如CPU核数和内存资源）等。修改资源单元配置可以动态调整资源单元的计算资源，进而调整对应租户的资源。</li></ol><p>在系统运行过程中，当资源单元出现变化（包括创建新的资源单元以及服务器上资源使用不均衡时）导致需要进行系统资源均衡时，需要考虑资源占用率。当系统中有多种资源需要进行均衡时，仅使用其中一种资源的占用率去进行均衡不可能准确，也很难达到较好的均衡效果。为此，OceanBase在多种资源（CPU资源和内存资源）均衡和分配时，使用了如下的资源占用评估方法，即为参与分配和均衡的每种资源分配一个权重，作为计算OBServer总的资源占用率时该资源所占的百分比，每种资源使用得越多，其权重就越高。<br>创建一个新的资源单元时，需要为该资源单元选择一个OBServer宿主机，分配宿主机所采用的方法是：先根据上面多资源占用率的计算规则，计算出每一个OBServer的资源占用率，然后选取资源占用率最小的那台OBServer，作为新建资源单元的宿主机。<br>资源单元均衡是通过在OBServer间迁移资源单元的方式使得各OBServer的资源占用率相差尽量小，使用上述多种资源占用率的算法，可以计算出每台OBServer的资源占用率，并尝试不断迁移资源单元，使得迁移资源单元完成后各OBServer之间的资源占用率比迁移资源单元前更小，即完成了资源单元的均衡。<br>例如，某集群中总的CPU资源为50个CPU，资源单元共占用20个CPU，则CPU总的占用率为40%。该集群中总的内存资源为1000GB，资源单元共占用内存资源100GB，则内存占用率为10%，集群中没有其他资源参与均衡。归一化后，CPU和内存资源的权重分配为80%和20%，各OBServer根据该权重计算各自的资源占用率，然后再通过迁移降低各OBServer之间的资源占用率差值。</p><h2 id="安全管理"><a href="#安全管理" class="headerlink" title="安全管理"></a>安全管理</h2><p>OceanBase的安全体系主要包括身份鉴别、访问控制和安全审计，这些就没什么好说了。</p>]]></content>
    
    
    <summary type="html">《OceanBase 数据库源码解析》读书笔记，按概述、架构、OBServer、存储引擎、SQL 引擎、事务引擎、高可用、多租户、安全九章梳理 OB 的 Shared-Nothing 与 Paxos 复制组、RootService 总控、LSM-Tree 存储与多级缓存、查询优化与并行执行、两阶段提交事务及多租户资源隔离。</summary>
    
    
    
    <category term="技术书籍" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E4%B9%A6%E7%B1%8D/"/>
    
    
    <category term="数据库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/"/>
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="源码分析" scheme="https://liang7878.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 -- [NOIP2002 普及组] 级数求和</title>
    <link href="https://liang7878.github.io/posts/45d47167.html"/>
    <id>https://liang7878.github.io/posts/45d47167.html</id>
    <published>2023-08-22T22:14:43.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<h1 id="NOIP2002-普及组-级数求和"><a href="#NOIP2002-普及组-级数求和" class="headerlink" title="[NOIP2002 普及组] 级数求和"></a>[NOIP2002 普及组] 级数求和</h1><h2 id="题目描述"><a href="#题目描述" class="headerlink" title="题目描述"></a>题目描述</h2><p>已知：$S_n&#x3D; 1+\dfrac{1}{2}+\dfrac{1}{3}+…+\dfrac{1}{n}$。显然对于任意一个整数 $k$，当 $n$ 足够大的时候，$S_n&gt;k$。</p><p>现给出一个整数 $k$，要求计算出一个最小的 $n$，使得 $S_n&gt;k$。</p><span id="more"></span><h2 id="输入格式"><a href="#输入格式" class="headerlink" title="输入格式"></a>输入格式</h2><p>一个正整数 $k$。</p><h2 id="输出格式"><a href="#输出格式" class="headerlink" title="输出格式"></a>输出格式</h2><p>一个正整数 $n$。</p><h2 id="样例-1"><a href="#样例-1" class="headerlink" title="样例 #1"></a>样例 #1</h2><h3 id="样例输入-1"><a href="#样例输入-1" class="headerlink" title="样例输入 #1"></a>样例输入 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">1</span><br></pre></td></tr></table></figure><h3 id="样例输出-1"><a href="#样例输出-1" class="headerlink" title="样例输出 #1"></a>样例输出 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">2</span><br></pre></td></tr></table></figure><h2 id="提示"><a href="#提示" class="headerlink" title="提示"></a>提示</h2><p><strong>【数据范围】</strong></p><p>对于 $100%$ 的数据，$1\le k \le 15$。</p><p><strong>【题目来源】</strong></p><p>NOIP 2002 普及组第一题</p><hr><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    <span class="type">double</span> sum;</span><br><span class="line">    <span class="type">int</span> n = <span class="number">1</span>;</span><br><span class="line">    <span class="type">int</span> k;</span><br><span class="line"></span><br><span class="line">    cin &gt;&gt; k;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">while</span> (sum &lt;= k) &#123;</span><br><span class="line">        sum += <span class="number">1.0</span> / n;</span><br><span class="line">        n++;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    cout &lt;&lt; n - <span class="number">1</span> &lt;&lt; endl;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">已知：$S_n= 1+&#92;dfrac{1}{2}+&#92;dfrac{1}{3}+…+&#92;dfrac{1}{n}$。显然对于任意一个整数 $k$，当 $n$ 足够大的时候，$S_n&gt;k$。</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 -- P1085 不高兴的津津</title>
    <link href="https://liang7878.github.io/posts/58cf4799.html"/>
    <id>https://liang7878.github.io/posts/58cf4799.html</id>
    <published>2023-08-22T21:53:16.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<h1 id="NOIP2004-普及组-不高兴的津津"><a href="#NOIP2004-普及组-不高兴的津津" class="headerlink" title="[NOIP2004 普及组] 不高兴的津津"></a>[NOIP2004 普及组] 不高兴的津津</h1><h2 id="题目描述"><a href="#题目描述" class="headerlink" title="题目描述"></a>题目描述</h2><p>津津上初中了。妈妈认为津津应该更加用功学习，所以津津除了上学之外，还要参加妈妈为她报名的各科复习班。另外每周妈妈还会送她去学习朗诵、舞蹈和钢琴。但是津津如果一天上课超过八个小时就会不高兴，而且上得越久就会越不高兴。假设津津不会因为其它事不高兴，并且她的不高兴不会持续到第二天。请你帮忙检查一下津津下周的日程安排，看看下周她会不会不高兴；如果会的话，哪天最不高兴。</p><span id="more"></span><h2 id="输入格式"><a href="#输入格式" class="headerlink" title="输入格式"></a>输入格式</h2><p>输入包括 $7$ 行数据，分别表示周一到周日的日程安排。每行包括两个小于 $10$ 的非负整数，用空格隔开，分别表示津津在学校上课的时间和妈妈安排她上课的时间。</p><h2 id="输出格式"><a href="#输出格式" class="headerlink" title="输出格式"></a>输出格式</h2><p>一个数字。如果不会不高兴则输出 $0$，如果会则输出最不高兴的是周几（用 $1, 2, 3, 4, 5, 6, 7$ 分别表示周一，周二，周三，周四，周五，周六，周日）。如果有两天或两天以上不高兴的程度相当，则输出时间最靠前的一天。</p><h2 id="样例-1"><a href="#样例-1" class="headerlink" title="样例 #1"></a>样例 #1</h2><h3 id="样例输入-1"><a href="#样例输入-1" class="headerlink" title="样例输入 #1"></a>样例输入 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">5 3</span><br><span class="line">6 2</span><br><span class="line">7 2</span><br><span class="line">5 3</span><br><span class="line">5 4</span><br><span class="line">0 4</span><br><span class="line">0 6</span><br></pre></td></tr></table></figure><h3 id="样例输出-1"><a href="#样例输出-1" class="headerlink" title="样例输出 #1"></a>样例输出 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">3</span><br></pre></td></tr></table></figure><h2 id="提示"><a href="#提示" class="headerlink" title="提示"></a>提示</h2><p>NOIP2004 普及组第 1 题</p><ul><li>2021-10-27：增加一组 hack 数据</li><li>2022-06-05：又增加一组 hack 数据</li></ul><hr><p>没什么好说的，直接写</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    <span class="type">int</span> res = <span class="number">0</span>, max = <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> (<span class="type">int</span> i = <span class="number">0</span>; i &lt; <span class="number">7</span>; i++) &#123;</span><br><span class="line">        <span class="type">int</span> a, b;</span><br><span class="line"></span><br><span class="line">        cin &gt;&gt; a &gt;&gt; b;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (a + b &gt; <span class="number">8</span> &amp;&amp; a + b &gt; max) &#123;</span><br><span class="line">            max = a + b;</span><br><span class="line">            res = i + <span class="number">1</span>;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    cout &lt;&lt; res &lt;&lt; endl;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">津津上初中了。妈妈认为津津应该更加用功学习，所以津津除了上学之外，还要参加妈妈为她报名的各科复习班。另外每周妈妈还会送她去学习朗诵、舞蹈和钢琴。但是津津如果一天上课超过八个小时就会不高兴，而且上得越久就会越不高兴。假设津津不会因为其它事不高兴，并且她的不高兴不会持续到第二天。请你帮忙检查一下津津下周的日程安排，看看下周…</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 -- P1089 [NOIP2004 提高组] 津津的储蓄计划</title>
    <link href="https://liang7878.github.io/posts/76e54fc8.html"/>
    <id>https://liang7878.github.io/posts/76e54fc8.html</id>
    <published>2023-08-22T20:47:12.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<h1 id="NOIP2004-提高组-津津的储蓄计划"><a href="#NOIP2004-提高组-津津的储蓄计划" class="headerlink" title="[NOIP2004 提高组] 津津的储蓄计划"></a>[NOIP2004 提高组] 津津的储蓄计划</h1><h2 id="题目描述"><a href="#题目描述" class="headerlink" title="题目描述"></a>题目描述</h2><p>津津的零花钱一直都是自己管理。每个月的月初妈妈给津津 $300$ 元钱，津津会预算这个月的花销，并且总能做到实际花销和预算的相同。</p><p>为了让津津学习如何储蓄，妈妈提出，津津可以随时把整百的钱存在她那里，到了年末她会加上 $20%$ 还给津津。因此津津制定了一个储蓄计划：每个月的月初，在得到妈妈给的零花钱后，如果她预计到这个月的月末手中还会有多于 $100$ 元或恰好 $100$ 元，她就会把整百的钱存在妈妈那里，剩余的钱留在自己手中。</p><p>例如 $11$月初津津手中还有 $83$ 元，妈妈给了津津 $300$ 元。津津预计$11$月的花销是 $180$ 元，那么她就会在妈妈那里存 $200$ 元，自己留下 $183$ 元。到了 $11$ 月月末，津津手中会剩下 $3$ 元钱。</p><p>津津发现这个储蓄计划的主要风险是，存在妈妈那里的钱在年末之前不能取出。有可能在某个月的月初，津津手中的钱加上这个月妈妈给的钱，不够这个月的原定预算。如果出现这种情况，津津将不得不在这个月省吃俭用，压缩预算。</p><p>现在请你根据 $2004$ 年 $1$ 月到 $12$ 月每个月津津的预算，判断会不会出现这种情况。如果不会，计算到 $2004$ 年年末，妈妈将津津平常存的钱加上 $20%$ 还给津津之后，津津手中会有多少钱。</p><span id="more"></span><h2 id="输入格式"><a href="#输入格式" class="headerlink" title="输入格式"></a>输入格式</h2><p>$12$ 行数据，每行包含一个小于 $350$ 的非负整数，分别表示 $1$ 月到 $12$ 月津津的预算。</p><h2 id="输出格式"><a href="#输出格式" class="headerlink" title="输出格式"></a>输出格式</h2><p>一个整数。如果储蓄计划实施过程中出现某个月钱不够用的情况，输出 $-X$，$X$ 表示出现这种情况的第一个月；否则输出到 $2004$ 年年末津津手中会有多少钱。</p><p>注意，洛谷不需要进行文件输入输出，而是标准输入输出。</p><h2 id="样例-1"><a href="#样例-1" class="headerlink" title="样例 #1"></a>样例 #1</h2><h3 id="样例输入-1"><a href="#样例输入-1" class="headerlink" title="样例输入 #1"></a>样例输入 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">290</span><br><span class="line">230</span><br><span class="line">280</span><br><span class="line">200</span><br><span class="line">300</span><br><span class="line">170</span><br><span class="line">340</span><br><span class="line">50 </span><br><span class="line">90 </span><br><span class="line">80 </span><br><span class="line">200</span><br><span class="line">60</span><br></pre></td></tr></table></figure><h3 id="样例输出-1"><a href="#样例输出-1" class="headerlink" title="样例输出 #1"></a>样例输出 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">-7</span><br></pre></td></tr></table></figure><h2 id="样例-2"><a href="#样例-2" class="headerlink" title="样例 #2"></a>样例 #2</h2><h3 id="样例输入-2"><a href="#样例输入-2" class="headerlink" title="样例输入 #2"></a>样例输入 #2</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">290 </span><br><span class="line">230 </span><br><span class="line">280 </span><br><span class="line">200 </span><br><span class="line">300 </span><br><span class="line">170 </span><br><span class="line">330 </span><br><span class="line">50 </span><br><span class="line">90 </span><br><span class="line">80 </span><br><span class="line">200 </span><br><span class="line">60</span><br></pre></td></tr></table></figure><h3 id="样例输出-2"><a href="#样例输出-2" class="headerlink" title="样例输出 #2"></a>样例输出 #2</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">1580</span><br></pre></td></tr></table></figure><hr><p>这个题没什么技巧，属于读完题就直接写出来的题</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line">#include &lt;iostream&gt;</span><br><span class="line"></span><br><span class="line">using namespace std;</span><br><span class="line"></span><br><span class="line">int main() &#123;</span><br><span class="line">    int mom = 0;</span><br><span class="line">    int jin = 0;</span><br><span class="line"></span><br><span class="line">    for (int i = 0; i &lt; 12; i++) &#123;</span><br><span class="line">        int budget;</span><br><span class="line">        cin &gt;&gt; budget;</span><br><span class="line"></span><br><span class="line">        int remain = jin + 300 - budget;</span><br><span class="line"></span><br><span class="line">        if (remain &lt; 0)  &#123;</span><br><span class="line">            cout &lt;&lt; &quot;-&quot; &lt;&lt; (i+1) &lt;&lt; endl;</span><br><span class="line">            return 0;</span><br><span class="line">        &#125; else &#123;</span><br><span class="line">            mom = mom + remain - remain%100;</span><br><span class="line">            jin = remain%100;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    cout &lt;&lt; mom*1.2+jin &lt;&lt; endl;</span><br><span class="line"></span><br><span class="line">    return 0;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">津津的零花钱一直都是自己管理。每个月的月初妈妈给津津 $300$ 元钱，津津会预算这个月的花销，并且总能做到实际花销和预算的相同。</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
  <entry>
    <title>读《性能之巅》</title>
    <link href="https://liang7878.github.io/posts/6429ef26.html"/>
    <id>https://liang7878.github.io/posts/6429ef26.html</id>
    <published>2023-08-21T14:16:46.000Z</published>
    <updated>2023-08-21T14:16:46.000Z</updated>
    
    <content type="html"><![CDATA[<p>《Systems Performance》这本书可以说是做性能调优的必读书目了，不仅仅是因为它有很多关于性能调优的理论知识，还因为它有很多关于性能调优的实例和思考。其实这本书我几年前就读过了，这次重新回顾一下，可以记录一些重要的知识点。话不多说，开始读起来。</p><span id="more"></span><h2 id="绪论"><a href="#绪论" class="headerlink" title="绪论"></a>绪论</h2><p>系统性能是对整个系统的研究，包括了所有的硬件组件和整个软件栈。术语“全栈”（entire stack）有时一般仅仅指的是应用程序环境，包括数据库、应用程序，以及网站服务器。不过，当论及系统性能时，我们用全栈来表示所有事情，包括系统库和内核。</p><p>性能领域包括了一下的事情：</p><p>1.设置性能目标和建立性能模型<br>2.基于软件或硬件原型进行性能特征归纳<br>3.对开发代码进行性能分析（软件整合之前）<br>4.执行软件非回归性测试（软件发布前或发布后）<br>5.针对软件发布版本的基准测试<br>6.目标环境中的概念验证（Proof-of-concept）测试<br>7.生产环境部署的配置优化<br>8.监控生产环境中运行的软件<br>9.特定问题的性能分析</p><p>性能分析的两种视角：负载分析（workload analysis）和资源分析（resource analysis），二者从不同的方向对软件栈做分析。</p><h2 id="方法"><a href="#方法" class="headerlink" title="方法"></a>方法</h2><p>常见术语：</p><ul><li>IOPS：每秒发生的输入&#x2F;输出操作的次数，是数据传输的一个度量方法。对于磁盘的读写，IOPS 指的是每秒读和写的次数。</li><li>吞吐量：评价工作执行的速率，尤其是在数据传输方面，这个术语用于描述数据传输速度（字节&#x2F;秒或比特&#x2F;秒）。在某些情况下（如数据库），吞吐量指的是操作的速度（每秒操作数或每秒业务数）</li><li>响应时间：一次操作完成的时间。包括用于等待和服务的时间，也包括用来返回结果的时间。</li><li>延时：延时是描述操作里用来等待服务的时间。在某些情况下，它可以指的是整个操作时间，等同于响应时间。例子参见 2.3 节。</li><li>使用率：对于服务所请求的资源，使用率描述在所给定的时间区间内资源的繁忙程度。对于存储资源来说，使用率指的就是所消耗的存储容量（例如，内存使用率）。</li><li>饱和度：指的是某一资源无法满足服务的排队工作量。</li><li>瓶颈：在系统性能里，瓶颈指的是限制系统性能的那个资源。分辨和移除系统瓶颈是系统性能的一项重要工作。</li><li>工作负载：系统的输入或者是对系统所施加的负载叫做工作负载。对于数据库来说，工作负载就是客户端发出的数据库请求和命令。</li><li>缓存：用于复制或者缓冲一定量数据的高速存储区域，目的是为了避免对较慢的存储层级的直接访问，从而提高性能。出于经济考虑，缓存区的容量要比更慢一级的存储容量要小。</li></ul><p>各种系统延时：</p><p><img data-src="/posts/6429ef26/system_latency.png" alt="系统延时层级示意图"></p><p>在计算机性能领域，profiling通常是按照特定的时间间隔对系统的状态进行采样，然后对这些样本进行研究。</p><p>通用的系统性能方法：</p><ul><li>问题陈述法：对性能问题的描述，包括对性能目标的定义。</li><li>科学法：科学法研究未知的问题是通过假设和试验。总结下来有以下步骤：问题、假设、预测、试验、分析。</li><li>诊断循环：假设→仪器检验→数据→假设</li><li>工具法：1.列出可用到的性能工具（可选的，安装的或者可购买的）2.对于每一个工具，列出它提供的有用的指标 3.对于每一个指标，列出阐释该指标可能的规则。</li><li>USE 方法（utilization、saturation、errors）应用于性能研究，用来识别系统瓶颈[Gregg 13]。一言以蔽之，就是：对于所有的资源，查看它的使用率、饱和度和错误。</li><li>工作负载特征归纳：工作负载可以通过回答下列的问题来进行特征归纳：负载是谁产生的？进程ID、用户ID、远端的IP 地址？负载为什么会被调用？代码路径、堆栈跟踪？负载的特征是什么？IOPS、吞吐量、方向类型（读取&#x2F;写入）？包含变动（标准方差），如果有的话。负载是怎样随着时间变化的？有日常模式吗？</li><li>向下挖掘分析：1.监测：用于持续记录高层级的统计数据，如果问题出现，予以辨别和报警。2.识别：对于给定问题，缩小研究的范围，找到可能的瓶颈。3.分析：对特定的系统部分做进一步的检查，找到问题根源并量化问题。五个 Why</li><li>延时分析：延时分析检查完成一项操作所用的时间，然后把时间再分成小的时间段，接着对有着最大延时的时间段做再次的划分，最后定位并量化问题的根本原因。</li><li>R 方法：R方法是针对Oracle 数据库开发的性能分析方法，意在找到延时的根源，基于Oracle 的trace events[Millsap 03]。它被描述成“基于时间的响应性能提升方法，可以得到对业务的最大经济收益”，着重于识别和量化查询过程中所消耗的时间。</li><li>事件跟踪：系统的操作就是处理离散的事件，包括CPU 指令、磁盘I&#x2F;O，以及磁盘命令、网络包、系统调用、函数库调用、应用程序事件、数据库查询，等等</li><li>基础线统计：基础线统计包括大范围的系统观测并将数据进行保存以备将来参考。在系统或应用程序变化的之前和之后都能做基础线统计，进而分析性能变化。可以不定期地执行基础线统计并把它作为站点记录的一部分，让管理员有一个参照，了解“正常”是什么样的。若是作为性能监测的一部分，可以每天都按固定间隔执行这类任务。</li><li>静态性能调整：静态性能分析是在系统空闲没有施加负载的时候执行的。做性能分析和调整，要对系统的所有组件逐一确认下列问题：该组件是需要的吗？配置是针对预期的工作负载设定的吗？组件的自动配置对于预期的工作负载是最优的吗？有组件出现错误吗？是在降级状态（degraded state）吗？</li><li>缓存调优：1.缓存的大小尽量和栈的高度一样，靠近工作执行的地方，减少命中缓存的资源开销。2.确认缓存开启并确实在工作。3.确认缓存的命中&#x2F;失效比例和失效率。4.如果缓存的大小是动态的，确认它的当前尺寸。5.针对工作负载调整缓存。这项工作依赖缓存的可调参数。6.针对缓存调整工作负载。这项工作包括减少对缓存不必要的消耗，这样可以释放更多空间来给目标工作负载使用。</li><li>微基准测试：微基准测试测量的是施加了简单的人造工作负载的性能。微基准测试可以用于支持科学方法，将假设和预测放到测试中验证，或者作为容量规划的一部分来执行。可以用微基准测试工具来施加工作负载并度量性能。或者用负载生成器来产生负载，用标准的系统工具来测量性能。两种方法都可以，但最稳妥的办法是使用微基准测试工具并用标准系统工具再次确认性能数据。</li></ul><h2 id="操作系统"><a href="#操作系统" class="headerlink" title="操作系统"></a>操作系统</h2><ul><li>操作系统：这里指的是安装在系统上的软件和文件，使得系统可以启动和运行程序。操作系统包括内核、管理工具，以及系统库。</li><li>内核：内核是管理系统的程序，包括设备（硬件）、内存和CPU 调度。它运行在CPU的特权模式，允许直接访问硬件，称为内核态。</li><li>进程：是一个OS 的抽象概念，是用来执行程序的环境。程序通常运行在用户模式，通过系统调用或自陷来进入内核模式（例如，执行设备I&#x2F;O）。进程是用以执行用户级别程序的环境。它包括内存地址空间、文件描述符、线程栈和寄存器</li><li>线程：可被调度的运行在CPU 上的可执行上下文。内核有多个线程，一个进程有一个或多个线程。</li><li>任务：一个Linux 的可运行实体，可以指一个进程（含有单个线程），或一个多线程的进程里的一个线程，或者内核线程。</li><li>内核空间：内核的内存地址空间。</li><li>用户空间：进程的内存地址空间。</li><li>用户空间：用户级别的程序和库（&#x2F;usr&#x2F;bin、&#x2F;usr&#x2F;lib……）。</li><li>上下文切换：内核程序切换CPU 让其在不同的地址空间上做操作（上下文）。</li><li>系统调用：一套定义明确的协议，为用户程序请求内核执行特权操作，包括设备I&#x2F;O。</li><li>处理器：不要与进程混淆[1]，处理器是包含有一颗或多颗CPU 的物理芯片。</li><li>自陷：信号发送到内核，请求执行一段系统程序（特权操作）。自陷类型包括系统调用、处理器异常，以及中断。</li><li>中断：由物理设备发送给内核的信号，通常是请求I&#x2F;O 服务。中断是自陷的一种类型。</li></ul><p>内核管理着CPU 调度、内存、文件系统、网络协议，以及系统设备（磁盘、网络接口，等等）。</p><p>栈用函数和寄存器的方式记录了线程的执行历史。当函数被调用时，CPU 当前的寄存器组（保存CPU 状态）会存放在栈里，在顶部会为线程的当前执行添加一个新的栈帧。函数通过调用CPU 指令“return”终止执行，从而清除当前的栈，执行会返回到之前的栈，并恢复相应的状态。</p><p>在执行系统调用时，一个进程的线程有两个栈：一个用户级别的栈和一个内核级别的栈。</p><p>中断服务程序（interrupt service routine）需要通过注册来处理设备中断。这类程序的设计要点是需要运行得尽可能快，以减少对活动线程中断的影响。如果中断要做的工作不少，尤其是还可能被锁阻塞，那么最好用中断线程来处理，由内核来调度。</p><p>从中断开始到中断被服务之间的时间叫做中断延时（interrupt latency）</p><p>中断优先级（interrupt priority level，IPL）表示的是当前活跃的中断服务程序的优先级。</p><p>虚拟内存是主存的抽象，提供进程和内核，它们自己的近乎是无穷的和私有的主存视野。</p><p>一级存储是主存（RAM），二级存储是存储设备（磁盘）。</p><p>当虚拟内存用二级存储作为主存的扩展时，内核会尽力保持最活跃的数据在主存中。有以下两个内核例程做这件事情。<br>● 交换：让整个进程在主存和二级存储之间做移动。<br>● 换页：移动称为页的小的内存单元（例如，4KB）。<br>swapping 是原始的UNIX 方法，会引起严重的性能损耗。paging 是更高效的方法，经由换页虚拟内存的引入而加到了BSD 中。两种方法，最近最少使用（或最近未使用）的内存被移动到二级存储，仅在需要时再次搬回</p><p>调度器可以动态地修改进程的优先级以提升特定工作负载的性能。工作负载可以做以下分类：</p><ul><li>CPU 密集型：应用程序执行繁重的计算，例如，科学和数学分析，通常运行时间较长（秒、分钟、小时）。这些会受到CPU 资源的限制。</li><li>I&#x2F;O 密集型：应用程序执行I&#x2F;O，计算不多，例如，Web 服务器、文件服务器，以及交互的shell，这些需要的是低延时的响应。当负载增加时，会受到存储I&#x2F;O 或网络资源的限制。</li></ul><p>操作系统提供了全局的文件命名空间，组织成为一个以根目录（“&#x2F;”）为起点，自上而下的拓扑结构。通过挂载（mounting）可以添加文件系统的树，把自己的树挂在一个目录上（挂载点）。这使得遍历文件命名空间对于终端用户是透明的，不用考虑底层的文件系统类型。</p><h2 id="观测工具"><a href="#观测工具" class="headerlink" title="观测工具"></a>观测工具</h2><ol><li>计数器</li></ol><p>内核维护了各种统计数据，称为计数器，用于对事件计数。计数器的使用可以认为是“零开销”的，因为它们默认就是开启的，而且始终由内核维护。唯一的使用开销是从用户空间读取它们的时候（可以忽略不计）。</p><p>系统级别：</p><ul><li>vmstat：虚拟内存和物理内存的统计，系统级别。</li><li>mpstat：每个CPU 的使用情况。</li><li>iostat：每个磁盘I&#x2F;O 的使用情况，由块设备接口报告。</li><li>netstat：网络接口的统计，TCP&#x2F;IP 栈的统计，以及每个连接的一些统计信息。</li><li>sar：各种各样的统计，能归档历史数据。</li></ul><p>进程级别：</p><ul><li>ps：进程状态，显示进程的各种统计信息，包括内存和CPU 的使用。</li><li>top：按一个统计数据（如CPU 使用）排序，显示排名高的进程。基于Solaris 的系统对应的工具是prstat(1M)。</li><li>pmap：将进程的内存段和使用统计一起列出。</li></ul><ol start="2"><li>tracing</li></ol><p>跟踪收集每一个事件的数据以供分析。跟踪框架一般默认是不启用的，因为跟踪捕获数据会有CPU 开销。</p><p>系统级别：</p><ul><li>tcpdump：网络包跟踪（用libpcap 库）。</li><li>snoop：为基于Solaris 的系统打造的网络包跟踪工具。</li><li>blktrace：块I&#x2F;O 跟踪（Linux）。</li><li>iosnoop：块I&#x2F;O 跟踪（基于DTrace）。</li><li>execsnoop：跟踪新进程（基于DTrace）。</li><li>dtruss：系统级别的系统调用缓冲跟踪（基于DTrace）。</li><li>DTrace：跟踪内核的内部活动和所有资源的使用情况（不仅仅是网络和块I&#x2F;O），支持静态和动态的跟踪。</li><li>SystemTap：跟踪内核的内部活动和所有资源的使用情况，支持静态和动态的跟踪。</li><li>perf：Linux 性能事件，跟踪静态和动态的探针。</li></ul><p>进程级别：</p><ul><li>strace：基于Linux 系统的系统调用跟踪。</li><li>truss：基于Solaris 系统的系统调用跟踪。</li><li>gdb：源代码级别的调试器，广泛应用于Linux 系统。</li><li>mdb：Solaris 系统的一个具有可扩展性的调试器。</li></ul><ol start="3"><li>Profiling</li></ol><p>剖析（profiling）通过对目标收集采样或快照来归纳目标特征。</p><p>系统级别和进程级别：</p><ul><li>profile：Linux 系统剖析。</li><li>perf：Linux 性能工具集，包含有剖析的子命令。</li><li>DTrace：程序化剖析，基于时间的剖析用自身的profile provider，基于硬件事件的剖析用cpc provider。</li><li>SystemTap：程序化剖析，基于时间的剖析用自身的timer tapset，基于硬件事件的剖析用自身perf tapset。</li><li>cachegrind：源自valgrind 工具集，能对硬件缓存的使用做剖析，也能用kcachegrind做数据可视化。</li><li>Intel VTune Amplifier XE：Linux 和Windows 的剖析，拥有包括源代码浏览在内的图形界面。</li><li>Oracle Solaris Studio：用自带的性能分析器对Solaris 和Linux 做剖析，拥有包括源代码浏览在内的图形界面。</li></ul><h2 id="应用程序"><a href="#应用程序" class="headerlink" title="应用程序"></a>应用程序</h2><p>性能调整离工作所执行的地方越近越好：最好在应用程序里。</p><p>一个能有效提高应用程序性能的方法是找到对应生产环境工作负载的公用代码路径，并开始对其做优化。如果应用程序是CPU 密集型的，那么意味着代码路径会频繁占用CPU。如果应用程序是I&#x2F;O 密集型的，你应该查看导致频繁I&#x2F;O 的代码路径。</p><p>操作系统最大的性能提升在于消除不必要的工作。</p><p>应用程序性能技术：</p><ul><li>选择 IO 的大小：执行I&#x2F;O 的开销包括初始化缓冲区、系统调用、上下文切换、分配内核元数据、检查进程权限和限制、映射地址到设备、执行内核和驱动代码来执行I&#x2F;O，以及，在最后释放元数据和缓冲区。增加I&#x2F;O 尺寸是应用程序提高吞吐量的常用策略。考虑到每次I&#x2F;O 的固定开销，一次I&#x2F;O 传输128KB 要比128 次传输1KB 高效得多。尤其是磁盘I&#x2F;O，由于寻道时间，每次I&#x2F;O 开销都较高。</li><li>缓存：缓存提高了读操作性能，存储通常用缓冲区来提高写操作的性能。</li><li>缓冲区：环形缓冲区（或循环缓冲区）是一类用于组件之间连续数据传输的大小固定的缓冲区，缓冲区的操作是异步的。该类型缓冲可以用头指针和尾指针来实现，指针随着数据的增加或移出而改变位置。</li><li>轮询：轮询是系统等待某一事件发生的技术，该技术在循环中检查事件状态，两次检查之间有停顿。轮询有一些潜在的性能问题：重复检查的CPU 开销高昂；事件发生和下一次检查的延时较高。poll()接口支持多个文件描述符作为一个数组，当事件发生要找到相应的文件描述符时，需要应用程序扫描这个数组。这个扫描是O(n)，扩展时可能会变成一个性能问题：在Linux 里是epoll()，epoll()避免了这种扫描，复杂度是O(1)</li><li>并发和并行：分时系统（包括所有从UNIX 衍生的系统）支持程序的并发：装载和开始执行多个可运行程序的能力。另外一个方法是基于事件并发（event-based concurrency），应用程序服务于不同的函数并在事件发生时在这些函数之间进行切换。</li></ul><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">Note:</span><br><span class="line"></span><br><span class="line">同步原语：</span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> mutex（MUTually EX clusive）锁：只有锁持有者才能操作，其他线程会阻塞并等待CPU。</span><br><span class="line"><span class="bullet">-</span> 自旋锁：自旋锁允许锁持有者操作，其他的需要自旋锁的线程会在CPU 上循环自旋，检查锁是否被释放。虽然这样可以提供低延时的访问，被阻塞的线程不会离开CPU，时刻准备着运行直到锁可用，但是线程自旋、等待也是对CPU 资源的浪费。</span><br><span class="line"><span class="bullet">-</span> 读写锁：读/写锁通过允许多个读者或者只允许一个写者而没有读者，来保证数据的完整性。</span><br><span class="line"></span><br><span class="line">mutex 锁可以用库或内核实现成为自适应mutex 锁（adaptive mutex lock）：这是自旋锁和mutex 锁的混合，如果锁持有者当前正运行在另一个CPU 上，线程会自旋，如果不是，线程会阻塞（或者自旋的时间阈值到了）。自适应的mutex 锁的优化支持低延时访问而又不浪费CPU资源，在基于Solaris 的系统上已经用了很多年。它在2009年应用到了Linux 上，称为自适应自旋mutex（adaptive spinning mutex）</span><br><span class="line"></span><br><span class="line">哈希表：</span><br><span class="line"></span><br><span class="line">可以用一张锁的哈希表来对大量数据结构的锁做数目优化。下面是两种方法：</span><br><span class="line"><span class="bullet">-</span> 为所有的数据结构只设定一个全局的mutex 锁。虽然这个方案很简单，不过并发的访问会有锁的竞争，等待时也会有延时。需要该锁的多个线程会串行执行，而不是并发执行。</span><br><span class="line"><span class="bullet">-</span> 为每个数据结构都设定一个mutex 锁。虽然这个方案将锁的竞争减小到真正需要时才发生——对同一个数据结构的访问也会是并发的——但是锁会有存储开销，为每个数据结构创建和销毁锁也会有CPU 开销。</span><br><span class="line">锁哈希表是一种折衷的方案，当期望锁的竞争能轻一些的时候很适用。创建固定数目的锁，用哈希算法来选择哪个锁用于哪个数据结构。这就避免了随数据结构创建和销毁锁的开销，也避免了只使用单个锁的问题。</span><br></pre></td></tr></table></figure><ul><li>非阻塞 I&#x2F;O：非阻塞I&#x2F;O 模型是异步地发起I&#x2F;O，而不阻塞当前的线程，线程可以执行其他的工作。</li><li>处理器绑定：NUMA 环境对于进程或线程保持运行在一颗CPU 上是有优势的，线程执行I&#x2F;O 后，能像执行I&#x2F;O 之前那样运行在同一CPU 上。这提高了应用程序的内存本地性，减少内存I&#x2F;O，并提高了应用程序的整体性能。某些应用程序会强制将自身与CPU 绑定。对于某些系统，这样做能显著地提高性能。</li></ul><p>方法与分析：</p><ul><li>线程状态分析：六种状态。<ul><li>执行：在CPU 上。</li><li>可运行：等待轮到上CPU。</li><li>匿名换页：可运行，但是因等待匿名换页而受阻。</li><li>睡眠：等待包括网络、块设备和数据&#x2F;文本页换入在内的I&#x2F;O。</li><li>锁：等待获取同步锁（等待其他线程）。</li><li>空闲：等待工作。</li></ul></li><li>CPU剖析：剖析的目标是要判断应用程序是如何消耗CPU 资源的。一个有效的技术是对CPU 上的用户栈跟踪做采样并将采样结果联系起来。栈跟踪告诉我们所选择的代码路径，这能够从高层和底层两方面揭示出应用程序消耗CPU 的原因。</li><li>系统调用分析：执行：CPU 上（用户模式）；系统调用：系统调用的时间（内核模式在运行或者等待）系统调用的时间包括I&#x2F;O、锁，以及其他系统调用类型。其他的线程状态，如可运行（等待CPU）和匿名换页，都被简化了。即使碰到这些状态（CPU 饱和或内存饱和），也能用USE 方法在系统中识别出来。</li><li>I&#x2F;O 剖析：I&#x2F;O 剖析判断的是I&#x2F;O 相关的系统调用执行的原因和方式。用DTrace 可以做到这一点，检查用户栈里系统调用的栈跟踪。</li><li>工作负载特征归纳：应用程序向系统资源——CPU、内存、文件系统、磁盘和网络，施加负载，也通过系统调用向操作系统施加负载。</li><li>USE 方法：USE 方法检查使用率、饱和，以及所有硬件资源的错误。通过发现某一成为瓶颈的资源，许多应用程序的性能问题都能用该方法得到解决。USE 方法也适用于软件资源，取决于应用程序。如果你能找到应用程序的内部组件的功能图，对每种软件资源都做使用率、饱和和错误指标上的考量，看看有什么问题。</li><li>向下挖掘法：对于应用程序，向下挖掘法可以检查应用程序的服务操作作为开始，然后向下至应用程序内部，看看它是如何执行的。对于I&#x2F;O，向下挖掘的程度可以进入系统库、系统调用，甚至是内核。</li><li>锁分析：对于多线程的应用程序，锁可能会成为阻碍并行化和扩展性的瓶颈。锁的分析可以通过检查竞争或者检查过长的持锁时间。第一个要识别的是当前是否有问题。过长的持锁时间并不一定会是问题，但是在将来随着更多的并行负载的加入，可能会产生问题的是试图识别每一个锁的名字（若存在）和通向使用锁的代码路径。</li><li>静态性能调优：重点在于环境配置的问题</li></ul><h2 id="CPU"><a href="#CPU" class="headerlink" title="CPU"></a>CPU</h2><p>CPU 架构</p><p><img data-src="/posts/6429ef26/cpu_arch.png" alt="现代 CPU 架构示意图"></p><p>CPU 内存缓存</p><p><img data-src="/posts/6429ef26/cpu_cache.png" alt="CPU 缓存层级结构示意图"></p><p>正在排队和就绪运行的软件线程数量是一个很重要的性能指标，表示了CPU 的饱和度</p><p>对于多处理器系统，内核通常为每个CPU 提供了一个运行队列，并尽量使得线程每次都被放到同一队列之中。这意味着线程更有可能在同一个CPU 上运行，因为CPU 缓存里保存了它们的数据。</p><p>时钟是一个驱动所有处理器逻辑的数字信号。每个CPU 指令都可能会花费一个或者多个时钟周期（称为CPU 周期）来执行。CPU 以一个特定的时钟频率执行，例如，一个5GHz 的CPU每秒运行五十亿个时钟周期。</p><p>CPU 执行指令集中的指令。一个指令包括以下步骤，每个都由CPU 的一个叫作功能单元的组件处理：<br>1.指令预取<br>2.指令解码<br>3.执行<br>4.内存访问<br>5.寄存器写回</p><p>指令流水线是一种CPU 架构，通过同时执行不同指令的不同部分，来达到同时执行多个指令的结果</p><p>指令宽度描述了同时处理的目标指令数量。现代处理器一般为宽度3 或者宽度4，意味着它们可以在每个周期里最多完成3～4 个指令。</p><p>每指令周期数（CPI）是一个很重要的高级指标，用来描述CPU 如何使用它的时钟周期，同时也可以用来理解CPU 使用率的本质。这个指标也可以被表示为每周期指令数（instructions per cycle，IPC），即CPI 的倒数。CPI 较高代表CPU 经常陷入停滞，通常都是在访问内存。而较低的CPI 则代表CPU 基本没有停滞，指令吞吐量较高。内存访问密集的负载，可以通过下面的方法提高性能，如使用更快的内存（DRAM）、提高内存本地性（软件配置），或者减少内存I&#x2F;O 数量。</p><p>CPU 使用率通过测量一段时间内CPU 实例忙于执行工作的时间比例获得，以百分比表示。CPU 使用率的测量包括了所有符合条件活动的时钟周期，包括内存停滞周期。虽然看上去有些违反直觉，但CPU 有可能像前面描述的那样，会因为经常停滞等待I&#x2F;O 而导致高使用率，而不仅是在执行指令。</p><p>CPU 花在执行用户态应用程序代码的时间称为用户时间，而执行内核态代码的时间称为内核时间。内核时间包括系统调用、内核线程和中断的时间。当在整个系统范围内进行测量时，用户时间和内核时间之比揭示了运行的负载类型。</p><p>计算密集的应用程序几乎会把大量的时间用在用户态代码上，用户&#x2F;内核时间之比接近99&#x2F;1。这类例子有图像处理、基因组学和数据分析。</p><p>I&#x2F;O 密集的应用程序的系统调用频率较高，通过执行内核代码进行I&#x2F;O 操作。例如，一个进行网络I&#x2F;O 的Web 服务器的用户&#x2F;内核时间比大约为70&#x2F;30。</p><p>一个100%使用率的CPU 被称为是饱和的，线程在这种情况下会碰上调度器延时，因为它们需要等待才能在CPU 上运行，降低了总体性能。</p><p>允许更高优先级的线程抢占当前正在运行的线程，并开始执行自己。这样节省了更高优先级工作的运行队列延时时间，提高了性能。</p><p>优先级反转指的是一个低优先级线程拥有了一项资源，从而阻塞了高优先级线程运行的情况。</p><p>多进程 vs 多线程</p><p><img data-src="/posts/6429ef26/multi_process_vs_multi_thread.png" alt="多进程与多线程对比图"></p><p>处理器是围绕最大字长设计的——32 位或者64 位——这是整数大小和寄存器宽度。</p><p>应用程序在CPU 上的运行时间可以通过编译器选项（包括字长设置）来大幅改进。编译器也频繁地更新以利用最新的CPU 指令集以及其他优化。有时应用程序性能可以通过使用新的编译器显著地提高。</p><p>多级缓存是用来取得大小和延时平衡的最佳配置。一级缓存的访问时间一般是几个CPU 时钟周期，而更大的二级缓存大约是几十个时钟周期。主存大概会花上60ns（对于4GHz 处理器大约是240 个周期），而MMU 的地址转译又会增加延时。</p><p>内存可能会同时被缓存在不同处理器的多个CPU 里。当一个CPU 修改了内存，所有的缓存需要知道它们的缓存拷贝已经失效，应该被丢弃，这样后续所有的读才会取到新修改的拷贝。这个过程叫做缓存一致性，确保了CPU 永远访问正确的内存状态。这也是设计可扩展多处理器系统里最大的挑战之一，因为内存会被频繁修改。</p><p>MMU 负责虚拟地址到物理地址的转换</p><p>支撑CPU 的内核软件包括了调度器、调度器类和空闲线程。</p><p>调度器功能如下：</p><ul><li>分时：可运行线程之间的多任务，优先执行最高优先级任务。</li><li>抢占：一旦有高优先级线程变为可运行状态，调度器能够抢占当前运行的线程，这样较高优先级的线程可以马上开始运行。</li><li>负载均衡：把可运行的线程移到空闲或者较不繁忙的CPU 队列中。</li></ul><p>CPU 分析和调优的方法：</p><ul><li><p>工具法： 对于CPU，工具法可以检查以下项目。</p><ul><li>uptime：检查负载平均数以确认CPU 负载是随时间上升还是下降。负载平均数超过了CPU 数量通常代表CPU 饱和。</li><li>vmstat：每秒运行vmstat，然后检查空闲列，看看还有多少余量。少于10%可能是一个问题。</li><li>mpstat：检查单个热点（繁忙）CPU，挑出一个可能的线程扩展性问题。</li><li>top&#x2F;prstat：看看哪个进程和用户是CPU 消耗大户。</li><li>pidstat&#x2F;prstat：把CPU 消耗大户分解成用户和系统时间。</li><li>perf&#x2F;dtrace&#x2F;stap&#x2F;oprofile：从用户时间或者内核时间的角度剖析CPU 使用的堆栈跟踪，以了解为什么使用这么多CPU。</li><li>perf&#x2F;cpustat：测量CPI。</li></ul></li><li><p>USE 方法：对于每个CPU，检查以下内容</p><ul><li>使用率：CPU 繁忙的时间（未在空闲线程中）。</li><li>饱和度：可运行线程排队等待CPU 的程度。</li><li>错误：CPU 错误，包括可改正错误。</li></ul></li><li><p>负载特征归纳：CPU 负载特征归纳的基本属性有：</p><ul><li>平均负载（使用率+饱和度）</li><li>用户时间与系统时间之比</li><li>系统调用频率</li><li>自愿上下文切换频率</li><li>中断频率</li></ul></li><li><p>剖析Profiling</p></li><li><p>周期分析</p></li><li><p>性能监控：使用率和饱和度</p></li><li><p>静态性能调优：配置环境的问题</p></li><li><p>优先级调优：UNIX 一直都提供nice()系统调用，通过设置nice 值以调整进程优先级。正nice 值代表降低进程优先级（更友好），而负值——只能由超级用户（root）设置——代表提高优先级。</p></li><li><p>资源控制： 操作系统可能为给进程或者进程组分配CPU 资源提供细粒度控制。</p></li><li><p>CPU 绑定：另一个CPU 性能调优的方法是把进程和线程绑定在单个CPU 或者一组CPU 上。这可以增加进程的CPU 缓存温度，提高它的内存I&#x2F;O 性能。实现方式有进程绑定和独占 CPU 组两种</p></li><li><p>微型基准测试：操作可能基于下列元素。</p><ul><li>CPU 指令：整数运算、浮点操作、内存加载和存储、分支和其他指令。</li><li>内存访问：调查不同CPU 缓存的延时和主存吞吐量。</li><li>高级语言：类似CPU 指令测试，不过使用高级解释或者编译语言编写。</li><li>操作系统操作：测试CPU 消耗型系统库和系统调用函数，例如getpid()和进程创建。</li></ul></li><li><p>扩展：一个基于资源的容量规划简单的扩展方法：<br>  1.确定目标用户数或者应用程序请求频率。<br>  2.转化成每用户或每请求CPU 使用率。对于现有系统，CPU 用量可以通过监控获得，再除以现有用户数或者请求数。对于未投入使用系统，负载生成工具可以模拟用户，以获得CPU 用量。<br>  3.推算出当CPU 资源达到100%使用率时的用户或者请求数。这就是系统的理论上限。</p></li></ul><h2 id="内存"><a href="#内存" class="headerlink" title="内存"></a>内存</h2><ul><li>主存：也称为物理内存，描述了计算机的高速数据存储区域，通常是动态随机访问内存（DRAM）。</li><li>虚拟内存：一个抽象的主存概念，它（几乎是）无限的和非竞争性的。虚拟内存不是真实的内存。</li><li>常驻内存：当前处于主存中的内存。</li><li>匿名内存：无文件系统位置或者路径名的内存。它包括进程地址空间的工作数据，称作堆。</li><li>地址空间：内存上下文。每个进程和内核都有对应的虚拟地址空间。</li><li>段：标记为特殊用途的一块内存区域，例如用来存储可执行或者可写的页。</li><li>OOM：内存耗尽，内核检测到可用内存低。</li><li>页：操作系统和CPU 使用的内存单位。它一直以来是4KB 或者8KB。现代的处理器允许多种页大小以支持更大的页面尺寸。</li><li>缺页：无效的内存访问。使用按需虚拟内存时，这是正常事件。</li><li>换页：在主存与存储设备间交换页。</li><li>交换：源自UNIX，指将整个进程从主存转移到交换设备。Linux 中交换指页面转移到交换设备（迁移交换页）。本书中使用原来的定义，即转移整个进程。</li><li>交换（空间）：存放换页的匿名数据和交换进程的磁盘空间。它可以是存储设备的一块空间，也称为物理交换设备，或者是文件系统文件，称作交换文件。部分工具用交换这个术语特指虚拟内存（这是令人误解和不正确的）。</li></ul><p>文件系统换页由读写位于内存中的映射文件页引发。对于使用文件内存映射（mmap()）的应用程序和使用了页缓存的文件系统，这是正常的行为。这也被称作“好的”换页。如果一个文件系统页在主存中修改过（“脏的”），页面换出要求将该页写回磁盘。相反，如果文件系统页没有修改过（“干净的”），因为磁盘已经存在一份副本，页面换出仅仅释放这些内存以便立即重用。</p><p>匿名换页牵涉进程的私有数据：进程堆和栈。被称为匿名是由于它在操作系统中缺乏有名字的地址（例如，没有文件系统路径）。匿名页面换出要求迁移数据到物理交换设备或者交换文件。Linux 用交换（swapping）来命名这种类型的换页。</p><p>交换是在主存与物理交换设备或者交换文件之间移动整个进程。</p><p>利用更大的字长有可能提升内存性能，具体取决于CPU 架构。当一个数据类型在更长的字长下有未使用的位时，可能会浪费一小部分内存。</p><p>内存硬件包括主存、总线、CPU 缓存和MMU（内存管理单元）。连接到每个CPU 的内存组被称为内存节点，或者仅仅是节点。基于处理器提供的信息，操作系统能了解内存节点的拓扑。这使得它可以根据内存本地性分配和调度线程，尽可能倾向于本地内存以提高性能。</p><p>空闲链表是一个未使用的页列表（也称为空闲内存），它能立刻用于分配。通常的实现是多个空闲页链表，每个本地组（NUMA）一个。在节点的空闲链表内分配能提高内存的本地性和性能。</p><p>页扫描仅按需启动。通常平衡的系统不会经常做页扫描并且仅以短期爆发方式扫描。如前所述，基于Solaris 的系统在页扫描前会利用其他机制释放内存，因此若页扫描多于几秒通常是内存压力问题的预兆。</p><p>内存调优方法：</p><ul><li>工具法<ul><li>页扫描：寻找连续的页扫描（超过10 秒），它是内存压力的预兆。Linux 中，可以使用sar -B 并检查pgscan 列。在Solaris 中，可以使用vmstat (1M)并检查sr 列。</li><li>换页：换页是系统内存低的进一步征兆。Linux 中，可以使用vmstat (8)并检查si和so 列（这里，交换指匿名换页）。Solaris 中，vmstat -p 按类型显示换页，检查匿名换页。</li><li>vmstat：每秒运行vmstat 检查free 列的可用内存。</li><li>OOM 终结者：仅对Linux 有效，这些事件可以在系统日志&#x2F;var&#x2F;log&#x2F;messages，或者从dmesg(1)中找到。搜索“Out of memory”。</li><li>交换：仅对Solaris 有效，运行vmstat 并检查w 列，它显示交换出的线程，这往往事后才能注意到。要查看即时的交换，用vmstat -S 并检查si 和so。</li><li>top&#x2F;prstat：查看哪些进程和用户是（常驻）物理内存和虚拟内存的最大使用者（列名参考Man 手册，不同版本有所变化）。这些工具也会总结内存使用率。</li><li>dtrace&#x2F;stap&#x2F;perf：内存分配的栈跟踪，确认内存使用的原因。</li></ul></li><li>USE 方法：要确认物理使用率，需要了解可用内存（free）大小。不同的工具不一定考虑了未被引用的文件系统缓存页或者非活动页，因此它们的报告可能会不同。<ul><li>使用特征归纳：对于内存，这包括了要求发现内存用于何处以及使用了多少，如下所示。</li><li>系统范围的物理和虚拟内存使用率。</li><li>饱和度：换页、交换、OOM 终结者。</li><li>内核和文件系统缓存使用情况。</li><li>每个进程的物理和虚拟内存使用情况。</li><li>是否存在内存资源控制。</li></ul></li><li>周期分析：内存总线负载通过检查CPU 性能计数器（CPC）测定，它能被设置用来计算内存停滞周期。</li><li>性能监测：关键的内存指标如下。使用率：使用百分比，由可用内存推断。饱和度：换页、交换、OOM 终结者。</li><li>泄露检测：内存泄漏：一种类型的软件bug，忘记分配过的内存而没有释放。通过修改软件代码，或应用补丁及进行升级（进而修改代码）能修复。内存增长：软件在正常地消耗内存，远高于系统允许的速率。通过修改软件配置，或者由软件开发人员修改软件内存的消耗方式来进行修复。</li><li>静态性能调优：调整各类配置</li><li>资源控制：操作系统可能向进程或进程组内存分配提供细粒度控制。这些控制可能会包括使用主存和虚拟内存的固定极限。</li><li>微基准测试：微基准测试可用于确定主存的速度和特征，例如CPU 缓存和缓存线长度。它有助于分析系统间的不同。由于应用程序和负载的不同，内存访问速度可能比CPU 时钟速度对性能影响更大。</li></ul><p>最重要的内存调优是保证应用程序保留在主存中，并且避免换页和交换经常发生。</p><h2 id="文件系统"><a href="#文件系统" class="headerlink" title="文件系统"></a>文件系统</h2><ul><li>文件系统：一种把数据组织成文件和目录的存储方式，提供了基于文件的存取接口，并通过文件权限控制访问。另外，还包括一些表示设备、套接字和管道的特殊文件类型，以及包含文件访问时间戳的元数据。</li><li>文件系统缓存：主存（通常是DRAM）的一块区域，用来缓存文件系统的内容，可能包含各种数据和元数据。</li><li>操作：文件系统的操作是对文件系统的请求，包括read()、write()、open()、close()、stat()、 mkdir()以及其他操作。</li><li>I&#x2F;O：输入&#x2F;输出。文件系统I&#x2F;O 有好几种定义，这里仅仅指直接读写（执行I&#x2F;O）的操作，包括read()、write()、stat()（读的统计信息）和mkdir()（创建一个新的目录项）。I&#x2F;O 不包括open()和close()。</li><li>逻辑I&#x2F;O：由应用程序发给文件系统的I&#x2F;O。</li><li>物理I&#x2F;O：由文件系统直接发给磁盘的I&#x2F;O（或者通过裸I&#x2F;O）。</li><li>吞吐量：当前应用程序和文件系统之间的数据传输率，单位是B&#x2F;s。</li><li>inode：一个索引节点（inode）是一种含有文件系统对象元数据的数据结构，其中有访问权限、时间戳以及数据指针。</li><li>VFS：虚拟文件系统，一个为了抽象与支持不同文件系统类型的内核接口。在Solaris上，一个VFS inode 被称为一个vnode。</li><li>卷管理器：灵活管理物理存储设备的软件，在设备上创建虚拟卷供操作系统使用。</li></ul><p>文件系统延时是文件系统性能一项主要的指标，指的是一个文件系统逻辑请求从开始到结束的时间。它包括了消耗在文件系统、内核磁盘I&#x2F;O 子系统以及等待磁盘设备——物理I&#x2F;O 的时间。应用程序的线程通常在请求时阻塞，等待文件系统请求的结束。这种情况下，文件系统的延时与应用程序的性能有着直接和成正比的关系。</p><p>文件系统用缓存（caching）提高读性能，而用缓冲（buffering）（在缓存中）提高写性能。</p><p>为了平衡系统对于速度和可靠性的需求，文件系统默认采用写回缓存策略，但同时也提供一个同步写的选项绕过这个机制，把数据直接写在磁盘上。</p><p>裸I&#x2F;O：绕过了整个文件系统，直接发给磁盘地址。有些应用程序使用了裸I&#x2F;O（特别是数据库），因为它们能比文件系统更好地缓存自己的数据。其缺点在于难以管理，即不能使用常用文件系统工具执行备份&#x2F;恢复和监控。</p><p>直接I&#x2F;O：允许应用程序绕过缓存使用文件系统。直接I&#x2F;O 可用于备份文件系统的应用程序，防止只读一次的数据污染文件系统缓存。裸I&#x2F;O和直接I&#x2F;O 还可以用于那些在进程堆里自建缓存的应用程序，避免了双重缓存的问题。</p><p>内存映射通过系统调用mmap()创建，通过munmap()销毁。如果问题在于磁盘设备的高I&#x2F;O 延时，用mmap()消除小小的系统调用开销，是无济于事的，这时，磁盘设备的高I&#x2F;O 问题并没有解决并仍在拖累性能。</p><p>如果说数据对应了文件和目录的内容，那元数据则对应了有关它们的信息。元数据可能是通过文件系统接口（POSIX）读出的信息，也可能是文件系统实现磁盘布局所需的信息。前者被称为逻辑元数据，后者被称为物理元数据。</p><p>下面这个列举步骤的例子描述了应用程序写入一个字节的背后发生了什么：<br>1.一个应用程序对一个已有的文件发起了一个一字节的写操作。<br>2.文件系统定位了这个地址对应的128KB 数据块，发现它未在缓存中（尽管指向数据块的元数据被缓存了）。<br>3.文件系统请求从磁盘载入那个记录块。<br>4.磁盘设备层把128KB 字节的读请求分拆成适配设备的较小读请求。<br>5.磁盘执行了多次较小的读请求，总共128KB。<br>6.文件系统把要写入的那个字节替换成新的数据。<br>7.一段时间后，文件系统请求把128KB 的“脏”记录写回到磁盘。<br>8.磁盘写入128KB 的记录（如果有需要还要分拆请求）。<br>9.文件系统写入新的元数据，比如引用（为了写时复制）或者访问时间。<br>10.磁盘执行更多的写入操作。<br>这样，即便应用程序只执行了一个字节的写操作，磁盘也承担了多次读（共128KB）和更多的写（超过128KB）操作。</p><p>一些操作的性能数据：<br><img data-src="/posts/6429ef26/store_benchmark.png" alt="存储基准测试结果图表"></p><p>页缓存缓存了虚拟内存的页面，包括文件系统的页面，提升了文件和目录的性能。页缓存大小是动态的，它会不断增长消耗可用的内存，并在应用程序需要的时候释放</p><p>基于块的文件系统把数据存储在固定大小的块里，被存储在元数据块里的指针所引用。对于大文件，这种方法需要大量的块指针和元数据块，而且数据块的摆放可能会变得零零碎碎，造成随机I&#x2F;O。有些基于块的文件系统尝试通过把块连续摆放，来解决这个问题。另一个办法是使用变长的块大小，随着文件的增长采用更大的数据块，也能减小元数据的开销。</p><h2 id="磁盘"><a href="#磁盘" class="headerlink" title="磁盘"></a>磁盘</h2><ul><li>虚拟磁盘：存储设备的模拟。在系统看来，这是一块物理磁盘，但是，它可能由多块磁盘组成。</li><li>传输总线：用来通信的物理总线，包括数据传输（I&#x2F;O）以及其他磁盘命令。</li><li>扇区：磁盘上的一个存储块，通常是512B 大小。</li><li>I&#x2F;O：对于磁盘，严格地说仅仅包括读和写，而不包括其他磁盘命令。I&#x2F;O 至少由方向（读或写）、磁盘地址（位置）和大小（字节数）组成。</li><li>磁盘命令：除了读写之外，磁盘还会被指派执行其他非数据传输的命令（例如缓存写回）。</li><li>吞吐量：对于磁盘而言，吞吐量通常指当前数据传输速率，单位是B&#x2F;s。</li><li>带宽：这是存储传输或者控制器能够达到的最大数据传输速率。</li><li>I&#x2F;O 延时：一个I&#x2F;O 操作的执行时间，这个词在操作系统领域广泛使用，早已超出了设备层。注意在网络领域，这个词有其他的意思，指发起一个I&#x2F;O 的延时，后面还跟着数据传输时间。</li><li>延时离群点：非同寻常的高延时磁盘I&#x2F;O。</li></ul><p>I&#x2F;O 等待是针对单个CPU 的性能指标，表示当CPU 分发队列（在睡眠态）里有线程被阻塞在磁盘I&#x2F;O 上时消耗的空闲时间。</p><p>磁盘检查工具：</p><ul><li>iostat：使用扩展模式寻找繁忙磁盘（超过60%使用率），较高的平均服务时间（超过大概10ms），以及高IOPS（可能）。</li><li>iotop：发现哪个进程引发了磁盘I&#x2F;O。</li><li>dtrace&#x2F;stap&#x2F;perf：包含了iosnoop(1)工具，仔细检查磁盘I&#x2F;O 延时，以发现延时离群点（超过大概100ms）。</li><li>磁盘控制器专用工具（厂商提供）。</li></ul><h2 id="网络"><a href="#网络" class="headerlink" title="网络"></a>网络</h2><p>包的长度通常受限于网络接口的最大传输单元（MTU）长度，许多以太网中它设置为1500B。以太网支持接近9000B 的特大包（帧），也称为巨型帧。</p><p>较大的缓冲可以通过在阻塞和等待确认前持续传输数据缓解高往返延时带来的影响。</p><p>全双工模式允许双向同时传输，利用分离的通道传输和接收能利用全部带宽。半双工模式仅允许单向的传输。</p><p>现代内核中网络栈是多线程的，并且传入的包能被多个CPU 处理。传入的包与CPU 的映射可用多个方法完成：可能基于源IP 地址哈希以平均分布负载，或者基于最近处理的CPU 以有效利用CPU 缓存热度以及内存本地性。</p><p>对于网络通信来说，应用工具法可以检查如下内容。</p><ul><li>netstat -s：查找高流量的重新传输和乱序数据包。哪些是“高”重新传输率依客户机而不同，面向互联网的系统因具有不稳定的远程客户会比仅拥有同数据中心客户的内部系统具有更高的重新传输率。</li><li>netstat -i：检查接口的错误计数器（特定的计数器依OS 版本而不同）。</li><li>ifconfig（仅限Linux 版本）：检查“错误”、“丢弃”、“超限”。</li><li>吞吐量：检查传输和接收的字节率-在Linux 中用ip(8)，在Solaris 中用nicstat(1)或者dladm(1M)。高吞吐量可能会因为到达协商的线速率而受到限制，它也可能导致系统中网络用户的竞争及延时。</li><li>tcpdump&#x2F;snoop：尽管需要大量的CPU 开销，短期使用可能就足以发现谁在使用网络并且定位可以消除的不必要的操作。</li><li>dtrace&#x2F;stap&#x2F;perf：用来检查包括内核状态在内的应用程序与线路间选中的数据。</li></ul><h2 id="云计算"><a href="#云计算" class="headerlink" title="云计算"></a>云计算</h2><p>OS 虚拟化将操作系统划分为形同分隔的访客服务器且能独立于宿主管理和重启的实例。相比硬件虚拟化技术，一个关键区别是仅有一个内核在运行。它有如下优势：</p><ul><li>由于客户应用程序能直接向宿主内核发起系统调用，客户应用程序I&#x2F;O 仅有一些甚至没有性能开销。</li><li>分配给客户的内存能完全用于客户应用程序——而没有来自OS 虚拟层或者其他访客内核的额外内核负担。</li><li>只有一个统一的文件系统缓存——没有宿主和访客的双重缓存。</li><li>所有的客户进程都可由宿主观测到，这样可以调试牵涉到它们之间的相互作用（包括资源竞争）的性能问题。</li><li>CPU 是真实的CPU，自适应互斥锁的假设仍然有效。<br>而劣势为：</li><li>任何kernel panic 会影响到所有客户。</li><li>客户不能运行不同的内核版本。</li></ul><p>硬件虚拟化创建系统虚拟机实例，它们能运行包括自己内核在内的整个操作系统。硬件虚拟化包括如下类型。</p><ul><li>全虚拟化——二进制翻译：提供一个由虚拟硬件组件组成的完整虚拟系统。在此之上可以安装未修改的操作系统。它由VMware 于1998年在x86 平台上始创，混合利用了直接处理器执行和按需翻译二进制指令。相对于服务器整合带来的节省，它的性能开销通常是可接受的。</li><li>全虚拟化——硬件支持：提供一个由虚拟组件组成的完整虚拟系统。在此之上可以安装未修改的操作系统。它利用处理器的支持以更有效地执行虚拟机，特别是2005-2006年引入的AMD-V 和Intel VT-x 扩展。</li><li>半虚拟化：提供了包括使得访客机操作系统更有效地使用宿主资源的接口（通过hypercalls）的一个虚拟系统，而不需要包括所有组件的完整虚拟化。例如，设置计时器通常需要多个必须由虚拟层模拟的特权指令。对于一个半虚拟化的客户机，这能被简化为单个超级调用。半虚拟化可能会利用访客机的半虚拟网络设备驱动将数据包更高效地传递给宿主的物理网络接口。尽管性能有所提升，但它依赖于客户OS 对半虚拟化的支持（Windows 一直以来不提供）。</li></ul><p>另一类硬件虚拟化，混合虚拟化，同时利用硬件辅助的虚拟化和更高效的半虚拟化调用，以期提供最佳性能。最常见的半虚拟化的目标是如网络板卡和存储控制器等的虚拟设备。</p><p><img data-src="/posts/6429ef26/virtual_type.png" alt="虚拟化类型分类示意图"></p><p>图中展示了两种类型的虚拟机管理程序：</p><ul><li>1 型　直接运行于处理器上，并且不属于其他宿主的内核或用户软件。虚拟机管理程序的管理可以由一个特权访客机实现（这里描绘为系统中的第一个：0 号）。它能创建和启动新的访客机。1 型也称为本地虚拟机管理程序或者裸机虚拟机管理程序。这类虚拟机管理程序包括了自带的用于客户VM 的CPU 调度器。</li><li>2 型　由宿主OS 内核运行并且可能由内核级模块和用户级进程组成。宿主OS 有管理虚拟机管理程序和启动新访客机的特权。这类虚拟机管理程序由宿主内核调度器调度。</li></ul><p>虚拟化技术的比较：</p><p><img data-src="/posts/6429ef26/virtual_comparsion.png" alt="不同虚拟化技术对比表"></p>]]></content>
    
    
    <summary type="html">阅读《Systems Performance》时整理的性能分析方法、指标与工具要点</summary>
    
    
    
    <category term="技术书籍" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E4%B9%A6%E7%B1%8D/"/>
    
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="性能" scheme="https://liang7878.github.io/tags/%E6%80%A7%E8%83%BD/"/>
    
    <category term="杂谈" scheme="https://liang7878.github.io/tags/%E6%9D%82%E8%B0%88/"/>
    
  </entry>
  
  <entry>
    <title>How To Do Great Work</title>
    <link href="https://liang7878.github.io/posts/7b67d6ae.html"/>
    <id>https://liang7878.github.io/posts/7b67d6ae.html</id>
    <published>2023-08-20T17:33:02.000Z</published>
    <updated>2026-06-29T17:07:30.754Z</updated>
    
    <content type="html"><![CDATA[<h1 id="如何取得杰出成就？"><a href="#如何取得杰出成就？" class="headerlink" title="如何取得杰出成就？"></a>如何取得杰出成就？</h1><blockquote><p><strong>速记</strong></p><ul><li>找到“天赋 × 兴趣 × 发展空间”的交集，并持续把自己推向领域前沿。</li><li>让自己成为运气的显眼目标：多阅读、结识人、尝试项目，及时切换到更令人兴奋的方向。</li><li>重点管理注意力和长期投入，用幂律思维集中资源，同时与优秀同伴合作、持续复盘。</li></ul></blockquote><p>这是一篇来自Paul Graham最新文章。保罗·格雷厄姆（Paul Graham），生于1964年11月13日，是一位英国计算机科学家、风险投资家和散文家。他以在Lisp编程语言领域的工作而闻名，曾共同创办了Viaweb（最终成为Yahoo! Store），并共同创办了Y Combinator种子资本公司。他是一些编程书籍的作者，例如《On Lisp》（1993年）、《ANSI Common Lisp》（1995年）和《Hackers &amp; Painters》（2004年）。</p><p>四个步骤：</p><ol><li><p>选择一个领域；</p></li><li><p>学习足够多的知识以到达领域前沿；</p></li><li><p>注意到领域前沿被人忽略的机会；</p></li><li><p>探索其中有前景的方向。</p></li></ol><span id="more"></span><h2 id="如何选择自己的事业？"><a href="#如何选择自己的事业？" class="headerlink" title="如何选择自己的事业？"></a>如何选择自己的事业？</h2><p>我们所选择的工作需要具有三个特质：</p><ol><li><p>我们对这件事有天赋；</p></li><li><p>我们对这件事有深深的兴趣；</p></li><li><p>这件事拥有取得杰出成就的空间。</p></li></ol><p>我们可以去做任何让自己感到兴奋的事，我们对什么东西有着异于常人的好奇心？即使大多数人觉得无聊，我们依然好奇。这就是我们要寻找的事。</p><p>学习足够多的知识，让我们能够到达此领域的知识前沿，在知识前沿寻找被人忽略的机会。许多伟大的发现来自于对大家都视为理所当然的事提出问题。</p><h2 id="如何让自己更有运气？"><a href="#如何让自己更有运气？" class="headerlink" title="如何让自己更有运气？"></a>如何让自己更有运气？</h2><p>当我们阅读取得杰出成就的人的传记时，会发现运气的参与程度是那么高：他们通过偶然的会面，或者读到他们碰巧拿起的一本书，就发现了要做什么。我们需要让自己成为运气的显眼目标，做到这一点的方法是保持好奇 —— 尝试很多事，见很多人，读很多书，问很多问题。如果在做一件事的过程中，我们发现了另一件更令人兴奋的事，不要害怕切换。</p><h2 id="如何看待时间的投入？"><a href="#如何看待时间的投入？" class="headerlink" title="如何看待时间的投入？"></a>如何看待时间的投入？</h2><p>杰出的成就通常需要我们在一个问题上花费大多数人认为超出了合理范围的大量时间。我们不能把这个时间看作是成本，否则它会显得太高，我们必须在工作过程中找到足够的吸引力。</p><h2 id="如何看待独特的个人风格？"><a href="#如何看待独特的个人风格？" class="headerlink" title="如何看待独特的个人风格？"></a>如何看待独特的个人风格？</h2><p>不要试图以一种独特的风格工作，只需要尽力做好自己的工作，风格是在不刻意为之的情况下以独特的方式做事，刻意为之则是矫饰。</p><h2 id="批评很容易，取得伟大成就不容易"><a href="#批评很容易，取得伟大成就不容易" class="headerlink" title="批评很容易，取得伟大成就不容易"></a>批评很容易，取得伟大成就不容易</h2><p>成为那个把事情做出来的人，而不是坐在背后提供复杂的批评的人。有些人天生就是真诚的，有些人需要有意识的努力，任何一种都可以。如果没有真诚，就不可能做出伟大的工作，而且即使我们是真诚的，也很难做到。</p><h2 id="如何管理自己的注意力"><a href="#如何管理自己的注意力" class="headerlink" title="如何管理自己的注意力?"></a>如何管理自己的注意力?</h2><p>不要把注意力平均分配到许多领域上，否则会分散得太薄。我们应该根据幂律分布的规则来分配它 —— 对少数几个主题「专业地好奇」，对更多的主题「随便地好奇」。</p><h2 id="如何看待打破规则？"><a href="#如何看待打破规则？" class="headerlink" title="如何看待打破规则？"></a>如何看待打破规则？</h2><p>在真正重要的问题中，只有打破规则的人才是真正严谨的。杰出的成就往往源于已经存在的东西，但问题发现者深入探索并展现了其更多潜力。</p><h2 id="如何看待尝试？"><a href="#如何看待尝试？" class="headerlink" title="如何看待尝试？"></a>如何看待尝试？</h2><p>我们尝试的事情越多，发现新事物的机会就越大。然而，要知道，尝试很多事也将意味着尝试很多无用的事，我们不能只有很多好主意而没有同样多的坏主意。</p><h2 id="如何看待比较难的问题？"><a href="#如何看待比较难的问题？" class="headerlink" title="如何看待比较难的问题？"></a>如何看待比较难的问题？</h2><p>即使一个项目失败了，也可以是有价值的。因为在处理它的过程中，我们会穿过很少有人见过的领域，遇到很少有人提出的问题。试图做一些稍微难一点的事情，在这个过程中遇到的问题可能是全新问题的最好来源。</p><h2 id="年轻-vs-经验？"><a href="#年轻-vs-经验？" class="headerlink" title="年轻 vs 经验？"></a>年轻 vs 经验？</h2><p>年轻人的优势是精力、时间、乐观和自由，有经验的优势是知识、效率、金钱和权力 —— 努力工作，我们可以在年轻时获取一些后者，并在经验渐长时保持一些前者。</p><h2 id="如何向周围的人学习？"><a href="#如何向周围的人学习？" class="headerlink" title="如何向周围的人学习？"></a>如何向周围的人学习？</h2><p>如果我们所在领域的许多最好的人都集中在一个地方，那去那里待一段时间通常是个好主意。寻找最好的同事。很多项目是无法独自完成的，即使我们正在从事可以独自完成的项目，也好有其他人来鼓励自己，和我们交流想法。同事不仅会影响我们的工作，他们也会影响我们本身。所以，与我们想成为的人一起工作，因为我们将会变得像他们。足够好的同事会提供令人惊讶的见解，他们能看到并做我们不能做的事。</p><h2 id="好奇心是杰出成就的根源"><a href="#好奇心是杰出成就的根源" class="headerlink" title="好奇心是杰出成就的根源"></a>好奇心是杰出成就的根源</h2><p>好奇心是做伟大工作的所有四个步骤的关键：它会为我们选择领域，带我们到达领域边界，让我们注意到其中的空白，并驱使我们去探索它们 —— 整个过程都在与好奇心共舞。</p>]]></content>
    
    
    <summary type="html">这是一篇来自Paul Graham最新文章。保罗·格雷厄姆（Paul Graham），生于1964年11月13日，是一位英国计算机科学家、风险投资家和散文家。他以在Lisp编程语言领域的工作而闻名，曾共同创办了Viaweb（最终成为Yahoo! Store），并共同创办了Y…</summary>
    
    
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    
    <category term="阅读" scheme="https://liang7878.github.io/tags/%E9%98%85%E8%AF%BB/"/>
    
    <category term="杂谈" scheme="https://liang7878.github.io/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="生活" scheme="https://liang7878.github.io/tags/%E7%94%9F%E6%B4%BB/"/>
    
  </entry>
  
  <entry>
    <title>读《C++并发编程实战 -- C++ Concurrency In Action》</title>
    <link href="https://liang7878.github.io/posts/836781f0.html"/>
    <id>https://liang7878.github.io/posts/836781f0.html</id>
    <published>2023-08-15T12:24:43.000Z</published>
    <updated>2026-06-29T17:07:30.727Z</updated>
    
    <content type="html"><![CDATA[<h2 id="C-并发编程基础"><a href="#C-并发编程基础" class="headerlink" title="C++ 并发编程基础"></a>C++ 并发编程基础</h2><h3 id="什么是并发"><a href="#什么是并发" class="headerlink" title="什么是并发"></a>什么是并发</h3><p>并发（concurrency）：把任务在不同的时间点交给处理器进行处理。在同一时间点，任务并不会同时运行。</p><p>并行（parallelism）：把每一个任务分配给每一个处理器独立完成。在同一时间点，任务一定是同时运行。</p><p>并发的方式包括多进程并发和多线程并发，多进程并发通过进程间通信（信号、套接字、文件、管道等）来相互传递信息；由于同一进程内的所有线程都共用相同的地址空间，所以多进程并发通过共享内存来同步数据。</p><span id="more"></span><p>并发技术可以：</p><ol><li>分离关注点（separation of concerns），使得不同的线程关注不同的任务；</li><li>提升性能。任务并行可以采取两种方式，一种是将单一任务分成多个部分，各自并行运作，从而节省总运行耗时；第二种是利用并行资源解决规模更大的问题。</li></ol><p>什么时候避免并发？</p><ol><li>并发会增加额外的复杂度，增加开发时间和维护成本</li><li>多线程的性能增幅可能不如预期，因为线程的启动会有额外的时间开销</li><li>线程是一种有限的资源，过多的线程会消耗系统资源，从而导致系统整体变慢</li><li>运行的线程越多，操作系统的上下文切换就越频繁，上下文切换会减少本该用于实质工作的时间</li></ol><p>我们先尝试一个简单的多线程版本的 <code>Hello World</code></p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;thread&gt;</span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">hello</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    cout &lt;&lt; <span class="string">&quot;Hello Concurrent World\n&quot;</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span></span>&#123;</span><br><span class="line">    <span class="function">thread <span class="title">t</span><span class="params">(hello)</span></span>;</span><br><span class="line">    t.<span class="built_in">join</span>();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="线程管控"><a href="#线程管控" class="headerlink" title="线程管控"></a>线程管控</h2><p>线程的管控可以通过 std::thread 对象来实现。</p><p>悬空引用（Dangling Reference）是指一个引用在指向有效数据之后，被引用的数据被销毁或释放，从而引用变成了无效的情况。这种情况可能导致程序运行时的未定义行为，因为引用指向的数据已经不存在，但程序仍试图通过该引用访问这个不存在的数据。如果新线程上的函数持有指针或引用，指向主线程的局部变量；但主线程所运行的函数退出后，新线程却还没结束，这时就会访问悬空引用的情况。</p><p>上述情况的处理办法是令线程函数完全自含（self-contained），将数据复制到新线程内部，而不是共享数据。另一种方法是汇合新线程，确保主线程的函数退出前，新线程执行完毕。</p><p>等待线程完成可调用成员函数<code>join()</code>来实现。在<code>std::thread</code>对象销毁前，我们需确保已经调用<code>join()</code>或<code>detach()</code>.假使打算等待线程结束，则需小心地选择执行代码的位置来调用join()。原因是，如果线程启动以后主线程有异常抛出，而join()尚未执行，则该join()调用会被略过。</p><h2 id="在线程间共享数据"><a href="#在线程间共享数据" class="headerlink" title="在线程间共享数据"></a>在线程间共享数据</h2><ul><li><strong>互斥锁（<code>std::mutex</code>）和守卫（<code>std::lock_guard</code>）</strong> 是最基础的保护手段。建议用 RAII 封装锁的获取与释放，避免异常路径遗忘 <code>unlock()</code>。</li><li><strong>读写场景</strong> 可选 <code>std::shared_mutex</code>，提升读多写少情况下的吞吐。</li><li>共享数据最好封装在类中，通过接口隐藏同步细节，并在接口处标记线程安全语义（例如 <code>const</code> 方法是否需要锁）。</li></ul><h2 id="并发操作的同步"><a href="#并发操作的同步" class="headerlink" title="并发操作的同步"></a>并发操作的同步</h2><ul><li><strong>条件变量</strong> (<code>std::condition_variable</code>) 适合“等待事件”场景，注意循环等待并搭配谓词，避免虚假唤醒。</li><li><strong>期物（<code>std::future</code>&#x2F;<code>std::promise</code>）</strong> 让任务之间通过值传递同步结果，与 <code>std::async</code> 结合时可以自动派发线程。</li><li><strong>闩锁与栅栏</strong> (<code>std::latch</code>&#x2F;<code>std::barrier</code>) 在 C++20 提供了更现代的阶段同步手段，写并行算法时很实用。</li></ul><h2 id="C-内存模型和原子操作"><a href="#C-内存模型和原子操作" class="headerlink" title="C++ 内存模型和原子操作"></a>C++ 内存模型和原子操作</h2><ul><li><code>std::atomic&lt;T&gt;</code> 提供无锁语义，默认 <code>memory_order_seq_cst</code> 最保守；可根据需求选择 <code>acquire/release</code> 等更轻量的序。</li><li>多线程可见性靠 happens-before 保证：锁解锁、条件变量通知、原子操作等都会建立 happens-before 关系。</li><li>自旋锁、无锁结构都必须正确处理 ABA、缓存一致性等问题，调试难度远超互斥锁方案。</li></ul><h2 id="设计基于锁的并发数据结构"><a href="#设计基于锁的并发数据结构" class="headerlink" title="设计基于锁的并发数据结构"></a>设计基于锁的并发数据结构</h2><ul><li>拆分粗粒度锁是提速关键：链表可以按桶分段上锁，哈希表可对每个 bucket 配独立互斥量。</li><li>避免死锁常见策略：固定加锁顺序、使用 <code>std::lock</code> 实现一次性加多个锁、在失败时回退并重试。</li><li>封装同步原语，对外暴露 STL 风格接口，减少误用概率。</li></ul><h2 id="设计无锁数据结构"><a href="#设计无锁数据结构" class="headerlink" title="设计无锁数据结构"></a>设计无锁数据结构</h2><ul><li>基础原语是 CAS (<code>compare_exchange_weak/strong</code>)，配合 hazard pointer 或 epoch 回收解决内存释放问题。</li><li>Michael-Scott 队列、Treiber 栈是经典无锁结构，实测性能需考虑 CPU 拓扑与 false sharing。</li><li>无锁不等于性能最优：在低并发或抢占严重的场景，粗粒度锁反而更稳定。</li></ul><h2 id="设计并发代码"><a href="#设计并发代码" class="headerlink" title="设计并发代码"></a>设计并发代码</h2><ul><li>先做任务划分：区分数据并行（同一任务分片）与任务并行（不同任务流水线）。</li><li>提前识别共享状态并决定同步策略，尽量让任务逻辑无共享或最小共享。</li><li>保持异常安全：线程函数内部捕获异常并回传给主线程，避免“silent crash”。</li></ul><h2 id="高级线程管理"><a href="#高级线程管理" class="headerlink" title="高级线程管理"></a>高级线程管理</h2><ul><li>线程池&#x2F;工作队列可以显著降低频繁创建线程的开销。C++17 可使用 <code>std::async</code>&#x2F;<code>std::packaged_task</code> 作为轻量封装。</li><li>定时器、后台维护线程要有退出机制（stop flag + condition variable），防止程序无法优雅结束。</li><li>对系统线程资源进行限流（<code>std::hardware_concurrency</code> 只是建议值），结合绑定 CPU、调整优先级进行调优。</li></ul><h2 id="并行算法函数"><a href="#并行算法函数" class="headerlink" title="并行算法函数"></a>并行算法函数</h2><ul><li>C++17 引入 <code>std::execution::par</code> &#x2F; <code>par_unseq</code> 让大部分 STL 算法具有并行后端，实现数据并行化。</li><li>注意算法是否满足无副作用、迭代器是否随机访问，否则并行执行策略可能抛异常或回退到串行。</li><li>对大量数据的转换&#x2F;归约，可优先尝试平铺在 <code>std::transform_reduce</code> 等并行算法上。</li></ul><h2 id="多线程应用的测试和除错"><a href="#多线程应用的测试和除错" class="headerlink" title="多线程应用的测试和除错"></a>多线程应用的测试和除错</h2><ul><li>单元测试要覆盖竞态条件：可以通过注入延迟、stress test 或工具（ThreadSanitizer、Helgrind）发现数据竞争。</li><li>记录线程、事件时间线（logging + trace）有助于复现场景，必要时写自定义探针。</li><li>把并发问题转化为确定性重放：利用固定随机种子、任务排序或伪调度器（virtual time）简化调试。</li></ul>]]></content>
    
    
    <summary type="html">并发（concurrency）：把任务在不同的时间点交给处理器进行处理。在同一时间点，任务并不会同时运行。</summary>
    
    
    
    <category term="技术书籍" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E4%B9%A6%E7%B1%8D/"/>
    
    
    <category term="C++" scheme="https://liang7878.github.io/tags/C/"/>
    
    <category term="CPP" scheme="https://liang7878.github.io/tags/CPP/"/>
    
    <category term="编程进阶" scheme="https://liang7878.github.io/tags/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    <category term="并发编程" scheme="https://liang7878.github.io/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 —— P1909 [NOIP2016 普及组] 买铅笔</title>
    <link href="https://liang7878.github.io/posts/aa4eb107.html"/>
    <id>https://liang7878.github.io/posts/aa4eb107.html</id>
    <published>2023-08-14T09:43:21.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<h1 id="NOIP2016-普及组-买铅笔"><a href="#NOIP2016-普及组-买铅笔" class="headerlink" title="[NOIP2016 普及组] 买铅笔"></a>[NOIP2016 普及组] 买铅笔</h1><h2 id="题目背景"><a href="#题目背景" class="headerlink" title="题目背景"></a>题目背景</h2><p>NOIP2016 普及组 T1</p><h2 id="题目描述"><a href="#题目描述" class="headerlink" title="题目描述"></a>题目描述</h2><p>P 老师需要去商店买 $n$ 支铅笔作为小朋友们参加 NOIP 的礼物。她发现商店一共有 $3$ 种包装的铅笔，不同包装内的铅笔数量有可能不同，价格也有可能不同。为了公平起 见，P 老师决定只买同一种包装的铅笔。</p><p>商店不允许将铅笔的包装拆开，因此 P 老师可能需要购买超过 $n$ 支铅笔才够给小朋友们发礼物。</p><p>现在 P 老师想知道，在商店每种包装的数量都足够的情况下，要买够至少 $n$ 支铅笔最少需要花费多少钱。</p><span id="more"></span><h2 id="输入格式"><a href="#输入格式" class="headerlink" title="输入格式"></a>输入格式</h2><p>第一行包含一个正整数 $n$，表示需要的铅笔数量。</p><p>接下来三行，每行用 $2$ 个正整数描述一种包装的铅笔：其中第 $1$ 个整数表示这种包装内铅笔的数量，第 $2$ 个整数表示这种包装的价格。</p><p>保证所有的 $7$ 个数都是不超过 $10000$ 的正整数。</p><h2 id="输出格式"><a href="#输出格式" class="headerlink" title="输出格式"></a>输出格式</h2><p>$1$ 个整数，表示 P 老师最少需要花费的钱。</p><h2 id="样例-1"><a href="#样例-1" class="headerlink" title="样例 #1"></a>样例 #1</h2><h3 id="样例输入-1"><a href="#样例输入-1" class="headerlink" title="样例输入 #1"></a>样例输入 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">57</span><br><span class="line">2 2</span><br><span class="line">50 30</span><br><span class="line">30 27</span><br></pre></td></tr></table></figure><h3 id="样例输出-1"><a href="#样例输出-1" class="headerlink" title="样例输出 #1"></a>样例输出 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">54</span><br></pre></td></tr></table></figure><h2 id="样例-2"><a href="#样例-2" class="headerlink" title="样例 #2"></a>样例 #2</h2><h3 id="样例输入-2"><a href="#样例输入-2" class="headerlink" title="样例输入 #2"></a>样例输入 #2</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">9998</span><br><span class="line">128 233</span><br><span class="line">128 2333</span><br><span class="line">128 666</span><br></pre></td></tr></table></figure><h3 id="样例输出-2"><a href="#样例输出-2" class="headerlink" title="样例输出 #2"></a>样例输出 #2</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">18407</span><br></pre></td></tr></table></figure><h2 id="样例-3"><a href="#样例-3" class="headerlink" title="样例 #3"></a>样例 #3</h2><h3 id="样例输入-3"><a href="#样例输入-3" class="headerlink" title="样例输入 #3"></a>样例输入 #3</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">9999</span><br><span class="line">101 1111</span><br><span class="line">1 9999</span><br><span class="line">1111 9999</span><br></pre></td></tr></table></figure><h3 id="样例输出-3"><a href="#样例输出-3" class="headerlink" title="样例输出 #3"></a>样例输出 #3</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">89991</span><br></pre></td></tr></table></figure><h2 id="提示"><a href="#提示" class="headerlink" title="提示"></a>提示</h2><p>铅笔的三种包装分别是：</p><ul><li>$2$ 支装，价格为 $2$;</li><li>$50$ 支装，价格为 $30$;</li><li>$30$ 支装，价格为 $27$。</li></ul><p>P老师需要购买至少 $57$ 支铅笔。</p><p>如果她选择购买第一种包装，那么她需要购买 $29$ 份，共计 $2 \times 29 &#x3D; 58$ 支，需要花费的钱为 $2 \times 29 &#x3D; 58$。</p><p>实际上，P 老师会选择购买第三种包装，这样需要买 $2$ 份。虽然最后买到的铅笔数量更多了，为 $30 \times 2 &#x3D; 60$ 支，但花费却减少为 $27 \times 2 &#x3D; 54$，比第一种少。</p><p>对于第二种包装，虽然每支铅笔的价格是最低的，但要够发必须买 $2$ 份，实际的花费达到了 $30  \times 2 &#x3D; 60$，因此 P 老师也不会选择。</p><p>所以最后输出的答案是 $54$。</p><p>【数据范围】</p><p>保证所有的 $7$ 个数都是不超过 $10000$ 的正整数。</p><p>【子任务】</p><p>子任务会给出部分测试数据的特点。如果你在解决题目中遇到了困难，可以尝试只解决一部分测试数据。</p><p>每个测试点的数据规模及特点如下表：</p><p> <img data-src="https://cdn.luogu.com.cn/upload/pic/3449.png"> </p><p>上表中“整倍数”的意义为：若为 $K$，表示对应数据所需要的铅笔数量 $n$ —定是每种包装铅笔数量的整倍数（这意味着一定可以不用多买铅笔）。</p><p>于 2022 年 12 月 23 日新加 Hack 数据三组。</p><hr><p>这题乍一看是个背包问题，其实他只买一种包装的，因此问题其实就是比较哪种包装能以最少的钱满足最终对铅笔数量的要求。做法很简单，通过倍增快速度找到最优解。下面是代码：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    <span class="type">int</span> ans;</span><br><span class="line">    <span class="type">int</span> n;</span><br><span class="line"></span><br><span class="line">    cin &gt;&gt; n;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">for</span> (<span class="type">int</span> i = <span class="number">0</span>; i &lt; <span class="number">3</span>; i++) &#123;</span><br><span class="line">        <span class="type">int</span> price, count;</span><br><span class="line"></span><br><span class="line">        cin &gt;&gt; count &gt;&gt; price;</span><br><span class="line"></span><br><span class="line">        <span class="type">int</span> total_price = price, total_count = count;</span><br><span class="line">        <span class="keyword">while</span> (total_count &lt; n) &#123;</span><br><span class="line">            total_price &lt;&lt;= <span class="number">1</span>;</span><br><span class="line">            total_count &lt;&lt;= <span class="number">1</span>;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">while</span> (total_count &gt; n) &#123;</span><br><span class="line">            total_price -= price;</span><br><span class="line">            total_count -= count;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">while</span> (total_count &lt; n) &#123;</span><br><span class="line">            total_price += price;</span><br><span class="line">            total_count += count;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (total_price &lt; ans || ans == <span class="number">0</span>) &#123;</span><br><span class="line">            ans = total_price;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    cout &lt;&lt; ans &lt;&lt; endl;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">NOIP2016 普及组 T1</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 —— P1421 小玉买文具</title>
    <link href="https://liang7878.github.io/posts/bf304d7d.html"/>
    <id>https://liang7878.github.io/posts/bf304d7d.html</id>
    <published>2023-08-14T09:01:20.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<h1 id="小玉买文具"><a href="#小玉买文具" class="headerlink" title="小玉买文具"></a>小玉买文具</h1><h2 id="题目描述"><a href="#题目描述" class="headerlink" title="题目描述"></a>题目描述</h2><p>班主任给小玉一个任务，到文具店里买尽量多的签字笔。已知一只签字笔的价格是 $1$ 元 $9$ 角，而班主任给小玉的钱是 $a$ 元 $b$ 角，小玉想知道，她最多能买多少只签字笔呢。</p><h2 id="输入格式"><a href="#输入格式" class="headerlink" title="输入格式"></a>输入格式</h2><p>输入只有一行两个整数，分别表示 $a$ 和 $b$。</p><h2 id="输出格式"><a href="#输出格式" class="headerlink" title="输出格式"></a>输出格式</h2><p>输出一行一个整数，表示小玉最多能买多少只签字笔。</p><h2 id="样例-1"><a href="#样例-1" class="headerlink" title="样例 #1"></a>样例 #1</h2><h3 id="样例输入-1"><a href="#样例输入-1" class="headerlink" title="样例输入 #1"></a>样例输入 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">10 3</span><br></pre></td></tr></table></figure><h3 id="样例输出-1"><a href="#样例输出-1" class="headerlink" title="样例输出 #1"></a>样例输出 #1</h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">5</span><br></pre></td></tr></table></figure><h2 id="提示"><a href="#提示" class="headerlink" title="提示"></a>提示</h2><h4 id="数据规模与约定"><a href="#数据规模与约定" class="headerlink" title="数据规模与约定"></a>数据规模与约定</h4><p>对于全部的测试点，保证 $0 &lt;&#x3D; a &lt;&#x3D; 10^4$，$0 &lt;&#x3D; b &lt;&#x3D; 9$。</p><span id="more"></span><p>这道题本身其实还挺简单的，纯试机题，直接算就可以了。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    <span class="type">int</span> a, b;</span><br><span class="line">    cin &gt;&gt; a &gt;&gt; b;</span><br><span class="line"></span><br><span class="line">    cout &lt;&lt; (a*<span class="number">10</span> + b) / <span class="number">19</span> &lt;&lt; endl;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">班主任给小玉一个任务，到文具店里买尽量多的签字笔。已知一只签字笔的价格是 $1$ 元 $9$ 角，而班主任给小玉的钱是 $a$ 元 $b$ 角，小玉想知道，她最多能买多少只签字笔呢。</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
  <entry>
    <title>CppCon 2017: When a Microsecond Is an Eternity: High Performance Trading Systems in C++</title>
    <link href="https://liang7878.github.io/posts/70cd342.html"/>
    <id>https://liang7878.github.io/posts/70cd342.html</id>
    <published>2023-08-13T01:03:54.000Z</published>
    <updated>2026-06-29T17:07:30.750Z</updated>
    
    <content type="html"><![CDATA[<p>这是今天刷 v2ex 的时候看到的一个 tech talk，讲的是 c++在高频交易下的使用，我以前看过《漫步华尔街》，了解过一点高频交易的原理，这个tech talk 主要讲了一下什么是电子做市，有哪些技术挑战，以及如何利用 c++ 解决这些挑战。</p><span id="more"></span><h2 id="什么是电子做市？"><a href="#什么是电子做市？" class="headerlink" title="什么是电子做市？"></a>什么是电子做市？</h2><p>The elements of good trading are: (1) cutting losses. (2) cutting losses, and (3) cutting losses. If you can follow these three rules, you may have a change.</p><p align="right"> - Ed Seykota</p><p>做市商的主要活动：</p><ol><li>为市场提供最新的价格</li><li>找出获利的机会</li></ol><p>说到底，做市商的存在是为市场提供流动性，并在这流动性中找到获利的机会。</p><p>主要目标：</p><ol><li>稳定做出小的可获利的交易</li><li>避免做出大的不好的交易</li></ol><p>抛开具体价格不谈，一个成功的交易算法需要做到低价买入、高价卖出。在市场竞争中，在任何时候都要更快。</p><p>在交易过程中，资金安全是最重要的，交易市场是一个完全混沌的系统，所有的订单都有可能在很短的时间内转为错误的走向，所以需要自动去检查失败的交易。</p><h2 id="技术挑战"><a href="#技术挑战" class="headerlink" title="技术挑战"></a>技术挑战</h2><ul><li>Hotpath只会在 0.01%的时间中活跃，剩下的时间，系统往往处于空闲状态，或者单纯执行一些管理工作</li><li>操作系统、网络和硬件都专注于吞吐量和公平性。</li><li>抖动是不可接受的，这意味着不好的交易</li></ul><p>整体而言，交易系统的性能最好不能太大的方差，整体平均性能最好大于中位水平，这样的系统就是一个好的交易系统。</p><h2 id="C-在交易系统中扮演的角色"><a href="#C-在交易系统中扮演的角色" class="headerlink" title="C++ 在交易系统中扮演的角色"></a>C++ 在交易系统中扮演的角色</h2><p>C++能够以接近于 0 的开销实现对硬件层的抽象。但是在实际生产中，还有其他的一些因素：编译器、机器架构、第三方库、编译时的标志位等会影响C++在机器指令层面的实际操作。</p><p>这里有一个网站（<a href="https://godbolt.org/%EF%BC%89%EF%BC%8C%E5%8F%AF%E4%BB%A5%E5%B0%86">https://godbolt.org/），可以将</a> C++ 编译成汇编语言，这样我们就能知道这些语句正在做什么。</p><p><img data-src="/posts/70cd342/vector.png" alt="向量容器性能特征图"></p><p>上图是对 <code>vector</code>执行<code>sort</code>操作时是否开启超线程的性能对比。可以看到，开启超线程之后，<code>sort</code>操作反而变慢了。</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">超线程（Hyperthreading），也被称为超线程技术，是一种在单个物理处理器核心内部模拟出多个逻辑处理器的技术。它旨在提高处理器的并发性能和利用率。</span><br><span class="line"></span><br><span class="line">在传统的处理器中，每个物理处理器核心只能同时执行一个指令流（线程）。而使用超线程技术，处理器核心会被虚拟地分成多个逻辑核心，每个逻辑核心都可以执行一个线程。虽然这些逻辑核心共享同一个物理核心的资源，如缓存和执行单元，但通过智能调度和切换，它们可以在某些情况下同时执行不同的线程。</span><br><span class="line"></span><br><span class="line">超线程的优势在于它可以更好地利用处理器资源，特别是在存在多个线程但并不总是需要完全独占处理器资源的情况下。这可以提高多线程应用程序的性能，尤其是在某些特定工作负载下。然而，效果可能因应用程序和硬件配置的不同而有所变化。虽然超线程可以带来性能提升，但并不像物理核心增加那样显著，因为逻辑核心之间仍然需要共享一些资源。</span><br></pre></td></tr></table></figure><p>那么多快才叫快呢？一个好的基于软件的交易系统，最小的开始到结束的时间最好在 2.5 us 左右。</p><h2 id="低延迟编程"><a href="#低延迟编程" class="headerlink" title="低延迟编程"></a>低延迟编程</h2><p>When in doubt, use brute force.</p><p align="right"> - Ken Thompson</p><p>Carl 在 presentation 中介绍了一个例子，如下：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 尽可能避免这种写法</span></span><br><span class="line"> <span class="keyword">if</span> (<span class="built_in">checkForErrorA</span>())</span><br><span class="line">    <span class="built_in">handleErrorA</span>();</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">if</span> (<span class="built_in">checkForErrorB</span>())</span><br><span class="line">    <span class="built_in">handleErrorB</span>();</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">if</span> (<span class="built_in">checkForErrorC</span>())</span><br><span class="line">    <span class="built_in">handleErrorC</span>();</span><br><span class="line"> <span class="keyword">else</span></span><br><span class="line">    <span class="built_in">sendOrderToExchange</span>();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 采用这种写法</span></span><br><span class="line"><span class="type">int64_t</span> errorFlags;</span><br><span class="line">...</span><br><span class="line"><span class="keyword">if</span> (!errorFlags)</span><br><span class="line">    <span class="built_in">sendOrderToExchange</span>();</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">    <span class="built_in">HandleError</span>(errorFlags);</span><br></pre></td></tr></table></figure><p>上述例子的思路也非常清晰，在允许交易的时候尽快进入交易代码。</p><p>下一个例子是关于是否使用虚函数来读取配置文件的，虚函数用起来非常方便，但是有时候会带来比较大的开销。因此，一种可能的解决方式是通过模板来实现，这样可以消除一些分支和不被执行的代码。下图是一个例子：</p><p><img data-src="/posts/70cd342/virtual_func.png" alt="虚函数调用链示意图"><br><img data-src="/posts/70cd342/virtual_func_impl.png" alt="虚函数实现结构图"></p><p>采用模版函数来实现能够保证在编译时所有的行为都是确定的。保证函数在运行时的确定性是非常重要的。</p><p>内存分配这一行为开销很大，所以很多对性能要求比较高的系统通常会使用一个预先分配好的内存池。复用对象比销毁释放对象要好，销毁对象也会带来很大的开销。复用对象还能够避免内存碎片。如果一定要销毁一个比较大的对象，那么使用其他线程去执行会是一个更好的行为。</p><p>C++中的异常本身不会带来太大的开销，如果 exception 不被抛出，这基本不会带来什么开销，但是如果我们使用 exception 来做流程控制，那么这个过程就会带来很大的开销。</p><p>在代码编写过程中，尽管分支是一种不可避免的逻辑，我们也应该尽可能避免分支，转而采用模版的方式来实现，下图是一个例子：</p><p><img data-src="/posts/70cd342/if_impl.png" alt="if 分支实现示意图"><br><img data-src="/posts/70cd342/if_impl_temp.png" alt="模板化 if 实现示意图"></p><p>多线程是一个非常好的工具，但是多线程会带来很大的开销，比如：通过锁来同步数据的开销很大、lock free 的代码在硬件层面依然需要通过锁来实现、并行执行非常复杂、生产者很容易意外地使消费者饱和。如果一定要使用多线程，那么要尽可能少地使用共享数据，尽可能通过数据副本来传递数据而非直接共享，如果不得不共享数据，尽可能不要使用同步。</p><p>在数据查找场景下，尽可能通过保存数据副本来避免数据查找。</p><p>接下来介绍一下<code>unordered_map</code>的使用，大家都知道 map 是通过多个 bucket 来管理 key-value pair，我们应该尽可能保证一个 bucket 只有一个 key-value pair，因为 bucket 下的 node 是通过指针管理的，显然没有连续内存空间的访问效率。我们可以使用一些特殊的 map 实现来替代，比如 Google 的 dense_hash_map，由于其利用的连续的内存空间，整个访问效率会高很多。</p><p><img data-src="/posts/70cd342/dense_hash_map.png" alt="dense_hash_map 内存布局图"></p><p>这里还提到一种方式，可以采用链式访问和连续内存空间的混合方式来实现，从而获得确定性的内存访问行为。下图是具体的实现：</p><p><img data-src="/posts/70cd342/hybrid_hash_map.png" alt="混合哈希表结构图"></p><p>这种实现将连续内存空间作为 metadata，存储着指向特定对象的指针，这样可以保证比较确切的一次指针跳转即可获得目标对象。这样可以带来很大的性能提升。</p><p>hotpath 往往很少被执行，这导致 cache 往往被非 hotpath 占据，所以最好通过一些 dummy path来持续执行 hotpath，以保证 hotpath 尽可能多的占据 cache，这样也能让硬件在做分支预测时做出正确的决定。</p><p>在使用 CPU 时，尽可能不要共享 L3 cache，保证只有一个核能够访问 cache。如何一定要允许多核访问，那么尽可能讲一些可能干扰 cache 的程序移到其他 CPU 上。</p><p>Placement new有可能降低性能，因为它会对传入的内存执行一个空指针检查，可以采用如下方式替换：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Placement new</span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;new&gt;</span></span></span><br><span class="line">Object* object = <span class="built_in">new</span>(buffer)Object;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Alternative</span></span><br><span class="line"><span class="function"><span class="type">void</span>* Object::<span class="keyword">operator</span> <span class="title">new</span><span class="params">(<span class="type">size_t</span>, <span class="type">void</span>* mem)</span> <span class="comment">/* can throw */</span> </span>&#123;</span><br><span class="line">    <span class="keyword">return</span> mem;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">&quot;Placement new&quot; 是 C++ 编程语言中的一个概念，用于在已分配的内存块上构造对象。通常，使用 &quot;new&quot; 运算符可以动态地在堆（heap）上分配内存并构造对象。然而，有时候我们需要将对象放置在已经存在的内存块中，而不是动态地分配新的内存。这就是 &quot;placement new&quot; 的用途。</span><br><span class="line"></span><br><span class="line">使用 &quot;placement new&quot;，可以在指定的内存位置上构造一个对象，而不是在堆上分配新的内存。这在某些特定的情况下很有用，比如：</span><br><span class="line"></span><br><span class="line"><span class="bullet">1.</span> 在特定的内存池中分配对象，以便更好地管理内存分配。</span><br><span class="line"><span class="bullet">2.</span> 与外部设备交互，需要将对象放置在特定的内存地址上。</span><br><span class="line"></span><br><span class="line">其中，<span class="code">`address`</span> 是一个指向已分配内存的指针，<span class="code">`Type`</span> 是要构造的对象的类型。这样，对象就会在指定的内存位置上构造，而不会分配新的内存。</span><br><span class="line"></span><br><span class="line">需要注意的是，当使用 &quot;placement new&quot; 构造对象后，也需要手动调用对象的析构函数来释放资源，因为对象是在已分配的内存块上构造的，而不是通过 &quot;new&quot; 在堆上分配的。</span><br></pre></td></tr></table></figure><p><code>std::function</code>也会发生 allocation，可以考虑使用<code>inplace_function</code></p><p><img data-src="/posts/70cd342/function_allocation.png" alt="函数对象内存分配示意图"><br><img data-src="/posts/70cd342/inplace_function.png" alt="inplace_function 内部结构图"></p><p><code>std::pow</code>也会带来比较大的性能影响，当执行 pow 操作时指数变大时，消耗的时间会发生巨大的变化</p><p><img data-src="/posts/70cd342/pow.png" alt="自定义 pow 函数性能对比图"></p><h2 id="低延迟系统的性能评估"><a href="#低延迟系统的性能评估" class="headerlink" title="低延迟系统的性能评估"></a>低延迟系统的性能评估</h2><p>两种通用的方式：</p><ul><li>Profiling：检查 code 在做什么，瓶颈是什么</li><li>Benchmarking：对系统用时进行评估</li></ul><p>Profiling 可以找到一些非预期的行为，但是 Profiling 中做的优化不一定百分百让系统变快</p><p><img data-src="/posts/70cd342/drawbacks.png" alt="方案局限性总结图"><br><img data-src="/posts/70cd342/best_measurement.png" alt="最佳测量实践流程图"></p>]]></content>
    
    
    <summary type="html">这是今天刷 v2ex 的时候看到的一个 tech talk，讲的是 c++在高频交易下的使用，我以前看过《漫步华尔街》，了解过一点高频交易的原理，这个tech talk 主要讲了一下什么是电子做市，有哪些技术挑战，以及如何利用 c++ 解决这些挑战。</summary>
    
    
    
    
    <category term="量化交易" scheme="https://liang7878.github.io/tags/%E9%87%8F%E5%8C%96%E4%BA%A4%E6%98%93/"/>
    
    <category term="C++" scheme="https://liang7878.github.io/tags/C/"/>
    
    <category term="CppCon" scheme="https://liang7878.github.io/tags/CppCon/"/>
    
    <category term="cpp" scheme="https://liang7878.github.io/tags/cpp/"/>
    
    <category term="高频交易" scheme="https://liang7878.github.io/tags/%E9%AB%98%E9%A2%91%E4%BA%A4%E6%98%93/"/>
    
    <category term="高性能" scheme="https://liang7878.github.io/tags/%E9%AB%98%E6%80%A7%E8%83%BD/"/>
    
  </entry>
  
  <entry>
    <title>读字节技术文章《广告案例｜10亿数据、查询&lt;10s，论基于OLAP搭建广告系统的正确姿势》</title>
    <link href="https://liang7878.github.io/posts/38d9ffd1.html"/>
    <id>https://liang7878.github.io/posts/38d9ffd1.html</id>
    <published>2023-08-12T14:52:52.000Z</published>
    <updated>2026-06-29T17:07:30.748Z</updated>
    
    <content type="html"><![CDATA[<p>这篇文章是昨天刚放出来的，还挺巧，前段时间我刚好在面试过程中被面试官问到这个系统的相关问题。我们借这个机会，正好深入看一下这篇文章。</p><h2 id="业务背景"><a href="#业务背景" class="headerlink" title="业务背景"></a>业务背景</h2><p>精细化营销是广告系统中极为重要的一环。依赖于客户画像平台对用户画像的精确标注，广告系统可以针对性地进行广告投放，实现所谓精准投放的效果。这个过程中，分析师需要通过用户标签进行人群圈选，以挑选出最合适的目标用户群体。对于这类操作，业务方自然是希望能够更加高效地获得更加实时的圈选结果。因此，这类平台会遇到两方面的问题：</p><ul><li>第一，由于此类查询分析是临时性的，各种标签组合数巨大，离线预计算无法满足此类灵活性。</li><li>第二，由于此类查询是实时场景，查询性能变得非常关键， 通常一次查询在分钟级，耗时较长，无法满足分析师需求。</li></ul><p>这篇文章介绍了如何在 OLAP 场景下解决人群圈选查询的需求，并介绍了 ByteHouse 的使用。在 10 亿级用户测试数据下，整体查询 P99 小于 10s。</p><h2 id="整体架构"><a href="#整体架构" class="headerlink" title="整体架构"></a>整体架构</h2><p>整个业务系统的数据架构如下：</p><p><img data-src="/posts/38d9ffd1/arch.png" alt="数据架构图"></p><span id="more"></span><p>系统的输入分为用户流和事件流，用户流主要包含用户的注册信息，事件流则是用户的行为信息。标签生产任务则是异步执行，为每个用户打上标签。完成标签生产后，数据会被写入 OLAP 引擎中，用于满足灵活的查询需求。</p><p>数据湖中的数据格式如下所示，主要包含用户的基本信息和标签信息：<br><img data-src="/posts/38d9ffd1/id-tag.png" alt="ID 与标签映射关系图"><br>为了方便用户圈选，还可采用以 tag 为主的数据格式，如下所示：<br><img data-src="/posts/38d9ffd1/tag-user.png" alt="标签到用户反查流程图"></p><p>这种格式的好处在于能够减少数据规模，只保留标签和相关的用户 id，方便用户圈选的相关操作。这里其实比较好奇他们是怎么管理用户 id 的，结果马上就来了。为了管理这些信息，这里采用了 ByteHouse建表，这里可知用户 id 是采用 int64 类型来存储的。</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> id_tags (</span><br><span class="line">    tags            String,</span><br><span class="line">    active_users    <span class="keyword">Array</span><span class="operator">&lt;</span>UInt64<span class="operator">&gt;</span></span><br><span class="line">) Engine <span class="operator">=</span> CnchMergeTree() <span class="keyword">order</span> <span class="keyword">by</span> tags</span><br></pre></td></tr></table></figure><p>在进行人群圈选时，比如需要找到满足多个 tag 的人群数量，可以采用如下 SQL：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">WITH</span> (<span class="keyword">SELECT</span> active_users <span class="keyword">as</span> tag_1</span><br><span class="line">        <span class="keyword">FROM</span> id_tags</span><br><span class="line">        <span class="keyword">WHERE</span> tags <span class="operator">=</span> <span class="string">&#x27;tag_1&#x27;</span>) <span class="keyword">as</span> tag_1_user,</span><br><span class="line"><span class="keyword">WITH</span>(<span class="keyword">SELECT</span> active_users <span class="keyword">as</span> tag_2</span><br><span class="line">        <span class="keyword">FROM</span> id_tags</span><br><span class="line">        <span class="keyword">WHERE</span> tags <span class="operator">=</span> <span class="string">&#x27;tag_2&#x27;</span>) <span class="keyword">as</span> tag_2_user,</span><br><span class="line"><span class="keyword">SELECT</span> length(arrayIntersect(tag_1_user, tag_2_user))</span><br></pre></td></tr></table></figure><p>这一操作需要进行全表的扫描，找到特定 tag 后在获取用户交集。然后再取长度。通过选取BitMap来实现，能够有效优化查询效率，建表语句如下：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> id_tags (</span><br><span class="line">    tags            String,</span><br><span class="line">    active_users    BitMap64</span><br><span class="line">) Engine <span class="operator">=</span> CnchMergeTree() <span class="keyword">order</span> <span class="keyword">by</span> tags</span><br></pre></td></tr></table></figure><p>查询语句如下：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> bitmapCount(<span class="string">&#x27;tag_1&amp;tag_2&#x27;</span>)</span><br><span class="line"><span class="keyword">FROM</span> tag_uids_map</span><br></pre></td></tr></table></figure><p>通过位图查询，能做有效优化这种查询的查询效率，这里给出的数据是 10 到 50 倍性能提升。</p><h2 id="BitMap在-BitHouse-中是如何管理的"><a href="#BitMap在-BitHouse-中是如何管理的" class="headerlink" title="BitMap在 BitHouse 中是如何管理的"></a>BitMap在 BitHouse 中是如何管理的</h2><p>接下来我们来看看 BitMap 在 ByteHouse 中是如何管理的。正常来说，BitMap 的数据规模是跟随者需要映射的用户空间来线性增长的。比如采用 32 位 INT 管理用户 ID 时，对应的位图则是 2^32 bits（512MB）的空间。由于用户量往往无法达到这个数据规模，所以这里采用了一个叫做 Roaring Bitmap 的数据结构来做数据压缩。来看 chatGPT 介绍一下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Roaring Bitmap是一种高效的数据结构，用于压缩稀疏位图数据。它被设计用于处理大型数据集中的集合操作，如并集、交集和差集，同时具有较低的内存占用和高性能。Roaring Bitmap在处理大量离散值的数据时特别有用，比如在大数据分析、数据库系统、搜索引擎和网络流量分析等领域。</span><br><span class="line"></span><br><span class="line">传统的位图数据结构在存储稀疏数据时可能会产生大量的内存浪费，因为它们需要为每个可能的值分配一位。而Roaring Bitmap使用了一种基于区间的压缩方法，将连续的位存储为一个区间，从而减少了存储空间的需求。这使得Roaring Bitmap可以有效地表示具有大量离散值的数据集，同时提供了高效的集合操作。</span><br><span class="line"></span><br><span class="line">Roaring Bitmap主要有两种格式：RoaringArray和RoaringBitmap。RoaringArray是一种紧凑的数组结构，用于存储小区间（通常是16位或32位的整数）。RoaringBitmap是一个由多个RoaringArray组成的集合，每个RoaringArray代表一个更大的区间范围。</span><br><span class="line"></span><br><span class="line">总之，Roaring Bitmap是一种优秀的数据结构，可以在高效使用内存的同时处理大规模稀疏位图数据，适用于各种需要进行集合操作的应用场景。</span><br></pre></td></tr></table></figure><p>关于 roaring BitMap 的具体实现，大家可以看<a href="https://cloud.tencent.com/developer/article/1136054">这篇文章</a></p><p>另一个优化则是通过字典对数据进行编码，优化查询效率，下面是开启字典优化的方式：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">CREATE TABLE</span> id_tags (</span><br><span class="line">    tags            String,</span><br><span class="line">    active_users    BitMap64 BitEngineEncode</span><br><span class="line">) Engine <span class="operator">=</span> CnchMergeTree() <span class="keyword">order</span> <span class="keyword">by</span> tags</span><br></pre></td></tr></table></figure><p>有了字典之后，再进行交并补操作的效率就会高很多。</p><h2 id="ClickHouse-在字节广告-DMP-CDP-的应用"><a href="#ClickHouse-在字节广告-DMP-CDP-的应用" class="headerlink" title="ClickHouse 在字节广告 DMP &amp; CDP 的应用"></a>ClickHouse 在字节广告 DMP &amp; CDP 的应用</h2><p>在查找文章相关的内容时，我碰巧看到了<a href="https://juejin.cn/post/6859570827051368462">这篇文章</a>，同样也是介绍 ClickHouse 在字节广告中的应用。这里介绍了广告业务下的三个场景：人群预估、人群画像和统计分析：</p><ul><li><strong>人群预估</strong>主要是根据一定的圈选条件，确认命中的用户数目。在广告精准投放过程中，广告主需要知道当前选定的人群组合中大概会有多少人，用于辅助判断投放情况进而确定投放预算。因为是在线业务，一般要求计算的时间不能超过 5 秒。</li><li><strong>人群画像</strong>主要是对广告投放的用户群进行画像分析，也是在线的，同样对时间有一定的要求，因为是偏分析的场景，一般不能超过 20 秒，否则用户的体验就非常差了。</li><li><strong>统计分析</strong>的使用场景比较多，在线、离线都有，包括一些搜索词统计分析，广告、投放收入数据的分析等等，应用的方面很多。</li></ul><p>这篇文章主要介绍了上面所说的人群圈选这一场景下业务迭代的整个过程，前期采用过明细存储的方式管理用户标签和 uid 的 mapping 关系，虽然他们通过各类方式对计算进行了优化，但是仍然存在一些问题，比如人群过大时查询效率低下，查询语句过于复杂等。最终才优化到了上文所说的方案。下图展示了 SQL 语句的优化：</p><p><img data-src="/posts/38d9ffd1/sql-opt.png" alt="SQL 优化策略示意图"></p><p>为了进一步优化整体的效率，这里还做了数据分割、并行计算的方式对位图查询进行优化。为了解决数据区间内用户 id 过于离散的问题，他们又对区间内数据进行了编码，从而使区间内的用户相对集中，这应该就是上文所说的“字典优化”了。</p><p>另外，文中还提到了通过 cache 进行数据读取和计算的优化，包括读取层面的 cache、中间计算过程的 cache 和最终结果的 cache。</p><p>这一系列的优化带来了非常明显的效果。文中提到“从空间上来说，采用 RoaringBitmap 可以减少 tag_id 列的冗余存储，同时 uid 采用压缩存储，因此整的空间存储降低为原来的 1&#x2F;3，因为数据量降低了，因此导入也变快了，导入时长也缩短为原来的 1&#x2F;3，同时，在查询性能上收益非常明显，avg&#x2F;pct99&#x2F;max 下降明显，消除绝大多数 5 s 以上的大查询，可以说达到了开发的预期。最后，在资源上效果也很不错，CPU 使用下降明显，内存使用上 PageCache 节省 100 G+ 以上。。。，原来确实是经常有一些大的查询，有些时间久的甚至超过了 20 s。上线后。。。很少看到超过 5 s 的查询了，绝大部分查询非常稳定。这个其实还是我们没有上中间结算结果 cache 时候的效果图，当我们通过 multiCount 缓存中间结果后，直接把 qps 下降了 4 倍以上”</p><p>对于他们的下一步工作，这里提到了三个方面，一是优化数据读取，有点类似于谓词下推的思路，二十智能化 cache，对公共子项进行缓存，三十扩充表达式的表现能力，优化使用方的使用体验。这几个思路说起来其实都是比较合理的优化方向。</p><h2 id="Reference"><a href="#Reference" class="headerlink" title="Reference"></a>Reference</h2><p>[1] <a href="https://mp.weixin.qq.com/s/8ws9sqlTVHjDa_VHbe62Sw">https://mp.weixin.qq.com/s/8ws9sqlTVHjDa_VHbe62Sw</a><br>[2] <a href="https://juejin.cn/post/6859570827051368462">https://juejin.cn/post/6859570827051368462</a><br>[3] <a href="https://cloud.tencent.com/developer/article/1136054">https://cloud.tencent.com/developer/article/1136054</a><br>[4] <a href="https://juejin.cn/post/6859570827051368462">https://juejin.cn/post/6859570827051368462</a></p>]]></content>
    
    
    <summary type="html">这篇文章是昨天刚放出来的，还挺巧，前段时间我刚好在面试过程中被面试官问到这个系统的相关问题。我们借这个机会，正好深入看一下这篇文章。</summary>
    
    
    
    <category term="技术杂谈" scheme="https://liang7878.github.io/categories/%E6%8A%80%E6%9C%AF%E6%9D%82%E8%B0%88/"/>
    
    
    <category term="OLAP" scheme="https://liang7878.github.io/tags/OLAP/"/>
    
    <category term="数据仓库" scheme="https://liang7878.github.io/tags/%E6%95%B0%E6%8D%AE%E4%BB%93%E5%BA%93/"/>
    
    <category term="广告" scheme="https://liang7878.github.io/tags/%E5%B9%BF%E5%91%8A/"/>
    
    <category term="查询" scheme="https://liang7878.github.io/tags/%E6%9F%A5%E8%AF%A2/"/>
    
    <category term="ByteHouse" scheme="https://liang7878.github.io/tags/ByteHouse/"/>
    
    <category term="ClickHouse" scheme="https://liang7878.github.io/tags/ClickHouse/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 -- P1000超级玛丽游戏</title>
    <link href="https://liang7878.github.io/posts/d27d7a9c.html"/>
    <id>https://liang7878.github.io/posts/d27d7a9c.html</id>
    <published>2023-08-11T02:10:33.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<p>这道题纯粹就是一道试机题，只要正常输出结果就可以了，下面是我的代码：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&quot;iostream&quot;</span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line">string s[] = &#123;<span class="string">&quot;                ********&quot;</span>,</span><br><span class="line">              <span class="string">&quot;               ************&quot;</span>,</span><br><span class="line">              <span class="string">&quot;               ####....#.&quot;</span>,</span><br><span class="line">              <span class="string">&quot;             #..###.....##....&quot;</span>,</span><br><span class="line">              <span class="string">&quot;             ###.......######              ###            ###&quot;</span>,</span><br><span class="line">              <span class="string">&quot;                ...........               #...#          #...#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;               ##*#######                 #.#.#          #.#.#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;            ####*******######             #.#.#          #.#.#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;           ...#***.****.*###....          #...#          #...#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;           ....**********##.....           ###            ###&quot;</span>,</span><br><span class="line">              <span class="string">&quot;           ....****    *****....&quot;</span>,</span><br><span class="line">              <span class="string">&quot;             ####        ####&quot;</span>,</span><br><span class="line">              <span class="string">&quot;           ######        ######&quot;</span>,</span><br><span class="line">              <span class="string">&quot;##############################################################&quot;</span>,</span><br><span class="line">              <span class="string">&quot;#...#......#.##...#......#.##...#......#.##------------------#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;###########################################------------------#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;#..#....#....##..#....#....##..#....#....#####################&quot;</span>,</span><br><span class="line">              <span class="string">&quot;##########################################    #----------#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;#.....#......##.....#......##.....#......#    #----------#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;##########################################    #----------#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;#.#..#....#..##.#..#....#..##.#..#....#..#    #----------#&quot;</span>,</span><br><span class="line">              <span class="string">&quot;##########################################    ############&quot;</span>&#125;;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    <span class="keyword">for</span> (string str : s) &#123;</span><br><span class="line">        cout &lt;&lt; str &lt;&lt; endl;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>非常朴实无华地用一个数组把需要输出的字符画保存起来输出就行了。没有什么技巧。</p>]]></content>
    
    
    <summary type="html">洛谷 P1000 超级玛丽游戏题解，属于 OI 算法入门试机题，讲解如何用 C++ 字符串数组保存并打印 ASCII 字符画，重点练习标准输出与字符画原样转义输出技巧。</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
  <entry>
    <title>洛谷综合题单 -- A+B Problem</title>
    <link href="https://liang7878.github.io/posts/2d329e20.html"/>
    <id>https://liang7878.github.io/posts/2d329e20.html</id>
    <published>2023-08-11T02:10:33.000Z</published>
    <updated>2026-06-29T17:07:30.755Z</updated>
    
    <content type="html"><![CDATA[<p>试机题，直接贴一下题解：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">#include &quot;iostream&quot;</span><br><span class="line"></span><br><span class="line">using namespace std;</span><br><span class="line"></span><br><span class="line">int main() &#123;</span><br><span class="line">    int a, b;</span><br><span class="line">    cin &gt;&gt; a &gt;&gt; b;</span><br><span class="line">    cout &lt;&lt; a + b;</span><br><span class="line">    return 0;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">洛谷 P1001 A+B Problem 题解，面向 OI 算法入门，讲解如何用 C++ 通过 cin/cout 完成两数相加的基础输入输出，是新手熟悉评测环境和标准 IO 的试机入门题。</summary>
    
    
    
    <category term="编程进阶" scheme="https://liang7878.github.io/categories/%E7%BC%96%E7%A8%8B%E8%BF%9B%E9%98%B6/"/>
    
    
    <category term="OI" scheme="https://liang7878.github.io/tags/OI/"/>
    
    <category term="算法" scheme="https://liang7878.github.io/tags/%E7%AE%97%E6%B3%95/"/>
    
    <category term="刷题" scheme="https://liang7878.github.io/tags/%E5%88%B7%E9%A2%98/"/>
    
  </entry>
  
</feed>
