<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[有赞技术团队]]></title><description><![CDATA[Thoughts, stories and ideas.]]></description><link>https://tech.youzan.com/</link><generator>Ghost 0.6</generator><lastBuildDate>Sun, 19 Apr 2026 15:14:53 GMT</lastBuildDate><atom:link href="https://tech.youzan.com/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[知识库检索匹配的服务化实践]]></title><description><![CDATA[<h1 id="">一、背景</h1>

<p>　　知识库是企业经营过程中的面向客户和内部员工的知识沉淀文档库，里面包含各类教程、问答、案例等，知识库的检索匹配是自然语言处理(NLP)中一个重要的基础问题，本质是进行文本语义的相似度计算，也就是语义匹配，我们很多领域的任务都可以抽象为文本匹配检索任务，例如检索引擎、智能客服、知识检索、信息推荐等领域。<br>
　　知识库检索匹配可以概述为：给定一个query和大量候选知识库的文档，从这些文档中找出与用户输入query最匹配的TopK个文档。</p>

<h1 id="">二、架构流程</h1>

<h2 id="21">2.1、整体架构</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/----.png" alt="image"></p>

<h2 id="22">2.2、请求链路</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/-----1.png" alt="image"></p>

<h1 id="">三、算法模型</h1>

<h2 id="31dsl">3.1、DSL改写</h2>

<p>　　检索优化第一步：DSL改写，接手前业务方自己已经对检索结果做过优化，调整不同字段的匹配权重，这一方法的已经难以继续优化。从知识运营的角度出发，在用户检索时，将运营认为重要的文档推到前面，由于文档之间互相有链接引用，可以使用PageRank算法给每个文档计算重要分(PR值)。<br>
　　PageRank的核心思想是，被引用次数越多的文档越重要。算法原理如下，假设只有四个网页ABCD，以AB间的箭头为例，代表可以从B网页跳转到A网页，对B即一次引用（</p>]]></description><link>https://tech.youzan.com/xiang-liang-hua-wen-ben-pi-pei-jian-suo-de-fu-wu-hua-shi-jian/</link><guid isPermaLink="false">e097cfe1-4722-4996-968c-9a102a89c5f4</guid><category><![CDATA[大数据]]></category><category><![CDATA[大数据]]></category><category><![CDATA[向量化]]></category><category><![CDATA[检索]]></category><category><![CDATA[知识库]]></category><dc:creator><![CDATA[liujiwen]]></dc:creator><pubDate>Fri, 28 Oct 2022 07:02:54 GMT</pubDate><media:content url="https://tech.youzan.com/content/images/2022/09/image-3.png" medium="image"/><content:encoded><![CDATA[<h1 id="">一、背景</h1>

<img src="https://tech.youzan.com/content/images/2022/09/image-3.png" alt="知识库检索匹配的服务化实践"><p>　　知识库是企业经营过程中的面向客户和内部员工的知识沉淀文档库，里面包含各类教程、问答、案例等，知识库的检索匹配是自然语言处理(NLP)中一个重要的基础问题，本质是进行文本语义的相似度计算，也就是语义匹配，我们很多领域的任务都可以抽象为文本匹配检索任务，例如检索引擎、智能客服、知识检索、信息推荐等领域。<br>
　　知识库检索匹配可以概述为：给定一个query和大量候选知识库的文档，从这些文档中找出与用户输入query最匹配的TopK个文档。</p>

<h1 id="">二、架构流程</h1>

<h2 id="21">2.1、整体架构</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/----.png" alt="知识库检索匹配的服务化实践"></p>

<h2 id="22">2.2、请求链路</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/-----1.png" alt="知识库检索匹配的服务化实践"></p>

<h1 id="">三、算法模型</h1>

<h2 id="31dsl">3.1、DSL改写</h2>

<p>　　检索优化第一步：DSL改写，接手前业务方自己已经对检索结果做过优化，调整不同字段的匹配权重，这一方法的已经难以继续优化。从知识运营的角度出发，在用户检索时，将运营认为重要的文档推到前面，由于文档之间互相有链接引用，可以使用PageRank算法给每个文档计算重要分(PR值)。<br>
　　PageRank的核心思想是，被引用次数越多的文档越重要。算法原理如下，假设只有四个网页ABCD，以AB间的箭头为例，代表可以从B网页跳转到A网页，对B即一次引用（链出），对A则一次被引用（链入）。L(B) 表示B网页的链出数量，PR(B)表示B网页的PageRank分数。
<img src="https://tech.youzan.com/content/images/2022/10/dsl1.png" alt="知识库检索匹配的服务化实践">
<img src="https://tech.youzan.com/content/images/2022/10/dsl2.png" alt="知识库检索匹配的服务化实践">
　　假设所有文档的初始PR值是0.25，这里L(B)=2，L(C)=1，L(D)=3，计算出PR(A)=0.458，接下来计算所有其他被引用（有链入）的文档PR值，PageRank是个迭代算法，反复计算以后所有的PR值会收敛，那就是最终每个文档的PR值，也是用来改写DSL的关键信息：<br></p>

<pre><code>new score = old score * log(1+2*PageRank)  
</code></pre>

<p>　　old score指原来不同字段加不同权重由ES算出来的BM25分数，PageRank缺失值使用1代替。</p>

<h2 id="3">3.２、文本召回</h2>

<p>　　文本召回是召回中最常用的一种策略，最常见的方式是通过对Query直接进行分词，然后将分词后的关键词到ES构建倒排索引，进行tf-idf等相似计算匹配索引召回，这种召回方式的优点是实现简单，不需要训练模型、低资源需求、检索速度快，然而它的缺点也很明显，文本是具有语义的、是有语法结构的，文本召回忽略了语句的语法结构，同时也无法解决一词多义和同义词的问题，对 query 进行语义层面相似的召回效果就比较一般，解决这个问题就要用到向量召回。</p>

<h2 id="3">3.３、向量召回</h2>

<p>　　向量召回的思想就是计算搜索词的向量和文档标题/相似问的向量的余弦相似度，返回相似度分数最高的TopK个文档，计算向量相似度的步骤放在Milvus进行，Milvus作为向量检索库，对计算过程有优化，性能更好。<br>
　　向量计算模型结构：
<img src="https://tech.youzan.com/content/images/2022/09/------.png" alt="知识库检索匹配的服务化实践">
　　文本转向量的算法模型由embedding、两层transformer和MLP组成，模型最后会对编码向量做L2归一化，采用典型的双塔模式，可以将左塔的搜索词和右塔的文档标题形成独立的子网络，左右塔的结构分离但编码器参数共享，双塔结构天然的可以用于召回，将这个模型部署到小盒子就可以在线计算搜索词的向量，将海量的知识库文档作为右塔离线训练成文本向量后刷入向量检索工具Milvus。<br>
　　双塔召回模型的核心思想是将query/item嵌入到共享低维空间，然后通过向量距离来度量相关性。<br>
　　向量召回流程：
<img src="https://tech.youzan.com/content/images/2022/09/------1-1.png" alt="知识库检索匹配的服务化实践"></p>

<p>　　工程实现部分的DP离线任务中实现了计算文档标题和相似问向量并将其导入Milvus的功能。由于Milvus对string类型属性信息存储检索不够友好，所以在DB阶段会请求mysql库表对召回结果进行扩展，匹配补全相关信息。<br></p>

<h2 id="34">3.4、精排序</h2>

<p>　　经过召回和粗排后，可以理解为将重要相关的文档排在了前面，但是距离用户真正的检索意图还有差距，可以使用用户的检索记录对结果再进行排序。<br>
　　基于所有场景的用户检索点击数据，有点击行为就认为检索词和文档标题匹配（正样本），其他就认为没有那么匹配（负样本）<br>
　　训练数据样例：<br>
<img src="https://tech.youzan.com/content/images/2022/10/------.png" alt="知识库检索匹配的服务化实践">
　　采用 in_batch 负采样就不需要提前构造负样本，模型的设计如下：
<img src="https://tech.youzan.com/content/images/2022/10/---1.png" alt="知识库检索匹配的服务化实践">
　　检索词与正负样本的相似度会进入InfoNCE（info Noise Contrastive Estimation，噪声对比估计）的函数计算损失，使用这个损失来更新模型参数。这个由很多负样本组成的双塔结构也称为对比学习，核心思想是去拉近相似的样本，推开不相似的样本，目标是要从样本中学习到一个好的语义表示空间。<br>
　　精排模型与文本转向量的算法原理相同。<br>
　　InfoNCE计算公式：（可以理解为带温度超参的CrossEntropy）<br></p>

<pre><code>L_i=-log(\frac{exp(sim(z_i, z_i^+)/\tau)}{\sum_j{exp(sim(z_i, z_j)/\tau)}})  
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2022/10/---2.png" alt="知识库检索匹配的服务化实践">
　　分子是正例对的相似度，分母是正例对+所有负例对的相似度，最小化infoNCE loss，就是最大化正例对的相似度，最小化负例对的相似度。<br>
　　在计算损失时，label可以在batch内生成，检索词和文档的编码向量经过矩阵乘法可以得到一个相似度方阵，对角位置就是互相匹配的检索词和文档的分数，如果batch<em>size=4，那每行对应的label就是 [0,1,2,3]。in</em>batch负采样损失计算示意图：<br>
<img src="https://tech.youzan.com/content/images/2022/10/---3.png" alt="知识库检索匹配的服务化实践">
<img src="https://tech.youzan.com/content/images/2022/10/---4.png" alt="知识库检索匹配的服务化实践"></p>

<p>　　模型训练好以后，就得到文本的编码器，输入两个文本，就可以得到一个匹配的分数，将这个模型部署到小盒子，在需要排序时，输入候选的文档标题和检索词，按计算出来的分数从高到低排序，就完成了一次对检索结果的排序。<br></p>

<h2 id="35">3.5、排序优化</h2>

<p>　　上述向量召回介绍的在模型服务中计算两个文本相似度的方法，在只需要对20个文档（一页）排序时是没有问题的，但是每个文档还会有若干个相似问，只使用20个商品标题没法很好的代表整个文档，如果能使用每个文档的标题和全部相似问，那效果会更稳定一些，但这样的话，每次进入小盒子排序的文档数就不固定了，少则20，多则几百，上百的batch_size走小盒子很容易超时，就算切成小的batch，也会有一个失败全部失败的情况，而且总体上rt也没下降多少。<br>
　　与向量召回时类似，模型也改为输入只有一个文本，输出这个文本的向量。每个文档的标题和全部相似问向量都与Query向量算相似度后计算均值，等价于先计算文档的标题和全部相似问的向量均值，再与Query向量计算相似度。基于此，排序任务也可以转换为向量召回任务。<br>
　　在进入Milvus之前，会按照向量召回和ES召回的文档ID作为过滤条件，一次计算的RT就得以保证，可以支持的QPS也可以比基于小盒子的版本高：<br>
<img src="https://tech.youzan.com/content/images/2022/10/----1.png" alt="知识库检索匹配的服务化实践">
　　此方法可以解决RT和QPS问题，但是也有局限，只能优化“每个文档的标题和全部相似问向量都与Query向量算相似度后计算均值”这个均值计算逻辑，其他的比如“取最大的相似度”就不能这么做了，而且Query与文档的交互太少，只在最后算相似度，可能不如多次交互的模型的效果好。<br></p>

<h1 id="">四、工程实现</h1>

<p>　　当线上接受一条检索请求文本后，先调用在线推理-小盒子计算Query向量，然后去Milvus向量库中和知识库向量进行相似度计算，并返回距离最近的Top N个Item作为向量召回的结果。<br></p>

<h2 id="41dp">4.1、离线训练（DP平台）</h2>

<p>　　海量的知识语料库向量化计算在自研DP平台离线运行，使得全库文本匹配速度较快：<br>
  　　1）语料库预处理：包括语料库的文本清洗、文本筛选等预处理逻辑<br>
  　　2）语料库向量化：利用上述的向量计算模型进行向量化<br>
  　　3）导入Milvus库：将集合部署在Milvus集群，依次批量导入更新机器的集合保证线上可用<br></p>

<h2 id="42sunfish">4.2、在线推理（Sunfish平台）</h2>

<p>　　自研算法平台（Sunfish）对模型训练提供一站式闭环服务，支持分布式训练、GPU/CPU切换、模型版本管理、一键式运行和部署等功能，其中：<br>
  　　1）算法工程模块：一键运行训练、任务作业管理、模型输出<br>
  　　2）模型管理模块：实现训练任务、地址导入、本地上传等多途径模型来源选择<br>
  　　3）模型部署模块：简单配置、模型格式选择、线上资源配置等便捷署方式<br></p>

<pre><code>{
    "inputs":[
        {
            "name":"INPUT",
            "shape":[
                1,
                1
            ],
            "datatype":"BYTES",
            "data":[
                "满足条件满减送没有赠品"
            ]
        }
    ],
    "outputs":[
        {
            "name":"OUTPUT"
        }
    ]
}
</code></pre>

<h2 id="43milvus">4.3、Milvus向量检索</h2>

<p>　　Milvus 是一款开源的、针对海量特征向量的向量相似性检索(ANNS，Approximately nearest neighbor search)引擎，集成了 Faiss、Annoy 等广泛应用的向量索引，成本更低、性能更好、高度灵活、稳定可靠以及高速查询等特点，十亿向量检索仅毫秒响应。<br>
　　1、Milvus向量索引列表如下：<br>
<img src="https://tech.youzan.com/content/images/2022/10/milvus1.png" alt="知识库检索匹配的服务化实践">
　　简言之，每种索引都有自己的适用场景，如何选择合适的索引可以简单遵循如下原则：<br>
  　　1）当查询数据规模小，且需要 100％查询召回率时，用 FLAT；<br>
  　　2）当需要高性能查询，且要求召回率尽可能高时，用 IVF_FLAT；<br>
  　　3）当需要高性能查询，且磁盘、内存、显存资源有限时，用 IVFSQ8H；<br>
  　　4）当需要高性能查询，且磁盘、内存资源有限，且只有 CPU 资源时，用 IVFSQ8。<br></p>

<p>　　2、Milvus 目前支持的距离计算方式与数据格式、索引类型之间的兼容关系：
<img src="https://tech.youzan.com/content/images/2022/10/milvus2-1.png" alt="知识库检索匹配的服务化实践">
　　选择合适的距离计算方式比较向量间的距离，能很大程度地提高数据分类和聚类性能，主要采用内积 (IP)的计算方式，内积更适合计算向量的方向。<br>
　　内积计算两条向量之间的夹角余弦，并返回相应的点积。内积距离的计算公式为：<br>
<img src="https://tech.youzan.com/content/images/2022/09/--.png" alt="知识库检索匹配的服务化实践">
　　假设有 A 和 B 两条向量，则 ||A|| 与 ||B|| 分别代表 A 和 B 归一化后的值。cosθ 代表 A 与 B 之间的余弦夹角。<br>
　　在向量归一化之后，内积与余弦相似度等价。因此 Milvus 并没有单独提供余弦相似度作为向量距离计算方式。<br></p>

<h2 id="44ai">4.4、AI模型接口服务</h2>

<p>　　算法模型接口服务由ai-service和ai-app两个服务组成，ai-service负责调用算法模型在线推理、Milvus实时向量召回等接入库，ai-app负责业务逻辑的开发。<br>
　　1、ai-service配置示例：<br></p>

<pre><code>{
    "model_name": "similarity_jira",
    "model_source_type": "YZ_MODEL",
    "model_version": 1,
    "model_invoke_timeout": 3000,
    "protocol": "kfserving",
    "infer_type": "triton",
    "feature_maps": [
        {
            "model_feature_key": "INPUT",
            "data_type": "string",
            "shape": "(-1,1)",
            "default_value": "",
            "feature_source": "PARAMS",
            "source_key": "jira_text",
            "is_required": 1
        }
    ],
    "param_mapping": {
        "jira_text": "&lt;objectList.jira_text&gt;"
    }
}
</code></pre>

<p>　　2、ai-app接口设计<br>
　　实现业务逻辑开发测试后，发布上线即可提供前后端调用。<br>
　　a、Maven示例:<br></p>

<pre><code>&lt;dependency&gt;  
  &lt;groupId&gt;com.youzan&lt;/groupId&gt;
  &lt;artifactId&gt;ai-app-api&lt;/artifactId&gt;
  &lt;version&gt;1.0.13-RELEASE&lt;/version&gt;
&lt;/dependency&gt;  
</code></pre>

<p>　　b、请求示例：<br></p>

<pre><code>invoke com.youzan.ai.app.api.service.jira.Service.retrieve({"fromApp":"test","scene":"similarity_predict","Title":"满足条件没有赠品","Key":"XXX"})  
</code></pre>

<p>　　c、返回示例：<br></p>

<pre><code>{
    "code":200,
    "data":{
        "Similaritys":[
            {
                "createdAt":1648137600000,
                "score":0.9390,
                "key":"XXX0123442334",
                "title":"满足条件没有赠品"
            },
            {
                "createdAt":1636214400000,
                "score":0.9010,
                "key":"XXX0123365819",
                "title":"满足条件没有送赠品"
            },
            {
                "createdAt":1653408000000,
                "score":0.8735,
                "key":"XXX0123482446",
                "title":"订单满足条件没有送赠品"
            },
            {
                "createdAt":1655308800000,
                "score":0.8312,
                "key":"XXX0123496337",
                "title":"订单满足条件但是没有送赠品"
            },
            {
                "createdAt":1659628800000,
                "score":0.8028,
                "key":"XXX0123527965",
                "title":"订单满条件但是赠品没有送"
            }
        ]
    },
    "success":true,
    "message":"successful"
}
</code></pre>

<h1 id="">五、服务场景</h1>

<h2 id="51">5.1、官网帮助中心</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/-------1.png" alt="知识库检索匹配的服务化实践"></p>

<h2 id="52">5.2、相似商品推荐</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/-------2.png" alt="知识库检索匹配的服务化实践"></p>

<h1 id="">六、总结</h1>

<p>　　本文大致介绍了知识库检索匹配的算法和工程服务化实践过程，服务应用的业务场景比较广泛，并对类似场景的接入做了低代码封装和策略配置，便于快速轻量级的服务化落地。在知识库检索匹配服务化实践过程中，后续值得关注以下几点：<br>
　　1）对于知识库中低频或缺失的新文档或新商品的Embedding学习还不够充分，可考虑利用图结算法结构，把更多query和其他属性的语义信息聚合，进而提升query和知识库文档的语义表征能力；<br>
　　2）Embedding 的实现算法比较多，可考虑结合业务需要，将词嵌入模型模块内置化；<br>
　　3）服务的性能和稳定性，确保服务在高准确率和高QPS下的响应性能依然是重中之重。</p>

<h1 id="">七、招聘号外</h1>

<p>　　有赞数据中台团队，目前有数据平台研发以及数据开发岗位虚位以待，有需要的同学请关注官网。</p>]]></content:encoded></item><item><title><![CDATA[Spark App 血缘解析方案]]></title><description><![CDATA[<h1 id="">一、背景</h1>

<p>随着数据仓库的数据量的增长，数据血缘( Data Lineage or Data Provence ) 对于数据分析来说日益重要， 通过数据血缘可以追溯表-表，表-任务，任务-任务的上下游关系， 用来支撑问题数据溯源，孤岛数据下线的需求。</p>

<p>目前已经基于 ANTLR 语法解析支持了 SQL 任务的血缘解析， 而 Spark App 任务的血缘仍然是通过人工配置方式进行。 我们希望能够将 Spark App 任务的解析做个补充，完善血缘逻辑。</p>

<p>目前线上的 Spark App 任务支持 Spark 2.3、 Spark 3.1 两个版本， 并且支持 python2/3、 java、scala 类型， 运行平台各自支持 yarn 和 k8s,</p>]]></description><link>https://tech.youzan.com/spark-app-xie-yuan-jie-xi-fang-an/</link><guid isPermaLink="false">889373e0-ad34-40d9-8822-ec9a7375eaa2</guid><category><![CDATA[大数据]]></category><category><![CDATA[大数据]]></category><category><![CDATA[bigdata]]></category><dc:creator><![CDATA[yuanyimeng]]></dc:creator><pubDate>Fri, 16 Sep 2022 05:03:20 GMT</pubDate><content:encoded><![CDATA[<h1 id="">一、背景</h1>

<p>随着数据仓库的数据量的增长，数据血缘( Data Lineage or Data Provence ) 对于数据分析来说日益重要， 通过数据血缘可以追溯表-表，表-任务，任务-任务的上下游关系， 用来支撑问题数据溯源，孤岛数据下线的需求。</p>

<p>目前已经基于 ANTLR 语法解析支持了 SQL 任务的血缘解析， 而 Spark App 任务的血缘仍然是通过人工配置方式进行。 我们希望能够将 Spark App 任务的解析做个补充，完善血缘逻辑。</p>

<p>目前线上的 Spark App 任务支持 Spark 2.3、 Spark 3.1 两个版本， 并且支持 python2/3、 java、scala 类型， 运行平台各自支持 yarn 和 k8s, 血缘的收集机制需要考虑适配所有上述所有任务。</p>

<h1 id="">二、思路</h1>

<p>可以想到的 Spark App 任务的解析思路有以下三类：</p>

<ul>
<li>基于代码解析： 通过解析 Spark App 的逻辑去达到血缘解析的目的， 类似的产品有 SPROV[1]</li>
<li>基于动态监听： 通过修改代码达到运行时收集血缘的目的的 Titian [2] 和 Pebble [3] , 或者通过插件方式在运行时收集血缘的 Spline [4] 和 Apache Atlas [5]</li>
<li>基于日志解析： 通过分析例如 Spark App 的 event log 信息，然后解析出任务的血缘</li>
</ul>

<p>因为 Spark App 的写法多样， 基于代码的解析需要考虑java、python、 scala， 显得过于复杂， 我们首先考虑了基于日志的分析。 通过分析 spark3 和 spark2 的任务的历史 event log 发现， spark2 的 event log 没有完整的 hive表 相关的元信息， 而 spark3 则在各种读取算子例如 FileSourceScanExec 和 HiveTableScan 的基础上打印出了 hive 表元信息， 所以基于 event log 方式不能完美支持 spark2 。</p>

<p>所以基于此我们最终打算采用基于动态监听的方式，并且调研了 spline, 进行了可用性分析。 下面介绍下 spline 的使用和设计原理。</p>

<h1 id="spline">三、spline</h1>

<h2 id="31spline">3.1 spline 原理</h2>

<p>spline （Spark Lineage）是一个免费基于 Apache 2.0 协议开源的  Spark 血缘收集系统。该系统主要分为三部分： spline agent、  spline server 和 spline ui。 </p>

<p>这里主要介绍 spline agent 的原理， 因为这是负责血缘解析的部分， 至于 spline server 和 ui 就负责血缘的收集和展示，可以用内部的系统替换。</p>

<p>总架构图如下图所示：</p>

<p><img src="https://tech.youzan.com/content/images/2022/09/01.png" alt="spline"></p>

<h3 id="311">3.1.1 初始化</h3>

<p>spline支持两种初始化方式，codeless  和 programmatic。本质上都是注册一个  <code>QueryExecutionListener</code>， 负责监听 <code>SparkListenerSQLExecutionEnd</code> 消息。</p>

<h5 id="codeless">codeless 初始化</h5>

<p>codeless init 就是通过配置化方式嵌入用户的 Spark APP 程序， 而不需要修改代码。通过注册 QueryExecutionListener 监听器，可以接收并且处理 Spark 的消息。 启动配置例如：</p>

<pre><code class="language-shell">spark-submit  --jars /path/to/lineage/spark-3.1-spline-agent-bundle_2.12-1.0.0-SNAPSHOT.jar --files /path/to/lineage/spline.properties --num-executors 2 --executor-memory 1G --driver-memory 1G --name test_lineage --deploy-mode cluster --conf spark.spline.mode=BEST_EFFORT --conf spark.spline.lineageDispatcher.http.producer.url=http://172.18.221.156:8080/producer --conf "spark.sql.queryExecutionListeners=za.co.absa.spline.harvester.listener.SplineQueryExecutionListener"  test.py  
</code></pre>

<p>通过 --jars 执行 spline agent jar 包地址， 也可以默认放到 spark 部署的 jars 目录下</p>

<p>通过 --files 指定 spline properties 文件， 也可以直接通过 --conf 指定配置项，配置项需要额外加上 spark. 前缀</p>

<p>通过  --conf "spark.sql.queryExecutionListeners=za.co.absa.spline.harvester.listener.SplineQueryExecutionListener" 可以注册监听器</p>

<h5 id="programmatic">programmatic 初始化</h5>

<p>programmatic init 需要在代码中显示的开启血缘解析， 例如</p>

<ul>
<li>scala demo</li>
</ul>

<pre><code class="language-scala">// given a Spark session ...
val sparkSession: SparkSession = ???

// ... enable data lineage tracking with Spline
import za.co.absa.spline.harvester.SparkLineageInitializer._  
sparkSession.enableLineageTracking()  
</code></pre>

<ul>
<li>java demo</li>
</ul>

<pre><code class="language-java">import za.co.absa.spline.harvester.SparkLineageInitializer;  
// ...
SparkLineageInitializer.enableLineageTracking(session);  
</code></pre>

<ul>
<li>python demo</li>
</ul>

<pre><code class="language-python">from pyspark.sql import SparkSession  
from pyspark.sql import functions as F

spark = SparkSession.builder \  
    .appName("spline_app")\
    .config("spark.jars", "dbfs:/path_where_the_jar_is_uploaded")\
    .getOrCreate()

sc = spark.sparkContext  
sc.setSystemProperty("spline.mode", "REQUIRED")

jvm = sc._jvm  
sc._jvm.za.co.absa.spline.harvester.SparkLineageInitializer.enableLineageTracking(spark._jsparkSession)  
</code></pre>

<h3 id="312">3.1.2 血缘解析</h3>

<p>血缘解析逻辑在 <code>SplineAgent.handle()</code> 方法。通过调用 <code>LineageHarvester.harvest()</code> 获取最终的血缘， 并交给 <code>LineageDispatcher</code> 输出结果。</p>

<p>通过 <code>SparkListenerSQLExecutionEnd</code> 消息可以获取到消息中的 <code>QueryExecution</code>, 血缘解析基于 <code>QueryExecution</code> 中的 analyzed logical plan 和 executedPlan 进行， <code>LineageHarvester.harvest()</code> 逻辑处理如下：</p>

<p><img src="https://tech.youzan.com/content/images/2022/09/02.png" alt="image-20220830170308300"></p>

<ol>
<li><p><code>tryExtractWriteCommand (logicalPlan)</code> 负责解析出 logicalPlan 中的写操作。 写操作的解析依托于插件方式。</p>

<p>通过获取 <code>PluginRegistry</code>  中  <code>WriteNodeProcessing</code>  类型的插件， 获取 logicalPlan 中的写操作，通过对具体的 Command 的解析，可以获取到例如 hive 表的 表名信息。最后信息会封装为 <code>WriteCommand</code> 数据结构。</p>

<p>例如 <code>DataSourceV2Plugin.writeNodeProcessor()</code> 会负责 <code>V2WriteCommand</code>、<code>CreateTableAsSelect</code>、<code>ReplaceTableAsSelect</code>这几个命令的解析。</p>

<p>解析插件可以自己扩展，丰富 spline 解析的数据源， 插件需要继承 <code>za.co.absa.spline.harvester.plugin.Plugin</code>,  spline agent 会在启动的时候自动加载 classpath 中的所有插件。</p></li>
<li><p>解析到 writeCommand 以后会基于 writeCommand 中的 query 字段解析读操作。读操作基于 query 这部分 logicalPlan 进行递归解析</p>

<p>最后解析完成可以得到 plan 和 event 两个 json 信息，plan 为血缘关系， event 为额外的辅助信息。</p></li>
</ol>

<p>例如：</p>

<pre><code class="language-json">[
  "plan",
  {
    "id": "acd5157c-ddc5-5ef0-b1bc-06bb8dcda841",
    "name": "team evaluation ranks",
    "operations": {
      "write": {
        "outputSource": "hdfs:///user/hive/warehouse/dm_ai.db/dws_kdt_comment_ranks_info",
        "append": false,
        "id": "op-0",
        "name": "CreateDataSourceTableAsSelectCommand",
        "childIds": [
          "op-1"
        ],
        "params": {
          "table": {
            "identifier": {
              "table": "dws_kdt_comment_ranks_info",
              "database": "dm_ai"
            },
            "storage": "Storage()"
          }
        },
        "extra": {
          "destinationType": "orc"
        }
      },
      "reads": [
        {
          "inputSources": [
            "hdfs://yz-cluster-qa/user/hive/warehouse/dm_ai.db/dws_kdt_comment_rank_base"
          ],
          "id": "op-6",
          "name": "LogicalRelation",
          "output": [
            "attr-0",
            "attr-1",
            "attr-2",
            "attr-3",
            "attr-4",
            "attr-5",
            "attr-6",
            "attr-7",
            "attr-8",
            "attr-9",
            "attr-10",
            "attr-11",
            "attr-12"
          ],
          "params": {
            "table": {
              "identifier": {
                "table": "dws_kdt_comment_rank_base",
                "database": "dm_ai"
              },
              "storage": "Storage(Location: hdfs://yz-cluster-qa/user/hive/warehouse/dm_ai.db/dws_kdt_comment_rank_base, Serde Library: org.apache.hadoop.hive.ql.io.orc.OrcSerde, InputFormat: org.apache.hadoop.hive.ql.io.orc.OrcInputFormat, OutputFormat: org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat, Storage Properties: [serialization.format=1])"
            }
          },
          "extra": {
            "sourceType": "hive"
          }
        }
      ],
      "other": [
        {
          "id": "op-5",
          "name": "SubqueryAlias",
          "childIds": [
            "op-6"
          ],
          "output": [
            "attr-0",
            "attr-1",
            "attr-2",
            "attr-3",
            "attr-4",
            "attr-5",
            "attr-6",
            "attr-7",
            "attr-8",
            "attr-9",
            "attr-10",
            "attr-11",
            "attr-12"
          ],
          "params": {
            "identifier": "spark_catalog.dm_ai.dws_kdt_comment_rank_base"
          }
        },
        {
          "id": "op-4",
          "name": "Filter",
          "childIds": [
            "op-5"
          ],
          "output": [
            "attr-0",
            "attr-1",
            "attr-2",
            "attr-3",
            "attr-4",
            "attr-5",
            "attr-6",
            "attr-7",
            "attr-8",
            "attr-9",
            "attr-10",
            "attr-11",
            "attr-12"
          ],
          "params": {
            "condition": {
              "__exprId": "expr-0"
            }
          }
        },
        {
          "id": "op-3",
          "name": "Project",
          "childIds": [
            "op-4"
          ],
          "output": [
            "attr-0",
            "attr-1",
            "attr-2",
            "attr-3",
            "attr-4",
            "attr-5",
            "attr-6",
            "attr-7",
            "attr-8",
            "attr-9",
            "attr-10",
            "attr-11",
            "attr-12"
          ],
          "params": {
            "projectList": [
              {
                "__attrId": "attr-0"
              },
              {
                "__attrId": "attr-1"
              },
              {
                "__attrId": "attr-2"
              },
              {
                "__attrId": "attr-3"
              },
              {
                "__attrId": "attr-4"
              },
              {
                "__attrId": "attr-5"
              },
              {
                "__attrId": "attr-6"
              },
              {
                "__attrId": "attr-7"
              },
              {
                "__attrId": "attr-8"
              },
              {
                "__attrId": "attr-9"
              },
              {
                "__attrId": "attr-10"
              },
              {
                "__attrId": "attr-11"
              },
              {
                "__attrId": "attr-12"
              }
            ]
          }
        },
        {
          "id": "op-2",
          "name": "Project",
          "childIds": [
            "op-3"
          ],
          "output": [
            "attr-0",
            "attr-1",
            "attr-2",
            "attr-3",
            "attr-4",
            "attr-5",
            "attr-6",
            "attr-7",
            "attr-8",
            "attr-9",
            "attr-10",
            "attr-11",
            "attr-12",
            "attr-13"
          ],
          "params": {
            "projectList": [
              {
                "__attrId": "attr-0"
              },
              {
                "__attrId": "attr-1"
              },
              {
                "__attrId": "attr-2"
              },
              {
                "__attrId": "attr-3"
              },
              {
                "__attrId": "attr-4"
              },
              {
                "__attrId": "attr-5"
              },
              {
                "__attrId": "attr-6"
              },
              {
                "__attrId": "attr-7"
              },
              {
                "__attrId": "attr-8"
              },
              {
                "__attrId": "attr-9"
              },
              {
                "__attrId": "attr-10"
              },
              {
                "__attrId": "attr-11"
              },
              {
                "__attrId": "attr-12"
              },
              {
                "__exprId": "expr-7"
              }
            ]
          }
        },
        {
          "id": "op-1",
          "name": "Project",
          "childIds": [
            "op-2"
          ],
          "output": [
            "attr-13"
          ],
          "params": {
            "projectList": [
              {
                "__attrId": "attr-13"
              }
            ]
          }
        }
      ]
    },
    "attributes": [
      {
        "id": "attr-0",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "id"
      },
      {
        "id": "attr-1",
        "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
        "name": "content"
      },
      {
        "id": "attr-2",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "goods_id"
      },
      {
        "id": "attr-3",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "kdt_id"
      },
      {
        "id": "attr-4",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "score"
      },
      {
        "id": "attr-5",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "group_id"
      },
      {
        "id": "attr-6",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "score_level"
      },
      {
        "id": "attr-7",
        "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
        "name": "created_at"
      },
      {
        "id": "attr-8",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "final_score"
      },
      {
        "id": "attr-9",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "comment_rerank_score"
      },
      {
        "id": "attr-10",
        "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
        "name": "updated_at"
      },
      {
        "id": "attr-11",
        "dataType": "e63adadc-648a-56a0-9424-3289858cf0bb",
        "name": "comment_origin_score"
      },
      {
        "id": "attr-12",
        "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
        "name": "par"
      },
      {
        "id": "attr-13",
        "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
        "childRefs": [
          {
            "__exprId": "expr-7"
          }
        ],
        "name": "comment_info"
      }
    ],
    "expressions": {
      "functions": [
        {
          "id": "expr-2",
          "dataType": "ab4da308-91fb-550a-a5e4-beddecff2a2b",
          "childRefs": [
            {
              "__attrId": "attr-12"
            }
          ],
          "extra": {
            "simpleClassName": "Cast",
            "_typeHint": "expr.Generic"
          },
          "name": "cast",
          "params": {
            "timeZoneId": "Asia/Shanghai"
          }
        },
        {
          "id": "expr-1",
          "dataType": "a155e715-56ab-59c4-a94b-ed1851a6984a",
          "childRefs": [
            {
              "__exprId": "expr-2"
            },
            {
              "__exprId": "expr-3"
            }
          ],
          "extra": {
            "simpleClassName": "EqualTo",
            "_typeHint": "expr.Binary",
            "symbol": "="
          },
          "name": "equalto"
        },
        {
          "id": "expr-5",
          "dataType": "ba7ef708-332f-54fd-a671-c91d13ae6f8e",
          "childRefs": [
            {
              "__exprId": "expr-6"
            }
          ],
          "extra": {
            "simpleClassName": "Cast",
            "_typeHint": "expr.Generic"
          },
          "name": "cast",
          "params": {
            "timeZoneId": "Asia/Shanghai"
          }
        },
        {
          "id": "expr-4",
          "dataType": "a155e715-56ab-59c4-a94b-ed1851a6984a",
          "childRefs": [
            {
              "__attrId": "attr-11"
            },
            {
              "__exprId": "expr-5"
            }
          ],
          "extra": {
            "simpleClassName": "GreaterThanOrEqual",
            "_typeHint": "expr.Binary",
            "symbol": "&gt;="
          },
          "name": "greaterthanorequal"
        },
        {
          "id": "expr-0",
          "dataType": "a155e715-56ab-59c4-a94b-ed1851a6984a",
          "childRefs": [
            {
              "__exprId": "expr-1"
            },
            {
              "__exprId": "expr-4"
            }
          ],
          "extra": {
            "simpleClassName": "And",
            "_typeHint": "expr.Binary",
            "symbol": "&amp;&amp;"
          },
          "name": "and"
        },
        {
          "id": "expr-8",
          "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
          "childRefs": [
            {
              "__attrId": "attr-3"
            },
            {
              "__attrId": "attr-0"
            },
            {
              "__attrId": "attr-8"
            }
          ],
          "extra": {
            "simpleClassName": "PythonUDF",
            "_typeHint": "expr.Generic"
          },
          "name": "pythonudf",
          "params": {
            "name": "fun_one",
            "evalType": 100,
            "func": "PythonFunction(WrappedArray(...),{PYTHONPATH={{PWD}}/pyspark.zip&lt;CPS&gt;{{PWD}}/py4j-0.10.9-src.zip, PYTHONHASHSEED=0},[],/data/venv/hdp-envpy3/bin/python,3.6,[],PythonAccumulatorV2(id: 0, name: None, value: []))",
            "udfDeterministic": true
          }
        },
        {
          "id": "expr-7",
          "dataType": "75fe27b9-9a00-5c7d-966f-33ba32333133",
          "childRefs": [
            {
              "__exprId": "expr-8"
            }
          ],
          "extra": {
            "simpleClassName": "Alias",
            "_typeHint": "expr.Alias"
          },
          "name": "comment_info",
          "params": {
            "name": "comment_info",
            "nonInheritableMetadataKeys": [
              "__dataset_id",
              "__col_position"
            ],
            "explicitMetadata": "{}"
          }
        }
      ],
      "constants": [
        {
          "id": "expr-3",
          "dataType": "455d9d5b-7620-529e-840b-897cee45e560",
          "extra": {
            "simpleClassName": "Literal",
            "_typeHint": "expr.Literal"
          },
          "value": 20220830
        },
        {
          "id": "expr-6",
          "dataType": "455d9d5b-7620-529e-840b-897cee45e560",
          "extra": {
            "simpleClassName": "Literal",
            "_typeHint": "expr.Literal"
          },
          "value": 80
        }
      ]
    },
    "systemInfo": {
      "name": "spark",
      "version": "3.1.2-yz-1.4"
    },
    "agentInfo": {
      "name": "spline",
      "version": "1.0.0-SNAPSHOT+874577a"
    },
    "extraInfo": {
      "appName": "team evaluation ranks",
      "dataTypes": [
        {
          "_typeHint": "dt.Simple",
          "id": "e63adadc-648a-56a0-9424-3289858cf0bb",
          "name": "bigint",
          "nullable": true
        },
        {
          "_typeHint": "dt.Simple",
          "id": "75fe27b9-9a00-5c7d-966f-33ba32333133",
          "name": "string",
          "nullable": true
        },
        {
          "_typeHint": "dt.Simple",
          "id": "a155e715-56ab-59c4-a94b-ed1851a6984a",
          "name": "boolean",
          "nullable": true
        },
        {
          "_typeHint": "dt.Simple",
          "id": "ab4da308-91fb-550a-a5e4-beddecff2a2b",
          "name": "int",
          "nullable": true
        },
        {
          "_typeHint": "dt.Simple",
          "id": "455d9d5b-7620-529e-840b-897cee45e560",
          "name": "int",
          "nullable": false
        },
        {
          "_typeHint": "dt.Simple",
          "id": "ba7ef708-332f-54fd-a671-c91d13ae6f8e",
          "name": "bigint",
          "nullable": false
        }
      ]
    }
  }
]
</code></pre>

<pre><code class="language-json">[
  "event",
  {
    "planId": "acd5157c-ddc5-5ef0-b1bc-06bb8dcda841",
    "timestamp": 1661960765255,
    "durationNs": 4353094937,
    "extra": {
      "appId": "application_1656468332243_128755",
      "user": "app",
      "readMetrics": {
        "numFiles": 1,
        "scanTime": 995,
        "pruningTime": 0,
        "metadataTime": 161,
        "filesSize": 87538,
        "numOutputRows": 7269,
        "numPartitions": 1
      },
      "writeMetrics": {
        "numFiles": 1,
        "numOutputBytes": 48430,
        "numOutputRows": 6785,
        "numParts": 0
      }
    }
  }
]
</code></pre>

<h3 id="313">3.1.3 血缘分发</h3>

<p>LineageDispatcher 决定血缘如何发送， 而内置的 Dispatcher 的实现也是一目了然的。 <br>
例如：HttpLineageDispatcher 就是将血缘发送给一个 HTTP 接口， KafkaLineageDispatcher 就是发给一个 Kafka topic, LoggingLineageDispatcher 就是将血缘打印在 Spark APP 的 stderr 日志里， 方便调试确认。</p>

<p>如果要自定义dispatcher, 可以自己继承 LineageDispatcher, 并且提供一个入参为 <code>org.apache.commons.configuration.Configuration</code> 的构造函数。
配置如下：</p>

<pre><code class="language-shell">spline.lineageDispatcher=my-dispatcher  
spline.lineageDispatcher.my-dispatcher.className=org.example.spline.MyDispatcherImpl  
spline.lineageDispatcher.my-dispatcher.prop1=value1  
spline.lineageDispatcher.my-dispatcher.prop2=value2  
</code></pre>

<h3 id="314">3.1.4 后置处理</h3>

<p>post processing filter 可以在血缘解析完成后，交给dispatcher前进行一些后置处理，例如脱敏 。 实现一个 filter 需要实现 <code>za.co.absa.spline.harvester.postprocessing.PostProcessingFilter</code>， 构造器接受 一个类型为 <code>org.apache.commons.configuration.Configuration</code> 的入参。</p>

<p>配置方式如下:</p>

<pre><code class="language-shell ">spline.postProcessingFilter=my-filter  
spline.postProcessingFilter.my-filter.className=my.awesome.CustomFilter  
spline.postProcessingFilter.my-filter.prop1=value1  
spline.postProcessingFilter.my-filter.prop2=value2  
</code></pre>

<h2 id="32">3.2 版本关系</h2>

<p><img src="https://tech.youzan.com/content/images/2022/09/03-1.png" alt=""></p>

<p>备注： 但是 pyspark 2.3 如果要支持 codeless init ， 需要打个 patch <a href="https://issues.apache.org/jira/browse/SPARK-23228">SPARK-23228</a>， 相关问题可以参考这个 ISSUE （ <a href="https://github.com/AbsaOSS/spline-spark-agent/issues/490">https://github.com/AbsaOSS/spline-spark-agent/issues/490</a> ）。</p>

<h2 id="33spline">3.3 spline 集成</h2>

<h3 id="331spline">3.3.1 集成 spline</h3>

<p>编译 对应 spark 与 scala 版本的 spline-agent jar包。</p>

<p>如 spark 3.1</p>

<pre><code>mvn scala-cross-build:change-version -Pscala-2.12  
mvn clean install -Pscala-2.12,spark-3.1 -DskipTests=True  
</code></pre>

<p>将 spark agent jar包部署在 /path/to/spark/jars 目录下。</p>

<p>配置 spark-defaults.conf</p>

<pre><code>spark.sql.queryExecutionListeners=za.co.absa.spline.harvester.listener.SplineQueryExecutionListener  
spark.spline.mode=BEST_EFFORT  
spark.spline.lineageDispatcher=composite  
spark.spline.lineageDispatcher.composite.dispatchers=logging,http  
spark.spline.lineageDispatcher.http.producer.url=http://[ip:port]/producer  
</code></pre>

<h3 id="332">3.3.2 血缘收集系统</h3>

<p>与现有系统集成要适当修改代码，在最后的 event 消息中添加该 Spark APP 对应的工作流或者任务名称， 将血缘和任务信息发给自定义的 HTTP server， 解析血缘上报 kafka, 统一消费处理。</p>

<h2 id="34spline">3.4 spline 演示</h2>

<p>通过docker-compose 可以一键启动 spline server 端。 可以通过 spline ui 看到解析出来的血缘。</p>

<pre><code class="language-text">1. wget https://raw.githubusercontent.com/AbsaOSS/spline-getting-started/main/docker/docker-compose.yml  
2. wget https://raw.githubusercontent.com/AbsaOSS/spline-getting-started/main/docker/.env  
3. export DOCKER_HOST_EXTERNAL=yourhostip // spline ui访问server默认地址127.0.0.1, 不修改无法外部访问  
4. docker-compose up  
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2022/09/03.png" alt="03"></p>

<h1 id="">四、总结</h1>

<p>spline agent 能够无感知的为线上运行的 Spark APP 程序增加血缘解析， 是个很不错的思路， 可以基于这个方向进行进一步的研究优化。目前 spline agent 有一些无法处理的逻辑，如下所示： <br>
1. 无法解析到 RDD 中的来源逻辑， 如果 dataframe 转换为 RDD 进行操作，则无法追踪到这之后的血缘。这跟 spline 解析的时候通过 logicalPlan 中的 child 关系进行递归有关， 遇到 LogicalRDD  递归结束。可能的解决方案是遇到 LogicalRDD 算子的时候通过解析 RDD的 dependency 关系查找血缘信息。 <br>
2. 血缘解析基于写入触发， 所以如果任务只做查询是解析不到血缘的</p>

<h1 id="">五、参考资料</h1>

<ol>
<li><a href="https://www.sigsac.org/ccs/CCS2009/pd/abstract_38.pdf">SPROV 2.0</a>  </li>
<li><a href="https://github.com/UCLA-SEAL/Titian">TiTian</a>: <a href="http://web.cs.ucla.edu/~todd/research/vldb16.pdf">http://web.cs.ucla.edu/~todd/research/vldb16.pdf</a>  </li>
<li><a href="https://dl.acm.org/doi/10.1145/3299869.3320225">Pebble</a>  </li>
<li><a href="https://absaoss.github.io/spline/">Spline</a>  </li>
<li><a href="https://docs.cloudera.com/runtime/7.2.2/atlas-reference/topics/atlas-spark-entities.html">Atlas</a>  </li>
<li><a href="https://github.com/AbsaOSS/spline-spark-agent">spline-spark-agent github</a>  </li>
<li><a href="https://link.springer.com/article/10.1007/s13222-021-00387-7">Collecting and visualizing data lineage of Spark jobs</a></li>
</ol>]]></content:encoded></item><item><title><![CDATA[浅谈有赞搜索QP架构设计]]></title><description><![CDATA[<h1 id="">一、有赞搜索平台整体设计</h1>

<p>&emsp;&emsp;在介绍QP前先简单介绍一下搜索平台的整体结构，方便大家快速了解QP在搜索平台中的作用。下图简单展示了一个搜索请求开始到结束的全部流程。业务通过简洁的api接入los，管理员在搜索平台新建配置并下发，完成整个搜索接入，并通过abtest验证QP带来的优化效果。   </p>

<p><img src="https://tech.youzan.com/content/images/2022/09/1-----.jpeg" alt="image"></p>

<h1 id="qp">二、QP的作用</h1>

<p>&emsp;&emsp; 在NLP中，QP被称作Query理解（QueryParser），简单来说就是从词法、句法、语义三个层面对query进行结构化解析。这里query从广义上来说涉及的任务比较多，最常见的就是搜索系统中输入的查询词，也可以是FAQ问答或阅读理解中的问句，又或者可以是人机对话中用户的聊天输入。 <br>
&emsp;&emsp;在有赞，QP系统专注对查询内容进行结构化解析，整合了有赞NLP能力，提供统一对外接口，与业务逻辑解耦。通过配置化快速满足业务接入需求，同时将算法能力插件化，并支持人工干预插件执行结果。 <br>
&emsp;&emsp;以精选搜索为例，当用户输入衣服时用户往往想要搜的是衣服类商品，而不是衣服架，衣服配饰等衣服周边用品。通过将衣服类目进行加权，将衣服类的商品排在靠前的位置，优化用户搜索体验。
<img src="https://tech.youzan.com/content/images/2022/09/-----2.jpg" alt="image">
&emsp;&emsp;QP目前应用在新零售，微商城、精选、爱逛买手店、</p>]]></description><link>https://tech.youzan.com/11/</link><guid isPermaLink="false">a84e4163-d24f-40c5-9bed-a5099ad15ecb</guid><category><![CDATA[大数据]]></category><category><![CDATA[搜索]]></category><category><![CDATA[大数据]]></category><dc:creator><![CDATA[wuxinqiang]]></dc:creator><pubDate>Mon, 05 Sep 2022 05:27:28 GMT</pubDate><content:encoded><![CDATA[<h1 id="">一、有赞搜索平台整体设计</h1>

<p>&emsp;&emsp;在介绍QP前先简单介绍一下搜索平台的整体结构，方便大家快速了解QP在搜索平台中的作用。下图简单展示了一个搜索请求开始到结束的全部流程。业务通过简洁的api接入los，管理员在搜索平台新建配置并下发，完成整个搜索接入，并通过abtest验证QP带来的优化效果。   </p>

<p><img src="https://tech.youzan.com/content/images/2022/09/1-----.jpeg" alt="image"></p>

<h1 id="qp">二、QP的作用</h1>

<p>&emsp;&emsp; 在NLP中，QP被称作Query理解（QueryParser），简单来说就是从词法、句法、语义三个层面对query进行结构化解析。这里query从广义上来说涉及的任务比较多，最常见的就是搜索系统中输入的查询词，也可以是FAQ问答或阅读理解中的问句，又或者可以是人机对话中用户的聊天输入。 <br>
&emsp;&emsp;在有赞，QP系统专注对查询内容进行结构化解析，整合了有赞NLP能力，提供统一对外接口，与业务逻辑解耦。通过配置化快速满足业务接入需求，同时将算法能力插件化，并支持人工干预插件执行结果。 <br>
&emsp;&emsp;以精选搜索为例，当用户输入衣服时用户往往想要搜的是衣服类商品，而不是衣服架，衣服配饰等衣服周边用品。通过将衣服类目进行加权，将衣服类的商品排在靠前的位置，优化用户搜索体验。
<img src="https://tech.youzan.com/content/images/2022/09/-----2.jpg" alt="image">
&emsp;&emsp;QP目前应用在新零售，微商城、精选、爱逛买手店、分销市场、帮助中心知识库、官网搜索等场景，通过类目加权，产品词识别，搜索词纠错，同近义词召回提升用户搜索效果。</p>

<h1 id="qp">三、QP应用整体设计</h1>

<p><img src="https://tech.youzan.com/content/images/2022/09/3-QP------.jpeg" alt="image">
&emsp;&emsp;上图完整描述了QP请求流程和配置流程的执行情况。当搜索请求到达QP时，根据请求体中的场景标记获取QP配置。QP配置中包含搜索词位置标记，插件列表，dsl改写脚本等内容。 <br>
&emsp;&emsp;QP根据配置，按序执行相应插件。插件在执行后，可通过干预配置以及超参数对结果进行人工干预。 <br>
&emsp;&emsp;QP在获取到算法插件执行结果后，根据改写配置，对搜索dsl进行改写。如将纠错词放置在搜索词同一层级，将dsl改写成fuction score结构进行类目加权。</p>

<h1 id="qp">四、QP应用分层设计</h1>

<p><img src="https://tech.youzan.com/content/images/2022/09/4-QP------.jpeg" alt="image">
上图按照请求流程从上到下展示了QP的分层设计，接下来将简单描述各层作用： <br>
<strong>controller层</strong>：查询改写服务入口，对请求做预处理。 <br>
<strong>service层</strong>：根据场景获取QP改写配置，获取dsl里的搜索词，调用相应的插件返回qp结果。 <br>
<strong>plugin层</strong>：负责算法插件执行，调用插件对应的算法实现handler，对算法结果做干预并针对调用成功或者失败做处理。 <br>
<strong>handler层</strong>：算法具体实现放置在该层，该层会依赖各种算法服务（如小盒子，Milvus等）。 <br>
<strong>Intervener层</strong>：负责对handler结果做人工干预。 <br>
<strong>processor层</strong>：根据QP改写配置，调用改写插件，完成dsl的改写。</p>

<h2 id="qp">五、QP算法插件设计</h2>

<h3 id="51preprocess">5.1 预处理Preprocess插件</h3>

<p>&emsp;&emsp;按照配置规则对搜索词进行预处理，预处理方式如下： <br>
&emsp;&emsp;* 删除特殊符号 " “ \ 等； <br>
&emsp;&emsp;* 大写转小写，全角转半角； <br>
&emsp;&emsp;* 连续英文联合切分，连续数字联合切分，其余单独切分； <br>
&emsp;&emsp;* 默认截取list前50个字/词； <br>
&emsp;&emsp;* 将list拼接成一个字符串。  </p>

<h4 id="">样例</h4>

<pre><code>输入："史蒂夫新款\时尚套装夏修身圆领百搭钩花DWF镂空雪纺两件套套裙；"
输出："史蒂夫新款时尚套装夏修身圆领百搭钩花dwf镂空雪纺两件套套裙"
</code></pre>

<h3 id="52correction">5.2 纠错Correction插件</h3>

<p>&emsp;&emsp;纠错插件的作用是对搜索词中错误内容进行识别，返回正确内容。</p>

<h4 id="">样例</h4>

<pre><code>输入：[上海牛黄皂]
输出：[上海硫磺皂]
</code></pre>

<p>&emsp;&emsp;当用户输入“上海牛黄皂”时，通过纠错插件能正确输出“上海硫磺皂”，其技术架构如下图所示。
<img src="https://tech.youzan.com/content/images/2022/09/5-----.jpeg" alt="image">
1、纠错模型在bert基础上采用知识蒸馏，提升模型精度降低模型时延。 <br>
2、根据同音字召回候选集，使用tri-gram语言模型对候选集排序。</p>

<h3 id="53tokenizer">5.3 细粒度分词Tokenizer插件（基础分词）</h3>

<h4 id="">样例</h4>

<pre><code>输入：[雪地靴女2020年新款皮毛一体冬季加绒加厚防滑东北厚底保暖棉鞋子]
输出：[雪地 靴 女 2020 年 新款 皮毛 一体 冬季 加绒 加厚 防滑 东北 厚底 保暖 棉 鞋子]
</code></pre>

<p>&emsp;&emsp;该分词插件由Java版结巴 jieba-analysis 修改而来，修改内容如下： <br>
&emsp;&emsp;*   从全网商品标题数据，有赞行业数据，开源数据中统计出词频，作为基础分词词典； <br>
&emsp;&emsp;*   解决词典中由英文单词导致英文字符串被分开的问题； <br>
&emsp;&emsp;*   限制DAG的长度，即匹配词的长度，以此控制分词粒度，目前默认是2，分出来的词除英文和数字外，长度不会超过2。</p>

<h3 id="54sementicsegment">5.4 语义分词sementicSegment插件</h3>

<h4 id="">样例</h4>

<pre><code>输入：[雪地 靴 女 2020 年 新款 皮毛 一体 冬季 加绒 加厚 防滑 东北 厚底 保暖 棉 鞋子]
输出：[雪地靴 女 2020年 新款 皮毛一体 冬季 加绒加厚 防滑 东北 厚底 保暖 棉鞋子]
</code></pre>

<p>&emsp;&emsp;该插件在细粒度分词的基础上，通过模型生成语义树将关联度大的词列表进行合并，输出语义分词结果。在样例中，雪地与靴关联度更大，所以在语义分词中将雪地与靴合并输出。</p>

<h3 id="55tagging">5.5 实体识别Tagging插件</h3>

<h4 id="">样例</h4>

<pre><code>输入：["汽车","脚垫","刷子"]
输出：[{"word":"汽车","tag":"产品修饰词"},{"word":"脚垫","tag":"产品修饰词"},{"word":"刷子","tag":"产品词"}]
</code></pre>

<p>&emsp;&emsp;实体识别插件主要用于识别出搜索内容中的产品词。比如用户在输入“汽车脚垫刷子”时，如果没有做产品词识别，“脚垫”相关的商品会因为商品分高而排在“刷子”商品前面，影响用户搜索体验。 <br>
&emsp;&emsp;反之，经过命名实体识别，对“刷子”做产品词提权，刷子类商品就可以排在脚垫类商品前面，优化搜索体验。 <br>
&emsp;&emsp;目前有赞规划的实体类别列表如下所示：</p>

<pre><code>产品词 eg：“修身连衣裙”中的“连衣裙”
产品修饰词 eg：“汽车脚垫”中的“汽车”
普通词
新词
修饰
品牌
机构实体
地点地域
材质
人名
功能功效
专有名词
影视名称
型号
文娱书文曲
系列
游戏名称
款式元素
颜色
场景
风格
营销服务
人群
时间季节
性别
类目
母婴
规格
新品
前缀
后缀
数字
符号
</code></pre>

<h4 id="">实体识别方法</h4>

<p>&emsp;&emsp;<strong>基于正则</strong>可以识别数字、符号、规格、时间季节。</p>

<pre><code>// 数字
private numWordRegex = "[0-9]+";  
// 符号
private String symbolWordRegex = "[\\[\\]\\{\\}【】「」\\\\\\|、｜‘'\"“”’；：;:&gt;.。》/？\\?&lt;《，,~`·！\\!@#\\$¥%\\^…&amp;\\*（\\(\\)）\\-_—=\\+\\s]+";  
// 规格
private String unitRegex = "(?:\\d+|\\d+\\.\\d+|[一二三四五六七八九十百千万]+)\\s*(?:m|米|cm|厘米|ml|毫升|l|升|度|平米|件|块|元|片|张|本|条|瓶|部|辆|个|桶|包|盒|g|克|kg|千克|吨|寸|斤)";  
// 时间季节
private String seasonRegex = "[春夏秋冬]+[季天]?";  
private String yearRegex = "(?:18|19|20)\\d{2}";  
</code></pre>

<p>&emsp;&emsp;<strong>基于词库</strong>可以识别产品词、品牌、产品修饰词。  </p>

<pre><code>品牌：query-&gt;二级类目-&gt;品牌，条件：在当前类目品牌词库里且模型预测不是“产品词”，此时打“品牌”实体标。    
产品词：在产品词库且模型预测是普通词。   
产品修饰词：多个词出现时，除最后一个，其余打“产品修饰词”实体标。  
</code></pre>

<p>&emsp;&emsp;<strong>基于模型</strong>可以识别剩余23类实体，类别如下所示：</p>

<pre><code>产品词
普通词
新词
修饰
品牌
机构实体
地点地域
材质
人名
功能功效
专有名词
影视名称
型号
文娱书文曲
系列
游戏名称
款式元素
颜色
场景
风格
营销服务
人群
时间季节
</code></pre>

<p>&emsp;&emsp;模型结构：
<img src="https://tech.youzan.com/content/images/2022/08/6-----.png" alt="image"></p>

<h3 id="56categorypredict">5.6 类目预测categoryPredict插件</h3>

<h4 id="">样例</h4>

<pre><code>输入：牛奶绒
输出: {
        "categoryId": "101000010001",
        "categoryName": "被套",
        "categoryChainList": [
            "家居建材",
            "床上用品",
            "被套"
        ],
        "parentCategoryId": "10100001",
        "level": 3,
        "hasChildren": true,
        "percent": 0.9010684490203857
    }
</code></pre>

<p>该插件会根据用户的搜索内容输出类目结果，主要应用在类目加权上。 <br>
例如当用户在有赞精选上输入牛奶绒，期望返回牛奶绒床单。 <br>
未使用类目加权，返回的商品大多为牛奶相关产品，不符合用户的搜索期望。 <br>
使用类目加权后，将床上用品类产品提权，返回的商品牛奶绒床单，符合用户期望。 <br>
类目预测模型是在对比学习基础上实现，具体内容可看<a href="http://tech.youzan.com/dui-bi-xue-xi-zai-you-zan-d/">对比学习在有赞的应用</a>   </p>

<h3 id="57">5.7 同近义词插件</h3>

<h4 id="">样例</h4>

<pre><code>输入：[衬衣]
输出：[衬衫]
</code></pre>

<p>&emsp;&emsp;同近义词插件目前非常实现轻量，通过离线同义词表，搜索内容中的产品词作为输入，输出同义词。</p>

<h1 id="">六、总结与展望</h1>

<p>&emsp;&emsp;本文从QP整体设计，分层设计，插件设计较为完整的介绍了QP的架构设计。目前经过一年多的迭代，QP已经实现业务场景小时级接入，优化了零售，微商城，精选，爱逛，分销等场景搜索效果。后续将会继续丰富算法插件能力同时完成QP可视化配置能力方便业务自主接入。</p>

<p>本文由吴鑫强，任艳萍负责收集整理。</p>]]></content:encoded></item><item><title><![CDATA[对比学习在有赞的应用]]></title><description><![CDATA[<h2 id="1">1. 对比学习的引入</h2>

<p>一般做算法任务时，都需要搜集大量标注的数据，假如我们要预测一个商品的产品词（中心词），下面是一个商品标题：</p>

<pre><code>三亚亚龙湾玫瑰谷JESS玫瑰臻白颜透润花瓣 免洗面膜收缩毛孔
</code></pre>

<p>这个商品的产品词就是“面膜”，任务就是要把面膜识别出来，看起来是个标准的NER任务，我们也确实使用了CRF和指针网络之类的方法，对于上面这种标题效果还不错，但是由于SaaS商家的经营习惯不同于平台，很少依赖平台搜索流量，所以很多标题很简短甚至不会包含产品词，比如：</p>

<pre><code>迪奥丝绒系列760 专属女团色 蓝调正红 可盐可甜
澳优能立多3段
</code></pre>

<p>对于这种问题，NER相关的算法就无解了，模型无法在商品标题中找到合适的产品词，当然也可以认为是标题中没有产品词。但是这种模型很多时候会从标题里预测出来奇奇怪怪的结果，导致产品词太发散，业务方很难基于产品词制定规则。</p>

<p>去年我们接触到了对比学习，被OpenAI的<a href="https://openai.com/blog/clip/">CLIP: Connecting Text and Images</a>惊艳到了，效果之好，方法之简单，令人兴奋。而且微软相似的模型<a href="https://www.microsoft.com/en-us/research/blog/turing-bletchley-a-universal-image-language-representation-model-by-microsoft/">Turing Bletchley: A Universal Image Language Representation</a></p>]]></description><link>https://tech.youzan.com/dui-bi-xue-xi-zai-you-zan-d/</link><guid isPermaLink="false">9be275b5-680f-4787-a2ad-17f5fd296abc</guid><category><![CDATA[大数据]]></category><category><![CDATA[大数据]]></category><category><![CDATA[机器学习]]></category><category><![CDATA[对比学习]]></category><category><![CDATA[分布式训练]]></category><category><![CDATA[NLP]]></category><dc:creator><![CDATA[肖洋]]></dc:creator><pubDate>Tue, 05 Jul 2022 03:57:00 GMT</pubDate><media:content url="https://tech.youzan.com/content/images/2022/07/Snipaste_2022-07-05_15-58-37.png" medium="image"/><content:encoded><![CDATA[<h2 id="1">1. 对比学习的引入</h2>

<img src="https://tech.youzan.com/content/images/2022/07/Snipaste_2022-07-05_15-58-37.png" alt="对比学习在有赞的应用"><p>一般做算法任务时，都需要搜集大量标注的数据，假如我们要预测一个商品的产品词（中心词），下面是一个商品标题：</p>

<pre><code>三亚亚龙湾玫瑰谷JESS玫瑰臻白颜透润花瓣 免洗面膜收缩毛孔
</code></pre>

<p>这个商品的产品词就是“面膜”，任务就是要把面膜识别出来，看起来是个标准的NER任务，我们也确实使用了CRF和指针网络之类的方法，对于上面这种标题效果还不错，但是由于SaaS商家的经营习惯不同于平台，很少依赖平台搜索流量，所以很多标题很简短甚至不会包含产品词，比如：</p>

<pre><code>迪奥丝绒系列760 专属女团色 蓝调正红 可盐可甜
澳优能立多3段
</code></pre>

<p>对于这种问题，NER相关的算法就无解了，模型无法在商品标题中找到合适的产品词，当然也可以认为是标题中没有产品词。但是这种模型很多时候会从标题里预测出来奇奇怪怪的结果，导致产品词太发散，业务方很难基于产品词制定规则。</p>

<p>去年我们接触到了对比学习，被OpenAI的<a href="https://openai.com/blog/clip/">CLIP: Connecting Text and Images</a>惊艳到了，效果之好，方法之简单，令人兴奋。而且微软相似的模型<a href="https://www.microsoft.com/en-us/research/blog/turing-bletchley-a-universal-image-language-representation-model-by-microsoft/">Turing Bletchley: A Universal Image Language Representation model by Microsoft - Microsoft Research</a>甚至还展现了OCR的能力，再加上<a href="https://zhuanlan.zhihu.com/p/370782081">微博</a>做的文本对匹配的效果，为我们打开了新的思路。如果利用对比学习学到商品标题和产品词的表示，那只需要清理一批产品词计算向量存到向量计算引擎，需要预测的商品标题计算完表示以后做一次向量召回就会得到语义相关的产品词，就算商品标题里不含产品词也可以找出一个合理的产品词，而且产品词词库可控，将会是一个理想的解决方案。</p>

<h2 id="2">2. 对比学习的原理</h2>

<p>对比学习的思想很简单，就是学习对象的表示（向量），相关对象的表示要接近，不相关对象的表示要远离。对比学习也算是自监督学习，跟其他方法的区别可以看下图，对比学习也是有label的，但是跟监督学习不同的是，这个label不是最终任务的label。</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-32-59.png" alt="对比学习在有赞的应用"></p>

<p>谷歌的<a href="https://arxiv.org/abs/1503.03832">FaceNet</a>以及微软的<a href="https://www.microsoft.com/en-us/research/publication/learning-deep-structured-semantic-models-for-web-search-using-clickthrough-data/">DSSM</a>等模型，也是这种思想。我们以人脸识别为例，如果使用监督学习的方式，任务就是基于人脸图片预测人的唯一ID。那就需要收集每个人很多张人脸照片（不同角度不同年龄不同光照等），这几乎无法实现。所以可行的方法就是让模型学会表示人脸，同一个人不同照片得到的表示接近，不同的人的照片表示不接近。FaceNet正是基于这种思想，使用Triplet Loss，拉近Anchor(用户A的照片1)与Positive(用户A的照片2)的距离，推远Anchor(用户A的照片1)与Negative(用户B的照片1)的距离，从而学会生成有区分度的人脸表示。这里每个样本中的负样本数量为1，即：(Q, P, N)，这里用Q表示Anchor，P表示正样本，N表示负样本。DSSM中负样本的数量为4，即：(Q, P, N_1,N_2,N_3,N_4)。</p>

<p>从<a href="https://arxiv.org/abs/2002.05709">SimCLR</a>到CLIP，再到最近大火的<a href="https://openai.com/dall-e-2/">DALL·E 2</a>，对比学习的潜力被大量开发，文本与文本的对比，图像与图像的对比，文本与图像的对比，效果都非常好，而且最重要的是，工业界落地容易，只要提前计算好向量表示就可以实时推理。</p>

<h3 id="21">2.1 损失函数</h3>

<p>我们来看看对比学习是怎么训练的，负样本的数量对模型效果的影响还是很大的，SimCLR文章给出了batch_size（可以理解为负样本数量）与模型效果的关系：
<img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-34-00.png" alt="对比学习在有赞的应用"></p>

<p>上面我们提过FaceNet一个负样本，DSSM4个负样本，而CLIP的负样本数量达到了32767，
这么多的负样本就是用来计算损失的，损失函数为infoNCE，形式如下：</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-34-34.png" alt="对比学习在有赞的应用"></p>

<p>sim(x, y)代表两个表示的相似度，可以是余弦相似度（一般模型最后一层会是L2归一化，所以余弦相似度 sim(x, y) 的结果在-1到1之间）。τ为温度超参，如果τ为1，sim(x, y)看做logits，那就是个标准的交叉熵。一般交叉熵我们用于分类任务，这么说的话对比学习也算一种分类任务。</p>

<p>以下图为例，这组样本可以表示为：(Q, P, N_1, N_2)，即有两个负样本，模型会计算出sim(Q, P)，sim(Q, N_1)， sim(Q, N_2)，为了最大化sim(Q, P)这一“类别”的概率，我们可以认为该样本的one-hot label是[1, 0, 0]。如果我们按照(Q, P, N_1, N_2)的方式准备所有训练数据，那每个样本的label就都是[1, 0, 0]，而且损失函数是交叉熵，形式上已经是分类任务了，不同的是，这个“类别”没有实际意义。从另外一个角度想，分类任务可以看到所有的负样本（所有的类别），而对比学习只能看到有限的负样本，所以负样本越多学习难度会越大，训练出来的模型更稳健。</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-35-45.png" alt="对比学习在有赞的应用"></p>

<h3 id="22batch">2.2 Batch内负采样</h3>

<p>如果像上图一样，每条训练数据中都提前准备好若干个负样本（可以全局负采样），假设两个负样本，即（Q, P, N_1, N_2）,当按batch训练时，需要分别计算batch_Q, batch_P, batch_N1, batch_N2的向量表示，然后再计算对比损失，计算量非常大，而且负样本数量越多训练越慢。所以一种经典的方法就是batch内负采样，训练数据只需要是（Q, P），实际计算时，一个batch内的其他Q对应的P就可以作为当前Q的N，前向计算只需要计算batch_Q和batch_P，只需要提高batch_size就可以增加负样本数量。</p>

<p>用CLIP的图来解释batch内负采样的计算过程，如下图所示，batch文本经过Text Encoder得到batch_size * dim的矩阵，记做ET，也就是图里T_1, T_2, ...,T_N的向量表示。batch图像经过Image Encoder得到batch_size * dim的矩阵，记做EI，进行矩阵乘法 ET * transpose(EI)后就得到batch_size * batch_size的相似度方阵，对角位置就是相关的文本和图像的相似度，即 sim(Q, P)，其他位置就是不相关的文本和图像的相似度 sim(Q, N)，所以这个batch的稀疏label就是（0，1，2，...，batch_size-1）。有了logits和label就可以使用infoNCE计算损失函数。</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-36-29.png" alt="对比学习在有赞的应用"></p>

<h2 id="3">3. 模型设计</h2>

<p>在我们决定尝试用对比学习（语义匹配）的方式做商品的产品词预测时，就想到了用搜索点击数据训练模型。搜索日志数据量非常大且容易获取，搜索点击是一种弱监督的数据，包含用户对query和商品相关性的认可，虽然包含很多噪声，但是数据量大的情况下模型有能力学会忽略这些噪音，反而有可能增加模型的稳健性。所以我们的训练数据（Q，P）就是（query，goods_info），goods_info可以只是简单的使用商品标题，也可以加入其他信息如店铺名，商品类型，商品描述等等。</p>

<p>模型设计为经典的双塔结构，由于商品搜索点击的query和goods_info分布上很接近，所以双塔的结构和参数完全共享，也就是一个塔用两次。</p>

<p><img src="https://tech.youzan.com/content/images/2022/07/Snipaste_2022-07-07_16-06-45.png" alt="对比学习在有赞的应用"></p>

<p>当goods_info 包含商品标题外的其他信息时，使用segment_id区分。这里我们输入的序列长度是100，embedding维度为512，6层Transformer encoder layer，总参数量17M，只使用6层transformer是基于线上服务性能考虑，独占单卡，100并发，可以达到60ms的RT和1500的QPS，且现有两层transformer的模型表现也不错，6层目前来说是比较经济的选择。</p>

<h2 id="4">4. 训练技巧</h2>

<h3 id="41">4.1 温度超参</h3>

<p>infoNCE相对于crossEntropy多出来的温度超参τ，按照<a href="https://arxiv.org/abs/2012.09740">Understanding the Behaviour of Contrastive Loss</a>中的解释，具有控制表示分布的能力，小的温度超参学习出来的分布更加均匀，大的温度超参学习出来的分布类间更加远离，如下图：</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-37-50.png" alt="对比学习在有赞的应用"></p>

<p>CLIP中将温度超参设置为可学习的参数，我们也曾尝试过，但是模型学习到最后，为了loss继续降低，会“偷懒”将温度参数设置的很小，之前训练得到过0.0037这么小的温度参数，当模型有分辨能力后，过小的温度超参会阻碍模型表示能力的进一步提高，如下图所示，所以最终训练时，我们还是使用了常数0.1作为温度超参的值。</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-38-20.png" alt="对比学习在有赞的应用"></p>

<h2 id="42">4.2 分布式训练</h2>

<p>由于对比学习对负样本的依赖，理论上负样本越多模型的表示能力越稳健，使用batch内负采样的技术需要增加batch_size以增加负样本数量，而batch_size的大小受限于显卡的显存。那既然一张卡的显存有限，使用多个机器的多张卡一起训练呢？不仅可以增加batch_size，还能提高训练速度，于是我们设计了多机多卡的训练方案，如下图所示：</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/-------_--_----_-----3-2.png" alt="对比学习在有赞的应用"></p>

<p>这里以三个节点为例，每个节点为一台单卡的机器，当模型前向计算出goods_embedding和query_embedding时，对于单机就可以直接计算相似度矩阵了，但是为了获得更大的相似度矩阵，我们使用all_gather操作，将其他节点的goods_embedding和query_embedding收集过来，拼成更大的embedding矩阵再计算相似度，原来单机的相似度矩阵维度为（batch_size， batch_size），这里分布式的相似度矩阵会增加为（batch_size * num_node，batch_size * num_node），如果单机最大batch_size是500，三台分布式训练batch_size（all_gather后）就可以达到1500。只要增加节点就可以增加负样本数量，分布式训练看起来挺适合对比学习。</p>

<p>实际训练时我们使用5台机器做分布式训练，将训练数据切分为5份，每台机器读取一份，训练框架使用Pytorch Lightning。</p>

<h2 id="43">4.3 一些节省显存的技巧</h2>

<p>分布式训练靠多台机器增加batch_size，单节点的batch_size可以靠优化模型训练时的显存占用提高。根据<a href="https://arxiv.org/abs/1910.02054">ZeRO</a>里的说明，模型在GPU上训练时，显存中会存在模型的参数、各层的激活值、优化器的状态、梯度等，要减少显存占用就要想办法优化这些数据的显存开销。</p>

<h3 id="431zero">4.3.1 ZeRO</h3>

<p>ZeRO就是基于分布式提出的节省单节点显存的方案，一般分布式每个节点都存储着完整的模型数据（模型参数、优化器的状态、模型的梯度、前向计算的激活值等），如果将优化器的状态参数拆分，保存在每个节点上，每个节点只维护部分参数，那单个节点的显存占用会显著下降，同样的，梯度，参数都分布式保存，显存占用会极大的得到优化。</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-40-29.png" alt="对比学习在有赞的应用"></p>

<p>我们使用Pytorch Lightning中的ddp_sharded，即将优化器的状态参数和模型的梯度分布式保存在每个节点上，详细说明参见<a href="https://pytorch-lightning.readthedocs.io/en/latest/advanced/model_parallel.html#sharded-training">官方文档</a>。</p>

<h3 id="432activationcheckpointing">4.3.2 Activation Checkpointing</h3>

<p>Activation Checkpointing也叫Gradient Checkpointing或者重计算技术。模型训练前向计算时，每层的计算结果（激活值）都会保存，方便反向传播时更新梯度，当batch_size增加时，其占用的显存将非常大，而Activation Checkpointing的思想就是部分层的计算结果不保存，当反向传播到这层时，重新前向计算这一层的激活值，再进行梯度更新，会增加一些训练时间，但是可以节省很多显存，如图，反向传播要等重计算完成：</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/output_poor.gif" alt="对比学习在有赞的应用"></p>

<p>根据网上的测试，使用Activation Checkpointing可以减少约60%的显存占用，同时增加约25%的训练时间，这对显存紧张的我们来说无疑是很划得来的。而且很重要的一点是，<a href="https://qywu.github.io/2019/05/22/explore-gradient-checkpointing.html">对模型训练的精度没有影响</a>：</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-41-37.png" alt="对比学习在有赞的应用"></p>

<p>陈天奇大佬2016年提出了这个方法，那个时候BERT还没出呢，详细见大佬论文<a href="https://arxiv.org/abs/1604.06174">Training Deep Nets with Sublinear Memory Cost</a> 。</p>

<p>我们的模型开发由Tensorflow迁移到Pytorch，也有Pytorch官方自带这个功能<a href="https://pytorch.org/docs/stable/checkpoint.html">torch.utils.checkpoint.checkpoint</a>的原因，不过需要尽量避免重算dropout层。具体实现时，我们继承TransformerEncoderLayer类，将MultiheadAttention层改为允许重计算的层。</p>

<h3 id="433mixedprecision">4.3.3 Mixed Precision</h3>

<p>混合精度训练（<a href="https://arxiv.org/abs/1710.03740">Mixed Precision Training</a>）是指模型训练时，模型权重和激活值以FP16存在，在有Tensor Core的GPU上可以加速，FP16也可以节省显存。虽然在前向和反向传播时使用FP16，但在更新权重的时候还会使用FP32。</p>

<p><img src="https://tech.youzan.com/content/images/2022/06/Snipaste_2022-06-21_17-42-20.png" alt="对比学习在有赞的应用"></p>

<p>实际测试中，大约可以节省30%的显存，节约50%的训练时间。</p>

<h3 id="434">4.3.4 训练配置</h3>

<p>使用上面提到的这些优化显存占用的方法，单卡显存占用可以节约55%，16G显存的GPU可以放下700的batch_size，5个节点，最终计算对比损失时的batch_size可以达到3500。（但是要注意batch_size也不是越大越好，当你的训练数据多样性不足而batch_size较大时，会引入较多False Negative，即其他Q的P与当前Q也是语义上相近的，但是计算loss时却是当作负样本处理的，有点类似于做分类时有类别重复）</p>

<p>我们总共收集184M的搜索点击加购日志，5台GPU机器分布式训练，计算loss的相似度矩阵大小是3500*3500，温度超参为0.1。</p>

<h2 id="5">5. 效果</h2>

<p>我们提前整理了一个产品词词库，用来做向量匹配，来看一下效果：</p>

<p><img src="https://tech.youzan.com/content/images/2022/07/Snipaste_2022-07-07_16-32-37.png" alt="对比学习在有赞的应用"></p>

<p>只要商品标题里出现了产品词，预测起来是比较容易的，标题中出现多个产品词时，模型也有能力找出最合适的那个（不过也有badcase，比如“足浴盐”），当标题中没有产品词时，也可以通过向量匹配的方式找一个合理的产品词，这是之前的NER方案完全不能处理的。而且可以通过卡阈值的方式找出词库中语义相近的产品词。</p>

<p>用来匹配文案效果也不错：
<img src="https://tech.youzan.com/content/images/2022/07/Snipaste_2022-07-07_15-02-01.png" alt="对比学习在有赞的应用"></p>

<p>而且测试还发现，属性信息也被编码进了表示中，可以区分品牌、颜色、男女、季节等。</p>

<p>再来一个更有挑战的任务：匹配类目。由于类目文本的形式是：一级类目名>二级类目名>三级类目名>... 这种形式与搜索词和商品标题偏差较大，正好可以测试一下模型的泛化能力：
<img src="https://tech.youzan.com/content/images/2022/07/Snipaste_2022-07-07_15-01-19.png" alt="对比学习在有赞的应用"></p>

<p>可以看出就算类目文本和训练数据分布不一致，模型也有能力做出预测，叶子类目预测的效果也超出预期，这是Zero-shot的效果，非常令人兴奋。想想之前如果要做文本分类，我们需要标注大量数据，加上类目体系庞大，要取得好的效果需要花费大量资源，有了对比学习，就不用从0分学起了，上来就是60分，只需要少量的标注数据，就可以达到令人满意的效果。人工智能终于可以不用那么人工了，大数据的优势也体现出来了。</p>

<p>商品文本编码可以使用商品搜索日志训练，类似的，图像的编码器可以通过商品标题与商品图片的相关性学习出来。至此，我们就拥有了商品文本和图像各自的编码器，可以做文本和文本的匹配，文本和图像的匹配，图像和图像的匹配。</p>

<p>图文匹配的CLIP模型已经开源，可以在这里找到<a href="https://huggingface.co/youzanai/clip-product-title-chinese">https://huggingface.co/youzanai/clip-product-title-chinese</a></p>

<h2 id="6">6. 应用</h2>

<p>基于对比学习预训练加微调和向量召回的方案，目前已经在有赞的商品产品词预测、商品类目预测、相似商品推荐、搜索召回、搜索排序、智能文案、商品风控等场景上线使用，稳健性都要好于之前纯有监督的方案。</p>

<p>可以说，有了对比学习这个强力工具，以前做不了的都可以做了，思路都打开了。对比学习，真的大有可为。</p>]]></content:encoded></item><item><title><![CDATA[有赞算法平台之模型部署演进]]></title><description><![CDATA[<h1 id="">一、 前言</h1>

<p>模型部署作为算法工程落地的最后一公里，其天然对算法团队而言具有较高的复杂性，不仅要考虑如何高效地部署、管理不同框架模型，还需要考虑分布式服务的负载均衡、故障容错、可扩展性、资源隔离、限流、核心指标监控等问题。 这些都极大的依赖于工程团队的能力，不是算法团队的强项，如何解决这最后一公里，让焦点聚焦在模型开发上，是模型部署服务模块需要解决的问题。
<img src="https://tech.youzan.com/content/images/2021/12/pic_1-1-2.png" alt="alt"></p>

<h1 id="">二、 原有架构</h1>

<h2 id="21">2.1 架构设计</h2>

<p>在有赞算法平台Sunfish包含算法训练和模型部署两部分， 模型部署的模块称为ABox（小盒子）。
<img src="https://tech.youzan.com/content/images/2021/12/image-1297.png" alt="alt"></p>

<p>ABox 主要提供将模型部署为在线服务的功能， 主要包含以下功能： <br>
1. 提供 tensorflow 模型的服务加载和版本管理、弹性部署 <br>
2. 提供 tensorflow 模型和其他模型服务（自己部署在额外服务器上）的路由管理 <br>
3. 提供模型输入和输出的自定义处理逻辑执行 <br>
4. 提供服务主机的负载均衡管理 <br>
5. 收集的 Metric 写入上报到 kafka，通过 druid</p>]]></description><link>https://tech.youzan.com/you-zan-suan-fa-ping-tai-zhi-mo-xing-bu-shu-yan-jin/</link><guid isPermaLink="false">f1d5df75-c808-409c-83ca-5b11c9e8775b</guid><category><![CDATA[大数据]]></category><category><![CDATA[大数据]]></category><category><![CDATA[bigdata]]></category><category><![CDATA[算法平台]]></category><dc:creator><![CDATA[yuanyimeng]]></dc:creator><pubDate>Tue, 21 Jun 2022 03:35:00 GMT</pubDate><content:encoded><![CDATA[<h1 id="">一、 前言</h1>

<p>模型部署作为算法工程落地的最后一公里，其天然对算法团队而言具有较高的复杂性，不仅要考虑如何高效地部署、管理不同框架模型，还需要考虑分布式服务的负载均衡、故障容错、可扩展性、资源隔离、限流、核心指标监控等问题。 这些都极大的依赖于工程团队的能力，不是算法团队的强项，如何解决这最后一公里，让焦点聚焦在模型开发上，是模型部署服务模块需要解决的问题。
<img src="https://tech.youzan.com/content/images/2021/12/pic_1-1-2.png" alt="alt"></p>

<h1 id="">二、 原有架构</h1>

<h2 id="21">2.1 架构设计</h2>

<p>在有赞算法平台Sunfish包含算法训练和模型部署两部分， 模型部署的模块称为ABox（小盒子）。
<img src="https://tech.youzan.com/content/images/2021/12/image-1297.png" alt="alt"></p>

<p>ABox 主要提供将模型部署为在线服务的功能， 主要包含以下功能： <br>
1. 提供 tensorflow 模型的服务加载和版本管理、弹性部署 <br>
2. 提供 tensorflow 模型和其他模型服务（自己部署在额外服务器上）的路由管理 <br>
3. 提供模型输入和输出的自定义处理逻辑执行 <br>
4. 提供服务主机的负载均衡管理 <br>
5. 收集的 Metric 写入上报到 kafka，通过 druid 做监控，并提供指标界面查询</p>

<p>其整体的架构设计如下图所示分为 3 个模块: master、 worker 和 manager， 各自主要职责为：
<img src="https://tech.youzan.com/content/images/2021/12/image-1273.png" alt="alt">
<strong>master：</strong></p>

<ol>
<li>业务请求的路由 <br>
根据 zookeeper 上的动态路由选择将请求直接路由给可以访问的服务（这里包括TF-Serving 服务和第三方注册REST服务，多个服务之间采用轮询的方式）, 请求不会经过worker。</li>
<li>自定义 jar 包（udl: user defined lib）执行 <br>
在模型预测前和预测后可以加载自定义处理逻辑，可以对模型的输入数据和输出数据进行预处理</li>
</ol>

<p><strong>worker:</strong></p>

<ol>
<li>注册本机信息，负责上报心跳给 manager, 心跳包含本机上的算法服务的健康状态  </li>
<li>负责算法模型的本地拉取， 由 tf-serving 服务加载模型</li>
</ol>

<p><strong>manager:</strong></p>

<ol>
<li>负责服务器（master 和 worker）的注册和下线  </li>
<li>负责算法和模型的创建  </li>
<li>提供 udl 的更新接口  </li>
<li>提供集群、业务组管理的接口  </li>
<li>提供模型的部署和上下线功能  </li>
<li>提供第三方算法的注册和上下线功能</li>
</ol>

<h2 id="22">2.2 痛点问题</h2>

<p>基于以上架构，ABox 能较好的支持 tensorflow cpu 模型的部署，但存在以下问题：</p>

<p><strong>痛点1 运维投入大</strong></p>

<ul>
<li>扩缩容需要人工调用接口</li>
<li>模型服务最多实例个数取决于 worker 节点个数， 横向扩容需要增加机器</li>
<li>除 tensorflow 模型部署， 其他需要人工调用接口注册 URL 到 master 来提供路由能力</li>
<li>tfserving 采用容器化部署，模型加载过多易 OOM，无法自动拉起</li>
</ul>

<p><strong>痛点2 负载不均衡</strong></p>

<ul>
<li>模型按照一定的资源调度策略分布在各个 worker 节点上，各 worker 节点资源使用容易不均衡</li>
</ul>

<p><strong>痛点3 资源未隔离</strong></p>

<ul>
<li>热点模型占用大量的CPU/内存，容易影响其他模型服务</li>
</ul>

<p><strong>痛点4 缺乏通用性</strong></p>

<ul>
<li>模型服务无法统一管理， tensorflow 模型和其他框架模型管理割裂</li>
<li>没有GPU模型服务部署功能</li>
</ul>

<h1 id="">三、 改造升级</h1>

<h2 id="31seldon">3.1 seldon 介绍</h2>

<p>基于前述问题，为了统一不同框架模型部署服务的管理， 为了增加 cpu 和 gpu 模型的统一部署功能， 为了简化运维操作，我们引入了 seldon 这个开源的基于云原生的模型部署服务。
seldon 是一个基于 K8S 的集成的模型部署方案， 内置了很多通用的例如 tfserving、  sklearn server、mlflow server、triton这样的模型推理服务器(inference server)。 除了这些还可以通过自己实现自定义的 inference server 提供一些额外的模型服务支持。通过将你的模型部署为K8S上的微服务，来提供 HTTP/GRPC 接口给业务调用。 <br>
我们来看下 seldon 的核心模块， 如下图所示。seldon 的核心模块是它的 Seldon Core Controller, 即 operator 模块， 该模块用来管理 Seldon Deployment 资源( Custom Resource )。 seldon 通过 CRD 向 K8S 注册自定义资源，Seldon Core Controller 负责处理该资源的 CRUD， 我们通过创建/删除/修改 Seldon Deployment 来达到创建/删除/修改模型服务的目的。 
除此之外， seldon 还可以集成 Prometheus 和 Jaeger 来提供模型服务的指标监控和调用追踪能力。
<img src="https://docs.seldon.io/projects/seldon-core/en/latest/_images/e2e-model-serving.svg" alt="alt">
seldon 的另一核心概念是 Model Server , 即加载模型用来提供HTTP接口的模型推理服务器。Model Server 有两种类型， 分为 Reusable Model Servers 和 Non-Reusable Model Servers 。 他们的差别从名字就可以看出， 前者是不同模型之间可复用的，后者是固定模型的。 <br>
Reusable Model Servers 通过配置的模型地址，从外部的模型仓库下载模型， seldon 模型预置了较多的开源模型推理服务器， 包含 tfserving , triton 都属于 Reusable Model Servers。 <br>
Non-Resuable Model Servers 将模型打入自定义镜像内部，不需要单独从外部加载模型， 适合做一些特殊化处理以及不需要频繁更新的场景。 <br>
<img src="https://docs.seldon.io/projects/seldon-core/en/latest/_images/model-servers.svg" alt="alt"></p>

<p>例如启动一个 tensorflow 的模型服务， mnist.yaml 文件定义如下：</p>

<pre><code>apiVersion: machinelearning.seldon.io/v1alpha2  
kind: SeldonDeployment  
metadata:  
  name: tfserving
spec:  
  name: mnist
  predictors:
  - graph:
      children: []
      implementation: TENSORFLOW_SERVER
      modelUri: gs://seldon-models/tfserving/mnist-model
      name: mnist-model
      parameters:
        - name: signature_name
          type: STRING
          value: predict_images
        - name: model_name
          type: STRING
          value: mnist-model
    name: default
    replicas: 1
</code></pre>

<p>这里声明一个名为 minist 的 SeldonDeployment 资源，主要的配置参数就是 implementation 和 modelUri。 implementation 指定此次 Model Server 使用预置的 tfserving 服务器， 并且需要指定模型的 modelUri 地址。
通过 <code>kubectl create -f mnist.yaml</code> 我们就可以创建一个 Seldon Deployment 资源。
通过 <code>kubectl get sdep</code> 可以看到的 Seldon Deployment <code>minist</code>。 
该 Seldon Deployment 资源会管理对应的所需要的 Deployment、 Service 和 Virtual Service。</p>

<h2 id="32">3.2 设计方案</h2>

<p>基于公司内部 K8S 环境，在商量了如何部署seldon的后，我们最后决定的架构如图所示：
<img src="https://tech.youzan.com/content/images/2021/12/image-1322.png" alt="alt"></p>

<p>在引入 seldon 管理模型服务部署的同时，进行了以下的改造： </p>

<ol>
<li>保留 ABox master 作为 Dubbo 请求接入入口， master 负责将请求根据协议封装成 http 请求 K8S 集群的 ingress controller。  </li>
<li>修改 Ingress Controller 为 Nginx Ingress Controller， 通过为每个模型服务生成 Ingress 规则路由到指定的 K8S service。  </li>
<li>增加 HDFS-Intializer 用于 Reusable Model Server 中的 hdfs:// 协议的 modelUri  </li>
<li>基于腾讯云的 GpuManager 方案实现GPU的虚拟化和共享  </li>
<li>通过在算法平台集成 K8S client 进行 Seldon Deployment 和 Ingress 的 CRUD  </li>
<li>为自定义镜像提供日志收集服务  </li>
<li>为模型服务增加资源使用展示</li>
</ol>

<p>下面来介绍下主要的部分。</p>

<h3 id="311ingresscontroller">3.1.1 Ingress Controller 替换</h3>

<p>seldon 通过 Ingress Controller 来提供集群外服务的统一访问， 然后通过 istio 或者 ambassador 提供服务的路由和分流， 默认的 istio 服务较为强大，但由于公司内部 K8S 不提供 istio 服务的维护，为了便于运维， 我们在部署 seldon operator 的时候关闭了 istio 和 ambassador 的功能。然后通过自己的客户端实现在 seldon deployment 和 ingress 的统一创建/删除/更新管理。</p>

<h3 id="312reusablemodelserver">3.1.2 Reusable Model Server模型初始化</h3>

<p>seldon 的默认模型下载协议只支持 s3://、 gs://， 我们的模型文件目前存储于 HDFS 管理， 所以实现了自定义的 HDFS-INITILIZER 负责HDFS模型文件的拉取。</p>

<h3 id="313gpu">3.1.3 GPU方案</h3>

<p>我们都知道在k8s上使用GPU资源有 NVIDIA 的 k8s device plugin ，但是这种方案的缺点是不支持GPU的共享和隔离， 也就是一个pod 的 container 必须占用整数个GPU。在我们的实际使用中， 有一些小模型需要GPU加速但是只占用小部分显卡资源。 目前主流的方案有
<img src="https://tech.youzan.com/content/images/2021/12/image-1377.png" alt="alt"></p>

<p>由于公司使用的腾讯云并且 GpuManager 方案功能较强大，所以我们考察了腾讯云的开源 GpuManager 方案和腾讯云新升级的 vGpu 方案，在权衡后， 选择了 GpuManager。
理由是：</p>

<ol>
<li>由于 vGpu 实现内核级别的 cuda 请求拦截，需要依赖腾讯自研的 TencentOS， 不利于服务器内部统一运维，另外 TencentOS 基于 Centos 8, 在公司目前普遍还是 Centos 7 的情况下， 无法安装基础监控服务，也不利于运维。  </li>
<li>vGPU 在积极研发推进阶段，但是目前还不支持服务配置独占 N 张 GPU 卡的情况。</li>
</ol>

<p>虽然GpuManager满足功能需求，但是也存在约5%的性能损耗。</p>

<h3 id="314">3.1.4 日志管理</h3>

<p>对于自定义的镜像的模型服务， 我们定制了 cpu 和 gpu 版本 （主要支持 pytorch）的基础镜像，通过在基础镜像内置 databus(filebeat) 实现日志上传到 kafka, 再通过自研 log-server 服务消费 kafka 写入日志文件，并且对日志文件进行定期清理，备份到 HDFS。
log-server 提供了 HTTP 的访问接口可以获取到最新的日志。</p>

<h3 id="314">3.1.4 资源监控</h3>

<p>通过定时收集每个 pod 的 cpu, memory 使用量， 我们粗略的统计了每个服务的最小/最大/平均资源使用情况， 并且在界面提供实时资源使用的展示。
<img src="https://tech.youzan.com/content/images/2021/12/image-1420.png" alt="alt"></p>

<h3 id="315">3.1.5 服务迁移</h3>

<p>在完成基本功能开发后， 我们依次对 QA、预发、生产环境进行了已有的模型服务的迁移， 这个过程比较长。
为了不影响生产的模型服务， 我们定制了流量切换开关， 逐步按比例切流线上服务到新的集群， 保证业务调用无痛无感知。目前已基本完成线上服务的切流。
<img src="https://tech.youzan.com/content/images/2021/12/image-1347.png" alt="alt"></p>

<h2 id="33">3.3 注意点</h2>

<p><strong>tfserving 依赖 avx 指令集</strong></p>

<p>在某些 KVM 虚机上无法启动 tfserving 服务， Container 提示状态为 CrashLoopBackOff。 进入容器直接运行 <code>/usr/bin/tensorflow_model_server --port=9000 --model_name=xxx --model_base_path=/path/to/model</code> 可以看到错误输出</p>

<pre><code>Illegal instruction (core dumped)  
</code></pre>

<p>tfserving 默认需要 avx 、avx2 的支持, 可以通过 lscpu 检查机器支持的对应指令集。</p>

<h1 id="">四、 总结与展望</h1>

<p>第一阶段模型服务改造已经告一段落，我们具备了通用化的模型部署能力， 并且依托于 K8S 简化了运维。但是目前模型服务部署仍有更多的优化点， 比如：</p>

<ol>
<li>支持推理图 <br>
我们目前的模型服务为单模型的服务， seldon 支持更复杂的图推理的结果， 可以在模型服务前后配置 INPUT TRANSFORMER 和 OUTPUT TRANSFORMER 进行输入输出的处理</li>
<li>支持多种发布策略、分流策略 <br>
支持灰度发布可以更为稳妥的支持线上模型在线推理服务的更新， 分流策略的支持可以用来衡量多版本模型之间的性能差异和效果比对</li>
<li>支持自定义 Model Server 镜像的自动打包更新  </li>
<li>支持更多模型 intializer <br>
如对象存储的支持</li>
</ol>]]></content:encoded></item><item><title><![CDATA[特性团队中的 DoD 右移实践]]></title><description><![CDATA[<blockquote>
  <p>作者：严梨炯 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>DoD（全称：Definition of Done）是特性团队内部，针对某个即将进入下一个迭代 backlog 的工作条目（一般指 story），约定其在该迭代结束时须完成到什么程度（比如：完成测试并等待演示，见图1）。尤其是当 story 颗粒度较大（须跨多个迭代）时，用该方式达成该团队共识，显得尤为重要。</p>

<p>众所周知，即便在敏捷模式中，研发过程依然由若干道工序所组成，故 DoD 的设计，完全可以基于工序来划定。笔者在敏捷转型的实践过程中，完成了特性团队从无到有创建 DoD 活动，并推动其逐渐右移，以帮助团队养成「聚焦目标」的习惯。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01-10.png" alt=""></p>

<p><center style="font-size:10px">图1. story 在某个迭代的 DoD</center></p>

<h1 id="">二、问题现状</h1>

<p>既然定义了 DoD，</p>]]></description><link>https://tech.youzan.com/dod-practise/</link><guid isPermaLink="false">0e9ee086-6765-4b99-b318-39781e16cd29</guid><category><![CDATA[效能提升]]></category><category><![CDATA[效能改进]]></category><dc:creator><![CDATA[费解]]></dc:creator><pubDate>Wed, 23 Mar 2022 03:18:52 GMT</pubDate><content:encoded><![CDATA[<blockquote>
  <p>作者：严梨炯 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>DoD（全称：Definition of Done）是特性团队内部，针对某个即将进入下一个迭代 backlog 的工作条目（一般指 story），约定其在该迭代结束时须完成到什么程度（比如：完成测试并等待演示，见图1）。尤其是当 story 颗粒度较大（须跨多个迭代）时，用该方式达成该团队共识，显得尤为重要。</p>

<p>众所周知，即便在敏捷模式中，研发过程依然由若干道工序所组成，故 DoD 的设计，完全可以基于工序来划定。笔者在敏捷转型的实践过程中，完成了特性团队从无到有创建 DoD 活动，并推动其逐渐右移，以帮助团队养成「聚焦目标」的习惯。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01-10.png" alt=""></p>

<p><center style="font-size:10px">图1. story 在某个迭代的 DoD</center></p>

<h1 id="">二、问题现状</h1>

<p>既然定义了 DoD，那么该 story 的未尽事宜工作就被称为 undone（故，story 生命周期 = DoD + undone）。参考图1，在该例子中，DoD 是到完成测试，那么 undone 就包含「演示、发布」工作。理想情况是，如果 DoD 是完成发布，那么就不存在 undone 工作了。</p>

<p>笔者对接的特性团队正处于敏捷转型阶段，整个迭代过程是粗放式的，其成员习惯于在迭代计划会上，从迭代 backlog 中一次性认领全部 story。与此同时，PO 看到 story 都被认领，也就放心地离开了。</p>

<p>照理说，迭代 backlog 中的 story 条目数，是根据团队的容量来规划的，目标是当迭代结束时，大部分 story 都能交付给用户使用。然后，真实的情况是，与只关注「有人认领」一样，团队同样也只关注 story「已经启动」，于是团队开始同时启动，并穿梭在多个 story 的架构设计和编码工作之间（见图2）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image02-8.png" alt=""></p>

<p><center style="font-size:10px">图2. 有大量 story 同时被启动的看板</center></p>

<p>这导致了一个怪现象：在迭代（3周）的第一周，几乎没有 story 进入测试，但到了第三周，看板上却堆满了测试任务，而此时开发人员已从上述 story 的研发工作中离开，不再关注。</p>

<p>最后，只有少量 story 能如期交付，其中一部分还是上个迭代流入后，占用本迭代的时间和资源才得以完成的。所以本迭代可用于规划的团队容量因 undone 工作积压而缩减，可认领的 story 变少了，于是团队的适应性变差了。团队持续在做遗留任务，失去了对目标的冲刺感，PO 对团队信心不足，迭代活动变得形同虚设了。</p>

<p>读者可能会说，何不将计就计，把各道工序都错开一个迭代（或一周）？见图3。这种做法借鉴了精益流动的思路。但问题是，story 并不像工厂车间生产零件，其每道工序没有标准化的处理时长，研发各环节的波动性对团队迭代计划的冲击会很大（关于该话题，笔者将单独撰文分享）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image03-6.png" alt=""></p>

<p><center style="font-size:10px">图3. 迭代按工序错开的效果图（<font style="color:red">注：该方法不可取</font>）</center></p>

<h1 id="1dod">三、实践1：引入 DoD，重获目标感</h1>

<p>在这样的团队氛围下，任何的改进都可能是无效的。笔者认为，当务之急是重拾团队信心。于是，我们引入团队的唯一举措是，在迭代计划会上，为每个 story 设定 DoD。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image04-4.png" alt=""></p>

<p><center style="font-size:10px">图4. 带有 DoD 信息的 story 卡片</center></p>

<p>虽然在后续的认领环节，团队依然会一次性清空迭代 backlog，但团队成员会更明确自己名下 story 在本迭代的目标（此时不对 DoD 提出任何额外要求），而且我们会在迭代结束时，为大部分 story 达成 DoD 而庆祝，以此逐渐找回迭代工作的节奏感。</p>

<p><b>迭代完成率 = 达成 DoD 的 story 个数 ÷ 迭代规划的 story 个数 × 100%</b></p>

<p><img src="https://tech.youzan.com/content/images/2022/02/image05-2.png" alt=""></p>

<p><center style="font-size:10px">图5. 试点团队的「迭代完成率」统计</center></p>

<h1 id="2dod">四、实践2：DoD 右移，提升吞吐</h1>

<p>然而，按「DoD 达成」作为迭代的产出，在业务上终归是没有实际意义的，毕竟迭代结束时，story 依然是半成品，在业务上不产生价值。即：团队的产出数据，与业务价值上的反馈是脱节的。</p>

<p>让我们回归到敏捷研发的目标：大部分 story 应在迭代结束时能交付给用户，进而清空 backlog，确保在下个迭代充分响应新的业务变化。这意味着要在尽量短的周期内完成 story 的所有工序。于是，我们下一步就必须对团队的 DoD 提出更高要求了。</p>

<p>按前文所述，DoD = 完成发布时（此刻已不存在 undone 工作），才算是交付到用户手上。所以，在迭代规划上，我们开始引导团队将 DoD 右移，以减少 undone 工作流入下个迭代，提升迭代规划时团队的可用容量，保持敏捷性。</p>

<p><b>迭代交付率 = 迭代内发布的 story 个数 ÷ 迭代规划的 story 个数 × 100%（与「迭代完成率」的区别是不遗留 undone 工作）</b></p>

<p><img src="https://tech.youzan.com/content/images/2022/02/image06-1.png" alt=""></p>

<p><center style="font-size:10px">图6. 试点团队的「迭代交付率」统计</center></p>

<h1 id="">五、效果点评</h1>

<p>通过倡导 DoD 右移，使得团队在迭代计划会上，倾向于以上线为目标来填充迭代 backlog，进入迭代的 story 数量更克制了。</p>

<p>团队围绕着有限的 story 工作，目标清晰，团队一致追求 story 在迭代内上线，遇到障碍会主动协商解决，职责的边界感也更弱了。于是，在迭代结束时遗留的 undone 工作变少了，迭代交付率得以改善。</p>

<p>需要流转的 story 变少，可以为下个迭代留出充足的空间，团队有能力承接更多的新需求，满足了上游业务提升吞吐率的诉求。</p>

<p><img src="https://tech.youzan.com/content/images/2022/02/image07-3.png" alt=""></p>

<p><center style="font-size:10px">图7. 通过 DoD 右移来提升研发吞吐的系统思考</center></p>

<h1 id="">六、实操建议</h1>

<p>团队刚做敏捷转型的时候，可能会出于各种原因，导致 DoD 混乱的情况。但随着团队越来越成熟，可以定期评估是否存在 DoD 右移的契机。可能的过程是：</p>

<ul>
<li>阶段0：没有 DoD 或没有统一的 DoD。</li>
<li>阶段1：统一 DoD，DoD = 完成测试。</li>
<li>阶段2：DoD = 完成演示。</li>
<li>阶段3：DoD = 完成发布。</li>
</ul>

<p>其中，有 2 个点是值得我们关注的：</p>

<ol>
<li>要有一个统一的 DoD 作为团队的目标。降低因迭代中存在不同 story 定义各自 DoD 而产生的团队认知负荷。  </li>
<li>DoD 至少是完成测试。因为在实操中，时常发生测试积压的现象，DoD 把测试阶段包含进来，可以让团队关注这个风险。</li>
</ol>

<p>团队在每个迭代都期望完成 DoD 的目标，在这个过程中会碰到各种情况，以下例举了 3 个场景以及对应的实践建议：</p>

<p>Case1：需求颗粒度太大，一个迭代做不完（这种情况比较常见）。建议：通过用户故事地图的方式，把 epic 级别的需求拆成 story，将 story 作为交付的最小单元。</p>

<p>Case2：业务上要求多个 story 集中发布，才能为客户提供场景闭环。建议：可以把 DoD 定为完成演示（即：待发布状态），当所有相关的 story 都达成 DoD 时，就可以进行统一交付。</p>

<p>Case3：拆分 story 后，回归的工作量陡增，测试成本上升。建议：前期可以先把 DoD 定为功能测试完成，发布前对 epic 进行统一的回归测试（或者，可以通过提升测试自动化水平，来降低回归成本，也有助于 DoD 右移）。</p>

<p>总的来说，明确 DoD 及右移，有助于特性团队的敏捷转型，DoD 离用户越近，团队的成熟度和敏捷性就越高。</p>

<h1 id="">延伸阅读</h1>

<ul>
<li><a href="https://mp.weixin.qq.com/s/rRYFHabZZZapbYyx54MflQ">如何用「标准差」度量研发波动</a></li>
<li><a href="https://mp.weixin.qq.com/s/a0YBnNvO3fmnNFDXwAYudg">一则物理看板的演进实践</a></li>
<li><a href="https://mp.weixin.qq.com/s/Lew0tQcfyupe1KGjtu0XoQ">「研发共建」提升中台效能初探</a></li>
<li><a href="https://mp.weixin.qq.com/s/aSOo7pVRIPIF5E-GSh2Acw">效能指标「研发浓度」在项目度量中的应用</a></li>
<li><a href="https://mp.weixin.qq.com/s/a4g-PrDFwZXx2aeCW6eN_g">有赞效能数据赋能实战</a></li>
<li><a href="https://mp.weixin.qq.com/s/YNySWTU5mz8Upov1dhYhwA">项目制实践如何助力组织进化</a></li>
<li><a href="https://mp.weixin.qq.com/s/VdMpmqRc2N1gmKBnxXkOig">有赞如何打造高绩效的千人技术团队？</a></li>
<li><a href="https://mp.weixin.qq.com/s/GqKG9b2JCiquSya_BJWRPw">敏捷与 OKR：系统思考与组织设计的艺术</a></li>
<li><a href="https://mp.weixin.qq.com/s/DHt2pQihA_oLQNGXVIXZvQ">大规模产品待办列表处理策略—需求分级</a></li>
<li><a href="https://mp.weixin.qq.com/s/dip-LmH1CzJGbKVuvhaXng">大规模产品技术团队需求管理实践</a></li>
</ul>

<blockquote>
  <p>如果读者对效能改进也有兴趣，欢迎加入有赞效能改进团队，请将简历投递至：yanlijiong@youzan.com，我们共同探讨和实践。</p>
</blockquote>]]></content:encoded></item><item><title><![CDATA[如何用「标准差」度量研发波动]]></title><description><![CDATA[<blockquote>
  <p>作者：陈煜 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>技术中心的年度研发效能报告已于前不久发布，在吞吐的分析中，我们新增了一个指标「标准差」（计算公式见图1）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01-11.png" alt=""></p>

<p><center style="font-size:10">图1. 标准差计算公式</center></p>

<p>标准差在概率统计中最常使用作为统计分布程度上的测量。它反映组内个体间的离散程度。标准差越大，表示大部分数值和其平均值之间差异较大，反之亦然。</p>

<p>上面的公式不用记，Excel 中有对应的计算函数：STDEVP（见图2）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image02-9.png" alt=""></p>

<p><center style="font-size:10">图2. Excel 中的标准差函数</center></p>

<h1 id="">二、指标的产生历程</h1>

<p>常见的数据分析方法包括：趋势分析、指标下钻分析、关联影响分析。而标准差，就是下钻分析维度的产物。我们的目标是提升吞吐量（即：单位周期交付的需求数量），所以重点关注在「吐」的情况。</p>

<p>然而，利特尔法则指出，过高的在制品数量会影响需求的交付周期，进而影响需求交付效率。故在吞吐量的分析中，我们加上了在制品的分析，引入了对「吞」的观察（即：单位周期规划的需求数）</p>]]></description><link>https://tech.youzan.com/metric-standard-deviation/</link><guid isPermaLink="false">adfb7d8a-9497-4fee-9295-e0737e32a9f8</guid><category><![CDATA[效能提升]]></category><category><![CDATA[效能改进]]></category><dc:creator><![CDATA[费解]]></dc:creator><pubDate>Fri, 04 Mar 2022 11:46:15 GMT</pubDate><content:encoded><![CDATA[<blockquote>
  <p>作者：陈煜 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>技术中心的年度研发效能报告已于前不久发布，在吞吐的分析中，我们新增了一个指标「标准差」（计算公式见图1）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01-11.png" alt=""></p>

<p><center style="font-size:10">图1. 标准差计算公式</center></p>

<p>标准差在概率统计中最常使用作为统计分布程度上的测量。它反映组内个体间的离散程度。标准差越大，表示大部分数值和其平均值之间差异较大，反之亦然。</p>

<p>上面的公式不用记，Excel 中有对应的计算函数：STDEVP（见图2）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image02-9.png" alt=""></p>

<p><center style="font-size:10">图2. Excel 中的标准差函数</center></p>

<h1 id="">二、指标的产生历程</h1>

<p>常见的数据分析方法包括：趋势分析、指标下钻分析、关联影响分析。而标准差，就是下钻分析维度的产物。我们的目标是提升吞吐量（即：单位周期交付的需求数量），所以重点关注在「吐」的情况。</p>

<p>然而，利特尔法则指出，过高的在制品数量会影响需求的交付周期，进而影响需求交付效率。故在吞吐量的分析中，我们加上了在制品的分析，引入了对「吞」的观察（即：单位周期规划的需求数）。</p>

<p>但是，我们仅仅以自然月为单位周期进行分析，发现规划需求数和交付需求数只是两组无规律的波动。仅用周期内发生的需求个数和时间趋势，难以评价吞吐量是否正常，我们只能看到一张随月度起伏变化的曲线图（见图3）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image03-9.png" alt=""></p>

<p><center style="font-size:10">图3. 某研发团队的需求吞吐量分析</center></p>

<p>我们希望有个指标或数据统计方式，可以直观反映出吞吐的「问题」，一方面可以做部门间的横向对比，另一方面也能提醒我们介入干预。标准差就是这样一个指标，它适用于分析一组数据，擅长只用一个数值，就可以表达波动幅度的情况，这对任何一支团队都是适用的。</p>

<p>吞吐标准差，统计了两个维度的数据：规划需求波动、交付需求波动。标准差表示，每月新规划或上线需求数，与平均值的离散程度。标准差越大，每月规划个数或上线个数越不稳定，对团队生产秩序的冲击越大（见图4）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image04-6.png" alt=""></p>

<p><center style="font-size:10">图4. 多个研发团队需求吞吐波动的对比</center></p>

<h1 id="">三、指标的运用场景</h1>

<p>在图4的案例（数据来自年度研发效能报告，挑选了最典型的三条业务线）中，我们有几个发现：</p>

<ol>
<li><p>单纯横向比较这三条业务线的吞或吐，都没有意义，在团队规模、需求场景、业务架构等维度上都没有可比性。</p></li>
<li><p>吞（左图）：业务线C（蓝点）的波动性最大，意味着该业务的产品方案输入最不稳定（这真是个意外的收获，用研发效能的度量指标也能观测产品端的生产节奏），对研发团队的影响最大，可能出现过需求大小月：在小月里可能发生过团队空转或承接了持续数月的巨型需求，在大月里则需要投入额外的管理成本和资源来维持生产。</p></li>
<li><p>吐（右图）：业务线B（红点）的波动性最小，意味着该业务的研发团队产出最稳定。真实的情况是，该业务线的研发团队已通过敏捷转型实现了时间盒内交付的稳定节奏，与此同时，其吞的标准差（左图红点）也是最低的（这是不是意味着，敏捷研发模式可以降低生产波动呢？敏捷真是个好东西！）。</p></li>
<li><p>吐（右图）：业务线A（绿点）的研发产出最不稳定，需要进一步介入分析（一般来说，原因会比较多元，本文不做展开），并采取改进措施。</p></li>
</ol>

<h1 id="">四、小结</h1>

<p>吞吐的标准差可以用于衡量研发团队的稳定性以及成熟度（敏捷转型相关），但因为软件研发不同于工厂制造，无论吞还是吐，每一条需求都会受到功能颗粒度和研发复杂度的影响，故无须追求消除偏差的极致目标。</p>

<p>标准差可用于事前。吐的标准差在一定程度上可以用于指导规划活动（吞）的开展，对于同一个团队来说，交付能力（吐）通常是稳定的，规划过多则会造成在制品积压反而影响交付，适得其反。</p>

<p>标准差也可用于事后。当吐的标准差过大，但总体需求交付数却不理想时，就需要结合过往需求研发并行程度、需求研发周期等数据，做进一步下钻分析。</p>

<p>传统的吞吐量指标，意在观察团队是否挖掘了产能极限。但笔者认为，团队的产能水平通常是恒定的，唯一影响其发挥的因素是「吞」，可通过吞的标准差来评价。而「吐」一方面折射出「吞」的效果，另一方面则是为了满足需求提出方的体感诉求。</p>

<p>吞吐的标准差指标可挖掘和探索的领域还有很多，如果读者朋友有更多的相关实践，欢迎在留言区沟通。</p>

<h1 id="">延伸阅读</h1>

<ul>
<li><a href="https://mp.weixin.qq.com/s/a0YBnNvO3fmnNFDXwAYudg">一则物理看板的演进实践</a></li>
<li><a href="https://mp.weixin.qq.com/s/Lew0tQcfyupe1KGjtu0XoQ">「研发共建」提升中台效能初探</a></li>
<li><a href="https://mp.weixin.qq.com/s/aSOo7pVRIPIF5E-GSh2Acw">效能指标「研发浓度」在项目度量中的应用</a></li>
<li><a href="https://mp.weixin.qq.com/s/a4g-PrDFwZXx2aeCW6eN_g">有赞效能数据赋能实战</a></li>
<li><a href="https://mp.weixin.qq.com/s/YNySWTU5mz8Upov1dhYhwA">项目制实践如何助力组织进化</a></li>
<li><a href="https://mp.weixin.qq.com/s/VdMpmqRc2N1gmKBnxXkOig">有赞如何打造高绩效的千人技术团队？</a></li>
<li><a href="https://mp.weixin.qq.com/s/GqKG9b2JCiquSya_BJWRPw">敏捷与 OKR：系统思考与组织设计的艺术</a></li>
<li><a href="https://mp.weixin.qq.com/s/DHt2pQihA_oLQNGXVIXZvQ">大规模产品待办列表处理策略—需求分级</a></li>
<li><a href="https://mp.weixin.qq.com/s/dip-LmH1CzJGbKVuvhaXng">大规模产品技术团队需求管理实践</a></li>
</ul>

<blockquote>
  <p>如果读者对效能改进也有兴趣，欢迎加入有赞效能改进团队，请将简历投递至：chenyu_yu@youzan.com，我们共同探讨和实践。</p>
</blockquote>]]></content:encoded></item><item><title><![CDATA[一则物理看板的演进实践]]></title><description><![CDATA[<blockquote>
  <p>作者：林晔琛 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>看板（并非特指 Kanban，下同）作为一种目视化管理工具，能够将团队成员的工作过程透明出来，帮助团队更好地发现问题和瓶颈，尤其是在特性团队中，更是会秉承看板的理念，将其与站会形成良好的配合和互动，充分发挥其目视化的作用。</p>

<p>在笔者的工作场景中，特性团队尚处于敏捷转型初期，并未养成良好的工作习惯，其中就包括看板使用不到位的情况，导致了站会活动效果不佳。于是，笔者尝试从「改变看板的使用姿势」切入，唤醒团队的自管理意识，逐步改善团队的敏捷氛围。</p>

<h1 id="">二、物理看板演进</h1>

<h2 id="1scrum">1. Scrum 板：从失焦到聚焦</h2>

<h3 id="">现象</h3>

<p>每日站会是技术负责人发起的，没有固定的聚会场所，而且形式比较随意：团队成员围坐在办公室休息区的沙发上，抑或茶水间的吧台前，每个人端着电脑，在技术负责人的主持下被挨个儿点名，然后大家轮流在自己电脑上更新维护同一份在线表格中所负责的任务条目的状态（石墨、Confluence 之类的），站会过程中的其他时间就处理与会议无关的事务。</p>

<h3 id="">分析</h3>

<ul>
<li>责任失焦。作为技术负责人角色，主持人天然带有权威性，</li></ul>]]></description><link>https://tech.youzan.com/evolution-of-real-kanban/</link><guid isPermaLink="false">9442ba93-8652-475e-b9e6-f46b6e208d26</guid><category><![CDATA[效能提升]]></category><category><![CDATA[效能改进]]></category><dc:creator><![CDATA[费解]]></dc:creator><pubDate>Thu, 24 Feb 2022 13:17:24 GMT</pubDate><content:encoded><![CDATA[<blockquote>
  <p>作者：林晔琛 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>看板（并非特指 Kanban，下同）作为一种目视化管理工具，能够将团队成员的工作过程透明出来，帮助团队更好地发现问题和瓶颈，尤其是在特性团队中，更是会秉承看板的理念，将其与站会形成良好的配合和互动，充分发挥其目视化的作用。</p>

<p>在笔者的工作场景中，特性团队尚处于敏捷转型初期，并未养成良好的工作习惯，其中就包括看板使用不到位的情况，导致了站会活动效果不佳。于是，笔者尝试从「改变看板的使用姿势」切入，唤醒团队的自管理意识，逐步改善团队的敏捷氛围。</p>

<h1 id="">二、物理看板演进</h1>

<h2 id="1scrum">1. Scrum 板：从失焦到聚焦</h2>

<h3 id="">现象</h3>

<p>每日站会是技术负责人发起的，没有固定的聚会场所，而且形式比较随意：团队成员围坐在办公室休息区的沙发上，抑或茶水间的吧台前，每个人端着电脑，在技术负责人的主持下被挨个儿点名，然后大家轮流在自己电脑上更新维护同一份在线表格中所负责的任务条目的状态（石墨、Confluence 之类的），站会过程中的其他时间就处理与会议无关的事务。</p>

<h3 id="">分析</h3>

<ul>
<li>责任失焦。作为技术负责人角色，主持人天然带有权威性，站会以该角色为中心，更像是个汇报的过程。</li>
<li>空间失焦。没有固定的场所，站会难以成为团队的一项工作习惯，大家在站会上的行为比较随意，就是意料之中的了。</li>
<li>目标失焦。每个人都等着被点名向老板汇报，所以往往只考虑自己的任务完成与否，难以与团队中其他人建立连接，更不用说关注到团队的共同目标和整体进展了。</li>
</ul>

<h3 id="">改进</h3>

<p>基于 Scrum 框架之三大支柱：透明、检视、调整，我们选择了从工作任务可视化入手，在一块白板上，直观地跟踪团队每项工作任务的进展和状态。</p>

<p>将白板用彩色胶带划分出多个行列，行（泳道）代表 story，在 story 下拆分出颗粒度不超过 1 个工作日的 task（不同颜色或大小代表不同的任务类型）卡片，都会经历 todo/doing/done 三个状态列。当 story 下的所有卡片都从 todo 转移到 done 后，即宣告 story 交付完成。</p>

<h3 id="">效果</h3>

<ul>
<li>责任聚焦。团队的工作都呈现在看板上，所以每个人不再需要带电脑，同步自己进展的时候会一并观察同一个 story 下的其他卡片所处状态，并与一起合作的同事进行交流互动。</li>
<li>空间聚焦。白板所在的位置，就天然成为团队开站会的场所，站会也越来越成为团队开始一天工作的仪式，无关行为越来越少，会议效率越来越高。</li>
<li>目标聚焦。大家围绕着 story 展开工作，每个人都能清楚地知道自己的任务是什么，任务背后的目标是什么，因此能够快速地暴露风险，关注障碍是否被移除。比如：曾经某个同学试图多任务并行，就立马被团队其他有依赖工作的成员发现和阻止了。</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01-5.png" alt=""></p>

<p><center style="font-size:10px">图1. Scrum 板实操图</center></p>

<h2 id="2">2. 精益看板：从聚焦个人任务到聚焦团队目标</h2>

<h3 id="">现象</h3>

<p>经过在完成一个迭代中采用「物理看板+站会」的尝试后，团队成员逐渐适应了这种新型的站会方式，一上来就先将自己的 task 卡片拖动到对应的状态栏，然后开始相互交流 story 的状态或阻塞情况。</p>

<h3 id="">分析</h3>

<p>我们从上述观察，意识到团队成员在看板工作模式的启发下，开始关注 story 的流动（原本只是技术负责人或产品负责人才会关注）。但此时的看板机制是只流动 task，而story 固定在看板最左侧列，无法体现其流转效果。于是，我们意识到当前的看板模式已经不能满足团队的使用诉求和工作习惯了。</p>

<h3 id="">改进</h3>

<p>对价值流的关注是敏捷团队成熟度提升的体现，这则新闻真的令笔者和 ScrumMaster 感到兴奋。于是，我们开始着手构思基于精益价值流导向的看板模型。</p>

<p>在新的看板模型中，我们打算优先在「列」的维度表达研发过程的工作流，以体现 story 的价值流动（期待它流动得越快越好）。初定了「筹备/开发/测试/发布」4 列，并在每个列中再细分出 story 栏和 task 栏（现状：story 的颗粒度较大）。</p>

<p>与此同时，为了避免在 story 流动过程中，大家对能否往下个阶段流转产生歧义（比如：对于「结束开发阶段」这件事，开发同学认为是「编码完成」，而测试同学认为是「冒烟自测通过」），我们在每个节点声明了准入准出的条件（在敏捷中常被称为 DoD，Definition of Done），并透明在看板上，以提醒团队在移动 story 卡片时，根据标准进行核对。</p>

<h3 id="">效果</h3>

<p>使用了新的看板，团队成员不再在站会上确认每一张 task 卡片的状态，而是将注意力转向影响价值流动的问题，并重点关注阻碍项。一旦某个环节变得不顺畅，就会立即体现在看板上（比如：story 或 task 卡片在此处积压），指引团队须重点关注并解决，以及时恢复它们的流动。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image02-6.png" alt=""></p>

<p><center style="font-size:10px">图2. 精益看板实操图</center></p>

<h2 id="3story">3. story 卡片的演进：管理从粗放式到精细化</h2>

<h3 id="">现象</h3>

<p>行文至此，习惯于使用电子看板的读者可能会产生一个疑问：物理看板如何度量？经过几个迭代的看板共创后，我们其实也意识到这个问题了，毕竟我们需要通过度量来指导改进。</p>

<h3 id="">改进</h3>

<p>但由于物理看板在数据自动统计方面有其局限性，所以我们采取了一种简单易实施但效果又不错的数据收集方法，来支持敏捷团队在数据度量方面的诉求。</p>

<p>我们在 story 卡片上增加了其流经各个价值节点的时间填写动作。在站会上，当 story 卡片被准出并开始流向下一节点时，站会主持人将触发日期记录到 story 卡片上。</p>

<h3 id="">效果</h3>

<p>在迭代结束时，我们可以从看板上收集到 story 流动相关的工作记录，整理后用于团队指导改进，于是，据此该团队便形成了反馈和持续改进的机制。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image03-5.png" alt=""></p>

<p><center style="font-size:10px">图3. story 卡片度量实操图</center></p>

<h1 id="">三、心得</h1>

<p>看板原理和实施方法，业界有不少教科书式的介绍，但运用到实际场景中时，我们务必根据团队敏捷成熟度的情况进行适当裁剪，在「微改进」实践中持续探索与团队相匹配的看板。看板形式并无好坏对错之分，只要适合的就是最好的。</p>

<p>根据未来该团队的成长情况，我们将酌情考虑在看板上增加关于缺陷类 task 的卡片信息，以覆盖团队更广义的工作范围，进而有机会对精益看板上各阶段的在制品数（WIP，Work In Progress）进行限制，找到提升价值流动效率的契机。抑或是，当 story 颗粒度变小且趋同时，可以尝试移除 task 列，忽略细节，降低团队的认知负荷，诸如此类。</p>

<p>看板是一个很强大的工具，未来还有很多内容等待我们实践探索，欢迎读者在下方留言，共同探讨看板实践。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image04.jpeg" alt=""></p>

<p><center style="font-size:10px">图4. 物理看板结合站会的实操图</center></p>

<h1 id="">延伸阅读</h1>

<ul>
<li><a href="https://mp.weixin.qq.com/s/Lew0tQcfyupe1KGjtu0XoQ">「研发共建」提升中台效能初探</a></li>
<li><a href="https://mp.weixin.qq.com/s/aSOo7pVRIPIF5E-GSh2Acw">效能指标「研发浓度」在项目度量中的应用</a></li>
<li><a href="https://mp.weixin.qq.com/s/a4g-PrDFwZXx2aeCW6eN_g">有赞效能数据赋能实战</a></li>
<li><a href="https://mp.weixin.qq.com/s/YNySWTU5mz8Upov1dhYhwA">项目制实践如何助力组织进化</a></li>
<li><a href="https://mp.weixin.qq.com/s/VdMpmqRc2N1gmKBnxXkOig">有赞如何打造高绩效的千人技术团队？</a></li>
<li><a href="https://mp.weixin.qq.com/s/GqKG9b2JCiquSya_BJWRPw">敏捷与 OKR：系统思考与组织设计的艺术</a></li>
<li><a href="https://mp.weixin.qq.com/s/DHt2pQihA_oLQNGXVIXZvQ">大规模产品待办列表处理策略—需求分级</a></li>
<li><a href="https://mp.weixin.qq.com/s/dip-LmH1CzJGbKVuvhaXng">大规模产品技术团队需求管理实践</a></li>
</ul>

<blockquote>
  <p>如果读者对效能改进也有兴趣，欢迎加入有赞效能改进团队，请将简历投递至：linyechen@youzan.com，我们共同探讨和实践。</p>
</blockquote>]]></content:encoded></item><item><title><![CDATA[「研发共建」提升中台效能初探]]></title><description><![CDATA[<blockquote>
  <p>作者：徐钰菡 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>在互联网行业，大部分研发团队都会通过建设中台（有些公司叫平台），来提高系统的可复用性，降低重复功能的研发成本。随着有赞业务的快速发展，我们也逐渐走向了大中台道路，充分享受着中台所带来的红利，但与此同时，我们也陆陆续续遇到了不少问题，笔者希望借助本文，从效能改进的视角进行剖析，期待引发读者对「如何从组织层面协同中台」的思考和共鸣。</p>

<h1 id="">二、发现问题</h1>

<p>有赞大大小小业务共有十几个（下称：业务域），在各业务域（一级）下，又细化出若干业务子域（二级），维护自己的需求优先级列表（backlog）。而中台，也并非以整体在运作，而是根据组件划分为商品、交易、营销等功能模块，各功能模块也有自己的需求优先级列表（中台没有整体的 backlog）。</p>

<h2 id="1">1. 需求规划困难，阻碍业务发展</h2>

<p>尽管业务域内部可以从容响应市场需求的快速变化，但因为功能托管而造成技术依赖，导致与中台的耦合度极高。当业务子域需要在中台通用能力基础上，快速叠加新场景时，就遇到跨团队协作壁垒、多个业务子域需求冲突、</p>]]></description><link>https://tech.youzan.com/open-source-mode-from-middle-platform/</link><guid isPermaLink="false">25c7b4e4-a478-4204-9a2b-73f252aadff1</guid><category><![CDATA[效能提升]]></category><category><![CDATA[效能改进]]></category><dc:creator><![CDATA[费解]]></dc:creator><pubDate>Thu, 17 Feb 2022 08:22:23 GMT</pubDate><content:encoded><![CDATA[<blockquote>
  <p>作者：徐钰菡 | 效能改进</p>
</blockquote>

<h1 id="">一、背景</h1>

<p>在互联网行业，大部分研发团队都会通过建设中台（有些公司叫平台），来提高系统的可复用性，降低重复功能的研发成本。随着有赞业务的快速发展，我们也逐渐走向了大中台道路，充分享受着中台所带来的红利，但与此同时，我们也陆陆续续遇到了不少问题，笔者希望借助本文，从效能改进的视角进行剖析，期待引发读者对「如何从组织层面协同中台」的思考和共鸣。</p>

<h1 id="">二、发现问题</h1>

<p>有赞大大小小业务共有十几个（下称：业务域），在各业务域（一级）下，又细化出若干业务子域（二级），维护自己的需求优先级列表（backlog）。而中台，也并非以整体在运作，而是根据组件划分为商品、交易、营销等功能模块，各功能模块也有自己的需求优先级列表（中台没有整体的 backlog）。</p>

<h2 id="1">1. 需求规划困难，阻碍业务发展</h2>

<p>尽管业务域内部可以从容响应市场需求的快速变化，但因为功能托管而造成技术依赖，导致与中台的耦合度极高。当业务子域需要在中台通用能力基础上，快速叠加新场景时，就遇到跨团队协作壁垒、多个业务子域需求冲突、规划节奏不一致的问题。业务发展速度越快，对中台的诉求越频繁，问题的复杂度就越呈现出指数级上升的态势，则业务功能迭代的阻力越大。</p>

<p>如何在不同业务域下与各业务子域横向 PK 需求优先级，进而管理来自各业务域需求（即相当于：排出公司级的 one backlog），成为一大难题（见图1）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01-4.png" alt=""></p>

<p><center style="font-size:10">图1. 业务和中台需求规划的示意图</center></p>

<h2 id="2">2. 研发周期变长，交付效率下降</h2>

<p>在图1 中，业务子域 2 的某个需求，需要被中台功能模块 α 和 β 在同一个项目中支持，但尴尬的是，α 不仅要支持业务子域 2，而且还正在支持业务子域 1（如果这两个子域分属于不同的业务域，那信息鸿沟则会更大），以至于该项目要等 α 完成上一项工作（业务子域 1 的某个项目）后才能启动，或虽然项目启动了，但 α 参与者同时并行多个项目，成为了关键路径，导致交付周期（= 等待周期 + 研发周期）变长（参见：<a href="https://mp.weixin.qq.com/s/aSOo7pVRIPIF5E-GSh2Acw">效能指标「研发浓度」在项目度量中的应用</a>）。</p>

<p>在实际场景中，由于「各业务域的需求是否会涉及到中台哪些功能模块」在需求规划阶段是不确定的，直到业务域研发人员在完成上一个项目交付并介入进行评估后才能识别，以至于对中台各功能模块来说，信息延迟效应明显，计划的波动性和调整的复杂度都很大。在中台侧增加人员投入，只能缓解波峰期间的症状（增加的人数仅相当于可额外承接的项目数），难以彻底根治，甚至还会在波谷期间出现资源闲置，使得各方对中台管理持续保持关注。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image02-5.png" alt=""></p>

<p><center style="font-size:10">图2. 中台支持多个业务子域导致交付周期拉长</center></p>

<p>在图2 所呈现的信息中，我们可以看到，尽管中台每个人的资源利用率都是 100%，但单个需求的交付周期，依然可能因为参与者被其他需求占用而变得很长。</p>

<h2 id="3">3. 各业务旱涝不均，价值交付低</h2>

<p>无论是经过了友好协商，抑或是协商不成先到先得，结果都只是在一定时段（我们是按月进行规划）内「让一部分业务先富起来」，而没有纳入中台各子域 backlog 的需求，则会被搁置。</p>

<p>一方面，尽管其中一些需求在本业务域的 backlog 中是高优先级的，但发起方（业务子域）技术团队只能做一些力所能及（不依赖中台模块）的工作，导致该业务域的发展进程放缓，业务子域成员的成就感不足。</p>

<p>另一方面，业务域接受了现状，做了需求妥协后，对中台交付的期望值较高（甚至会以倒排期作为交换条件），导致中台技术团队加班严重。</p>

<h1 id="">三、尝试解决</h1>

<p>基于上述问题，结合各方的反馈，我们认为主要是资源协调、目标不一致、排期模式等「协作流」问题，而这也是我们效能团队最为擅长的领域，从这些方面切入的风险较低。于是，我们做了如下尝试，并快速识别出它们的可行性：</p>

<h2 id="1backlog">1. 建立公司级 backlog 运作机制</h2>

<p>措施：借鉴 LeSS（大规模 Scrum）模式，效能团队牵头成立了「高优需求委员会」，定期（按月）对由各业务域提报的高优需求进行排序和决策，取 top20 的高优需求，填充到公司级的 backlog 中，作为核心交付目标（参见：<a href="https://mp.weixin.qq.com/s/DHt2pQihA_oLQNGXVIXZvQ">大规模产品待办列表处理策略—需求分级</a>）。</p>

<p>优点：将业务目标具象为 backlog 中的需求条目，以此来牵引全公司的产能配置，各方观点快速对齐，减少协调和内耗，工作变得更聚焦。</p>

<p>缺点：我们主要以功能的影响面和用户数等因素来评判需求的优先级程度，未考虑到尚未形成规模的新兴业务，当下仅有潜在价值，故未能进入 backlog 进行探索和试错，导致部分功能错过市场窗口。</p>

<h2 id="2okr">2. 建立基于 OKR 的目标对齐机制</h2>

<p>措施：效能团队定期组织「世界咖啡」活动，由各业务域产品负责人，根据业务进度及目标（有赞使用 OKR 来设定关键目标）向下拆解出项目（及大致描述），进行展示和讲解，中台各功能模块团队根据综合情况评估，是否存在可被沉淀的通用能力，如果双方达成共识，则该需求会被纳入双方后续的规划中（参见：<a href="https://mp.weixin.qq.com/s/dip-LmH1CzJGbKVuvhaXng">大规模产品技术团队需求管理实践</a>）。</p>

<p>优点：业务域在阶段性目标的制定之初，就能排除掉中台无法提供支持的需求，将风险前置，减少了目标层面的摩擦。</p>

<p>缺点：这种方式对产品负责人的规划能力有非常大的考验，且市场变化较快，在规划外临时发起的需求较多，很多时候难以完全按照规划执行落地。</p>

<h2 id="3">3. 中台按比例支持各业务域</h2>

<p>措施：业务域和中台就资源投入的分配比例达成一致。在需求规划（按月）时，按契约提供相应的资源，以支持各方的最高优需求（超出部分不再支持）。若有更多资源诉求，各业务域之间可相互协商调配（中台不介入）。</p>

<p>优点：由于是事前协商和平衡，故每个业务域都会被顾及到。</p>

<p>缺点：会出现「算账」行为。即：当多个业务子域同时求助中台某个功能模块时，且业务子域之间无法达成一致时，会要求中台「赊账、借账」。且在需求高峰期，会出现集体兑换过往的欠账，导致瞬时资源需求的峰值远超团队总可用人力（类似于银行挤兑）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image03-3.png" alt=""></p>

<p><center style="font-size:10">图3. 效能平台资源分配设置的示意图</center></p>

<h2 id="4">4. 中台通用能力产品功能拆分</h2>

<p>优点：从产品的视角，明确了中台目标和业务目标之间的关系。</p>

<p>缺点：尽管产品拆分干净了，但是在架构和代码层面，依旧是严重耦合的，无法解决实际问题。</p>

<h2 id="5">5. 研发共建</h2>

<p>以上尝试大多只是改变了「协作流」，故均未能解决当时中台卡点的问题。我们根据杨三角理论，认定协同中台的改进工作要从「提升组织能力」的层面出发，大致可以分为「技术框架、工具链支持、主观能动性」三个方面。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image04-3.png" alt=""></p>

<p><center style="font-size:10">图4. 从组织能力层面改进中台协同问题</center></p>

<p>具体来说，先引导研发人员放弃本位主义思想，建立以业务目标为导向，一方面，鼓励中台各功能模块团队开放代码仓库并提供咨询服务，另一方面，鼓励业务子域团队在中台的帮助下去中台仓库中写代码。</p>

<p>优点：业务子域团队无须再依赖中台，能够自闭环地工作，缩短了需求的等待周期，提升了业务项目的交付效率。同时，中台研发人员精力得以释放，不再受困于业务项目，可以投入更多资源完善中台能力。</p>

<p>缺点：业务子域团队需要花一段时间来储备中台技术能力，在刚开始阶段会增加研发时长。但磨刀不误砍柴工，随着对中台越来越熟悉，该项风险会逐渐缓解。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image05-3.png" alt=""></p>

<p><center style="font-size:10">图5. 关于研发共建的系统思考</center></p>

<h1 id="">四、实践效果</h1>

<p>通过采用共建（v1）的尝试，需求的整体交付周期（= 等待周期 + 处理周期）缩短了 10%。其中，等待周期缩短了 30%。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image06-2.png" alt=""></p>

<p><center style="font-size:10">图6. 需求交付周期缩短了 10%</center></p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image07-1.png" alt=""></p>

<p><center style="font-size:10">图7. 等待周期缩短了 30%</center></p>

<p>后来，随着核心问题通过共建（v1）方式得以解决之后，协同中台的过程中，又出现了新问题，包括：业务技术对编码质量（如：单测覆盖率）等工程化的要求和门禁条件与中台不同、底层代码改动的回归质量未做收口控制、代码稳定性要求、如何降低学习中台的成本（参见：图5 中的「学习成本」悬摆）等。</p>

<p>于是，我们后续又把共建继续往前推进到了 v2 阶段。如果读者想知道我们在共建（v2）阶段具体做了哪些改进来提升中台效能的，那就烦请坐等下回分解吧。如读者有中台协同相关的话题，欢迎在底部留言，我们做进一步交流。</p>

<h1 id="">延伸阅读</h1>

<ul>
<li><a href="https://mp.weixin.qq.com/s/aSOo7pVRIPIF5E-GSh2Acw">效能指标「研发浓度」在项目度量中的应用</a></li>
<li><a href="https://mp.weixin.qq.com/s/a4g-PrDFwZXx2aeCW6eN_g">有赞效能数据赋能实战</a></li>
<li><a href="https://mp.weixin.qq.com/s/YNySWTU5mz8Upov1dhYhwA">项目制实践如何助力组织进化</a></li>
<li><a href="https://mp.weixin.qq.com/s/VdMpmqRc2N1gmKBnxXkOig">有赞如何打造高绩效的千人技术团队？</a></li>
<li><a href="https://mp.weixin.qq.com/s/GqKG9b2JCiquSya_BJWRPw">敏捷与 OKR：系统思考与组织设计的艺术</a></li>
<li><a href="https://mp.weixin.qq.com/s/DHt2pQihA_oLQNGXVIXZvQ">大规模产品待办列表处理策略—需求分级</a></li>
<li><a href="https://mp.weixin.qq.com/s/dip-LmH1CzJGbKVuvhaXng">大规模产品技术团队需求管理实践</a></li>
</ul>

<blockquote>
  <p>如果读者对效能改进也有兴趣，欢迎加入有赞效能改进团队，请将简历投递至：xuyuhan@youzan.com，我们共同探讨和实践。</p>
</blockquote>]]></content:encoded></item><item><title><![CDATA[逻辑表在OLAP场景下的应用与实现]]></title><description><![CDATA[大数]]></description><link>https://tech.youzan.com/luo-ji-biao-zai-olapchang-jing-xia-de-ying-yong-yu-shi-xian/</link><guid isPermaLink="false">c6355838-9e99-49a6-98c8-70518bfa05e0</guid><category><![CDATA[大数据]]></category><category><![CDATA[大数据]]></category><category><![CDATA[逻辑表]]></category><category><![CDATA[统一数据服务]]></category><dc:creator><![CDATA[xiejialong]]></dc:creator><pubDate>Wed, 09 Feb 2022 07:16:32 GMT</pubDate><content:encoded><![CDATA[<h1 id="">一.背景</h1>

<p>数据中心为微商城和零售的商家后台分别建立了一套数据模型、 数据服务， 在此基础上分别建立了一套数据分析的产品体系，随着微商城和零售慢慢往连锁版本融合并伴随着新业务接入，目前的开发模式遇到了以下痛点：</p>

<ol>
<li>两套数据服务有很多相同之处，重复烟囱式的开发，造成了人力资源浪费，而且开发效率低，从数据开发到最终交付数据服务，需要经历较长的周期；  </li>
<li>两套数据模型在指标上存在大量重合，相同的指标的重复开发，增加了开发和维护成本，且存在口径不一致的风险，导致众多线上咨询，损害了商家对数据可靠性的信任。</li>
</ol>

<p>基于上述痛点，数据应用团队搭建了统一的数据模型和数据服务。本文将介绍在搭建统一数据模型的过程中遇到的问题并给出相应的解决方案。</p>

<h1 id="">二.统一数据模型实现</h1>

<p>上层的数据分析模型会在某个主题下，对多个主题域的数据指标进行分析，因此上层数据模型需要包含丰富的指标。
本文将以kylin作为在线存储的选型为例，分享统一数据模型建设中逻辑表的建设实践。</p>

<h2 id="">一.大宽表解决方案</h2>

<p><img src="https://tech.youzan.com/content/images/2022/01/p1-1.png" alt="大宽表解决方案">
                   图一 大宽表解决方案<br>
方案一是日常数据开发中比较常规的一种操作方式，将各个业务域的明细表进行汇合，生成一张稀疏的明细大宽表，这里的稀疏指的是每行的记录只写入自己业务域的指标和维度，其他业务域的指标按空处理。</p>

<p>最终大宽表的数据量为各个业务域的数据量相加之和。在hive层经过ETL后，将大宽表导入kylin中，根据业务对cube进行建模。</p>

<p>大宽表方案的优点为方案简单，且分页排序等复杂的查询场景sql也会比较简单。
但缺点也很明显：</p>

<ol>
<li>刷数据问题，其中任意一个业务域的数据出现问题，需要数据修复，需要重新构建上述的大宽表，并且kylin上需要对整个大宽表的指标进行计算，整个数据恢复时长较长。  </li>
<li>稀疏性导致的数据倾斜，比如日志域的数据量是几十亿，那么自然会出现几十亿个支付相关的空字段，这些大量的空字段导致kylin构建字典时的数据倾斜。  </li>
<li>数据产出并发度低，大宽表构建任务依赖最晚产出的明细表，某个域产出延迟，导致多个业务域受到影响 <br>
指标复用能力较弱，有新的业务需要和其他业务域进行组合时需要重新构建cube，容易产生数据指标口径不一致、指标重复计算等问题。</li>
<li>底层模型可扩展性差，表结构一旦确定后续增加指标或维度时，开发、构建数据等成本都很高</li>
</ol>

<p>这些缺点让大宽表处于用时一时爽，但维护起来问题重重的困境。</p>

<h2 id="">二.分域解决方案</h2>

<p><img src="https://tech.youzan.com/content/images/2022/01/p2.png" alt="数据服务架构">
                                                       图二 基于逻辑表数据服务架构</p>

<p>为了解决底层指标的统一和解决大宽表刷数资源问题，我们在各个主题下为各个域设计了相应的物理表，各自根据业务特性进行cube设计，
这样做的好处在于：</p>

<ol>
<li>各个域的模型更稳定更聚焦，简洁清晰，容易维护和扩展指标。  </li>
<li>各个域耦合度低，不存在大宽表依赖最晚产出的明细表的问题，各个域可以并发构建。  </li>
<li>分域后各Cube构建时间短，恢复数据效率高。</li>
</ol>

<p>但这些分域的物理表在对上层应用提供服务时并不友好，我们在统一数据服务中根据配置将各个物理表映射到一张逻辑表中，通过逻辑视图对外提供服务，让其既保持宽表的便捷性，又能提供高效的查询性能。</p>

<h1 id="">三、逻辑表实现</h1>

<h2 id="">一.数据库视图</h2>

<p>最简单直白的实现方式是通过join的能力将各个域的数据在计算时合并到一起，生成一个视图，所有的查询都转换成基于该视图的查询。</p>

<p>但产生的问题是join性能较差，在实际测试过程中某些场景多个域的join相比于宽表模式查询时延会高几十秒。对于在线场景，这样的性能的损耗是完全不能接受的。</p>

<p>此外对于跨数据源的场景，数据库视图也是行不通的，对于后续数据接入的扩展性限制较强。</p>

<h2 id="">二.服务层处理逻辑视图</h2>

<p>对于数据库视图的实现方式，虽然简单，但是有各种限制。于是我们决定在服务层自行实现逻辑视图，通过规则配置，将物理表字段映射到逻辑表的字段上，查询时正常查询逻辑表，底层引擎自动根据规则进行sql重写，尽可能的将查询下推到数据库中，之后在服务层实现二次计算，比如结果集归并、复合指标计算、排序等。这个过程根据业务场景可以有更多的优化空间和扩展性。
<img src="https://tech.youzan.com/content/images/2022/02/--20220209-151235.png" alt="逻辑表配置">
      图三 统一数据服务中逻辑表配置</p>

<p>逻辑表处理流程大致分以下几步</p>

<p>1.首先我们解析逻辑sql，将其解析成抽象语法树，然后对其进行预处理。比如对于复合指标，我们会校验字段内容是否都属于同一个物理表，如果存在跨数据源的复合指标，会将复合指标进行拆分成多个查询字段。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/p4.png" alt="接入现状3"></p>

<p>                                            图四 复合指标预处理</p>

<p>2.对于大数据量的分页排序的场景，如果简单地在服务端进行数据聚合后再进行分页，会退变成数据库视图的模式，服务端的计算和内存压力将会特别大。</p>

<p>由于当前场景基本都是对于单域指标的排序，对于单域的指标的分页排序，可将分页排序下推至排序的域。</p>

<p>此时可认为对当前分页排序出来的结果进行补充其他域的数据，也就是将出现在group by字段中的值放到其他域的过滤性条件中，充分利用索引，避免全表扫描。</p>

<p>在分页排序的场景下，因为各个域的数据是不同的，在分页排序场景下总数的计算难以从一个域中获得，将会出现按照不同域的指标进行排序时数据总数是不同的，并且分页的结果也是缺失的，无法通过其他域的数据进行补全。</p>

<p>针对维度缺失问题，我们通过动态生成数据维表，查询时进行补全维度。</p>

<p>在数据生产过程中除了各个事实表的建设，额外将多个域的维度数据合并成一个维表，动态生成只包含维度信息的维表，在查询时对缺失的维度进行补充。
<img src="https://tech.youzan.com/content/images/2022/01/p5.png" alt="分域数据维度补全">
                               图五 分域数据维度补全实现</p>

<p>这样做的优点在于：</p>

<ol>
<li>各个域的cube构建没有相互依赖  </li>
<li>不存在写入大量无用数据的问题  </li>
<li>数据查询性能损耗较小，只有在必要时才需要进行维度补全，维度补全过程不再需要所有域的数据进行join再去重。</li>
</ol>

<p>与此同时该方案存在的缺点是：</p>

<ol>
<li>逻辑表需要额外依赖维度表，查询的复杂度会提升  </li>
<li>某个域的数据出现问题时，不仅需要重跑当前域的数据，还需要重跑维表。</li>
</ol>

<p>3.其他未处理过的物理表会根据指标的逻辑字段和物理字段的映射关系进行各自表上的查询字段改写。因为各个域的指标之间是相互独立的，不会出现一个逻辑指标存在多个域之中，这样就能保证即使是去重指标，我们也可以轻松地下推到数据库中执行。同理我们将过滤条件，聚合条件根据逻辑字段和物理字段的映射关系进行重写，如果遇到某个过滤维度只存在部分物理表中，那么不存在该维度映射的物理表将会裁剪该维度过滤条件。通过上述方式我们将生成物理执行sql集，之后并发地去执行这些sql。</p>

<p>4.服务端获取到各个域的数据后根据预处理的sql，对结果集进行数据归并、复合指标二次计算、排序等操作。</p>

<p>整体流程如下：</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/p6.png" alt="整体流程"></p>

<p>接下来，我们举个简单的例子来帮助解释上面的流程。对于商品主题下的分析有两个主题域</p>

<p>交易域:</p>

<pre><code>CREATE TABLE `dm.TRADE`(  
`shop_id` bigint COMMENT '店铺id',
`goods_id` bigint COMMENT '商品id',
`sku_id` bigint COMMENT '规格id',
`channel` string COMMENT '渠道标识',
`online` bigint COMMENT '线上线下标识',
`placed_order_uv` string COMMENT '商品下单人数',
`placed_order_cnt` string COMMENT '商品下单订单数 ',
`placed_order_amt` bigint COMMENT '商品下单金额 ',
`placed_sku_cnt` bigint COMMENT '商品下单商品件数')
COMMENT '商品交易模型'  
</code></pre>

<p>日志域:</p>

<pre><code>CREATE TABLE `dm.FLOW`(  
`shop_id` bigint COMMENT '店铺id',
`goods_id` bigint COMMENT '商品id',
`channel` string COMMENT '渠道标识',
`uuid` bigint COMMENT '商品详情页访客id',
`pv` bigint COMMENT '商品详情页访问PV',
`keep_duration` bigint COMMENT '商品详情页停留时长')
COMMENT '商品流量模型'  
</code></pre>

<p>加购域</p>

<pre><code>CREATE TABLE `dm.ADDCART`(  
    `shop_id` bigint COMMENT '店铺id',
    `goods_id` bigint COMMENT '商品id',
    `sku_id` bigint COMMENT '规格id',
    `channel` string COMMENT '渠道标识',
    `add_cart_sku_cnt` bigint COMMENT '加购商品件数',
    `add_cart_uv` string COMMENT '加购人数')
COMMENT '商品加购模型'  
</code></pre>

<p>这三个域通过字段映射可配置生成一张逻辑表</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/p7.png" alt="物理表和逻辑表映射"></p>

<p>                                              图五 物理表和逻辑表映射</p>

<p>对于查询</p>

<pre><code>select shop_id,goods_id,  
count(distinct placed_order_uv) as order_uv,  
count(distinct placed_order_uv)/count(distinct flow_uuid) as paid_rate，  
sum(pv)/count(distinct flow_uuid) as uv_rate，  
count(distinct add_cart_uv) as add_cart_uv  
from logic_goods where shop_id=123 group by shop_id,goods_id order by order_uv limit 5 offset 10  
</code></pre>

<p>会转换成如下查询：</p>

<p>先执行order by域数据</p>

<pre><code>物理查询sql1：
select shop_id,goods_id,count(distinct placed_order_uv) as order_uv,count(distinct placed_order_uv) as tmp_measure_01,  
from dm.TRADE  
where shop_id=123  
group by shop_id,goods_id order by order_uv limit 5 offset 10  
</code></pre>

<p>查询结果为</p>

<table>
<thead>
<tr>
<th id="shop_id" style="text-align:left;"> shop_id   </th>
<th id="goods_id" style="text-align:left;"> goods_id  </th>
<th id="order_uv" style="text-align:left;"> order_uv </th>
<th id="tmp_measure_01" style="text-align:left;">tmp_measure_01</th>
</tr>
</thead>

<tbody>
<tr>
<td style="text-align:left;"><p>123</p></td>
<td style="text-align:left;"><p>1</p></td>
<td style="text-align:left;"><p>1</p></td>
<td style="text-align:left;"><p>1</p></td>
</tr>

<tr>
<td style="text-align:left;"><p>123</p></td>
<td style="text-align:left;"><p>2</p></td>
<td style="text-align:left;"><p>2</p></td>
<td style="text-align:left;"><p>2</p></td>
</tr>

<tr>
<td style="text-align:left;"><p>123</p></td>
<td style="text-align:left;"><p>3</p></td>
<td style="text-align:left;"><p>3</p></td>
<td style="text-align:left;"><p>3</p></td>
</tr>

<tr>
<td style="text-align:left;"><p>123</p></td>
<td style="text-align:left;"><p>4</p></td>
<td style="text-align:left;"><p>4</p></td>
<td style="text-align:left;"><p>4</p></td>
</tr>

<tr>
<td style="text-align:left;"><p>123</p></td>
<td style="text-align:left;"><p>5</p></td>
<td style="text-align:left;"><p>5</p></td>
<td style="text-align:left;"><p>5</p></td>
</tr>

</tbody>
</table>

<p>接下来处理其他域的数据</p>

<pre><code>物理查询sql2：
select shop_id,goods_id ,count(distinct flow_uuid) as tmp_measure_02,sum(pv)/count(distinct flow_uuid) as uv_rate from dm.FLOW  
where kdt_id=123 and goods_id in(1,2,3,4,5)  
group by shop_id,goods_id  
物理查询sql3：
select shop_id,goods_id ,count(distinct add_cart_uv) as tmp_measure_02 from dm.ADDCART  
where kdt_id=123 and goods_id in(1,2,3,4,5)  
group by shop_id,goods_id  
</code></pre>

<p>之后我们通过预处理的sql对结果集进行归并。</p>

<pre><code>select kdt_id,goods_id ,sum(order_uv),sum(tmp_measure_01)/sum(tmp_measure_02) as paid_rate ,sum(uv_rate) as uv_rate from logic_goods group by kdt_id,goods_id  
</code></pre>

<h1 id="">四.效果</h1>

<p>我们以店铺主题下的某个分析模型为例，对比一下大宽表模式和逻辑表模式的性能。</p>

<p>表一：构建性能</p>

<table>
<thead>
<tr>
<th id="<big>模式</big>" style="text-align:left;"><big>模式</big>    </th>
<th id="<big>构建前置依赖时长</big>" style="text-align:left;"><big>构建前置依赖时长</big>    </th>
<th id="<big>构建时长" style="text-align:left;"><big>构建时长  </big></th>
<th id="<big>构建完成时间</big>" style="text-align:left;"><big>构建完成时间</big> </th>
</tr>
</thead>

<tbody>
<tr>
<td style="text-align:left;"><p>大宽表    </p></td>
<td style="text-align:left;"><p>2小时    </p></td>
<td style="text-align:left;"><p>40分钟</p></td>
<td style="text-align:left;"><p>每天5：30</p></td>
</tr>

<tr>
<td style="text-align:left;"><p>逻辑表    </p></td>
<td style="text-align:left;"><p>无  </p></td>
<td style="text-align:left;"><p>平均构建时长20分钟</p></td>
<td style="text-align:left;"><p>所有域构建最晚时间为4：30，大部分在3点之前可产出</p></td>
</tr>

</tbody>
</table>

<p>表二：查询性能</p>

<table>
<thead>
<tr>
<th id="<big>模式_</big>" style="text-align:left;"> <big>模式 </big> </th>
<th id="<big>tp95</big>" style="text-align:left;"> <big>TP95</big>  </th>
<th id="<big>大于1s慢查次数占比</big>" style="text-align:left;"> <big>大于1S慢查次数占比</big> </th>
</tr>
</thead>

<tbody>
<tr>
<td style="text-align:left;"><p>大宽表     </p></td>
<td style="text-align:left;"><p>550ms </p></td>
<td style="text-align:left;"><p>5%                 </p></td>
</tr>

<tr>
<td style="text-align:left;"><p>逻辑表 </p></td>
<td style="text-align:left;"><p>350ms </p></td>
<td style="text-align:left;"><p>整体低于0.2%       </p></td>
</tr>

</tbody>
</table>

<h1 id="">五.未来的展望</h1>

<p>现逻辑表目前更多解决的是同维跨域的问题，后续可支持更多的场景如智能查询加速等。此外当前二次计算的算子都是在服务端通过自定义算子实现，对于calcite的使用更多的是在sql解析与改写，后续可完整地使用calcite的计算能力，丰富逻辑表的查询能力。</p>]]></content:encoded></item><item><title><![CDATA[效能指标「研发浓度」在项目度量中的应用]]></title><description><![CDATA[<blockquote>
  <p>文 | 费解 on 效能改进</p>
</blockquote>

<h1 id="1">1. 背景</h1>

<p>在研发管理领域，业界一直在试图寻找可以衡量研发交付效率的指标。常见的指标有：吞吐率（多）、研发周期（快）、资源利用率（省）。然而，在实践中，我们发现，上述三项无法直接作为指导改进的北极星指标：</p>

<p>1）吞吐率，在一段时间内交付项目的个数，是产品需求方关注的指标。若项目未交付，则不落入统计，也就无法发现问题和采取行动。而一旦交付，就错过了采取行动的时机。该指标是个滞后指标，它只关注项目的终点，犹如刻舟求剑，可参考性较差。见图1中，4月份吞吐率为0，但并不意味着生产是停滞的，5月份吞吐率为1，也不意味着持续了5个月的项目D是健康的。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01.png" alt=" ">
<center><span style="font-size:10px">图1. 多个项目上线后，被统计在不同月份的吞吐率中</span></center></p>

<p>2）研发周期，基于单个项目计划的起止时间，是由关键路径决定的，项目经理尤为关心。然而，在关键路径上的人员，除了计划内的研发工作之外，又受到项目外的精力牵扯（比如：</p>]]></description><link>https://tech.youzan.com/development-density-index/</link><guid isPermaLink="false">52bdfcd3-90b0-4416-a78d-5edbfe08a0b2</guid><category><![CDATA[效能提升]]></category><category><![CDATA[效能改进]]></category><dc:creator><![CDATA[费解]]></dc:creator><pubDate>Thu, 13 Jan 2022 12:16:28 GMT</pubDate><content:encoded><![CDATA[<blockquote>
  <p>文 | 费解 on 效能改进</p>
</blockquote>

<h1 id="1">1. 背景</h1>

<p>在研发管理领域，业界一直在试图寻找可以衡量研发交付效率的指标。常见的指标有：吞吐率（多）、研发周期（快）、资源利用率（省）。然而，在实践中，我们发现，上述三项无法直接作为指导改进的北极星指标：</p>

<p>1）吞吐率，在一段时间内交付项目的个数，是产品需求方关注的指标。若项目未交付，则不落入统计，也就无法发现问题和采取行动。而一旦交付，就错过了采取行动的时机。该指标是个滞后指标，它只关注项目的终点，犹如刻舟求剑，可参考性较差。见图1中，4月份吞吐率为0，但并不意味着生产是停滞的，5月份吞吐率为1，也不意味着持续了5个月的项目D是健康的。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image01.png" alt=" ">
<center><span style="font-size:10px">图1. 多个项目上线后，被统计在不同月份的吞吐率中</span></center></p>

<p>2）研发周期，基于单个项目计划的起止时间，是由关键路径决定的，项目经理尤为关心。然而，在关键路径上的人员，除了计划内的研发工作之外，又受到项目外的精力牵扯（比如：处理临时突发的线上 bug）和因为他人牵扯而等待（比如：等待联调、等待测试）的影响。单看研发周期，无法评价项目中资源被有效利用的情况。见图2中，甲中途离开处理外部事务，在完成任务后等待乙来接棒。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image02.png" alt=" ">
<center><span style="font-size:10px">图2. 项目受计划外工作牵扯</span></center></p>

<p>3）资源利用率，员工工作投入的饱和度，技术经理在做团队管理时常考虑的指标。这个饱和度特指从工作负荷视角出发，看员工是不是在忙，但容易忽略工作的聚焦程度。见图2中，甲和乙的工作饱和度都很高，但因为参与者的精力分散在多处，并不会对项目B尽快交付有任何帮助。</p>

<p>那么，是否存在一项北极星指标，可以实时反馈研发过程的效率，从而有效采取改进措施呢？</p>

<h1 id="2">2. 指标介绍</h1>

<p>有赞效能改进团队经过不断探索，定义了「研发浓度」指标，作为研发效率的度量。该指标融合前文介绍的吞吐率、研发周期和资源利用率，反映了「为缩短项目周期而投入资源」的决策收益。计算公式如下：</p>

<p><strong>研发浓度  = 项目工作量人日 ÷ ( 研发周期 × 参与人数 ) × 100%</strong></p>

<p>场景a：单人满负荷完成全部工作。这个场景比较简单，聚焦并独立完成一件事，此时的研发浓度是：100%（ = 10人日 ÷ ( 10个工作日 × 1人 ) × 100%）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image03.png" alt=" ">
<center><span style="font-size:10px">图3. 单人满负荷完成全部工作</span></center></p>

<p>场景b：单人拖沓完成全部工作。和图2中出现的问题一样，甲中途离开，去处理项目以外的工作，导致研发周期发生变动（工作量并未发生变化），此时的研发浓度是：66.7%（ = 10人日 ÷ ( 15个工作日 × 1人 ) × 100%），且精力越分散，该项目的浓度越低。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image04.png" alt=" ">
<center><span style="font-size:10px">图4. 单人拖沓完成全部工作</span></center></p>

<p>场景c：两人分工紧密衔接。由于职能或既定工序等原因（比如：甲先负责开发，然后乙负责测试），需要由不同的角色，分别负责先后两道工序，来协同完成工作。从项目的视角来看，甲和乙分别存在等待（尽管人员不会闲置，比如处理一些日常事务性工作，但终究无助于该项目的尽快交付）。此时的研发浓度是：50%（ = 10人日 ÷ ( 10个工作日 × 2人 ) × 100%）。在这种流水线工作模式下，同一时刻始终只有 1 人处于工作状态，故随着参与人数上升，研发浓度下降。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image05.png" alt=" ">
<center><span style="font-size:10px">图5. 两人分工紧密衔接</span></center></p>

<p>场景d：紧后工作部分前置。乙的 4 人日工作，是甲的紧后工作。如果乙想办法将一部分准备工作前置（比如：提前写测试用例），就能使研发周期缩短。此时的研发浓度是：62.5%（ = 10人日 ÷ ( 8个工作日 × 2人 ) × 100%）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image06.png" alt=" ">
<center><span style="font-size:10px">图6. 紧后工作部分前置</span></center></p>

<p>场景e：两人并行工作。乙的工作完全不依赖甲（工作节奏完美匹配），甲是唯一的关键路径，乙可以在6个工作日内弹性完成自己的任务，但依然存在等待。该场景在规模较大的项目中经常出现（比如：多名研发人员并行开发）。此时的研发浓度是：83.3%（ = 10人日 ÷ ( 6个工作日 × 2人 ) × 100%）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image07.png" alt=" ">
<center><span style="font-size:10px">图7. 两人并行工作</span></center></p>

<p>场景f：两人各担一半工作。甲和乙能自由地均分任务，尽量不出现某个人成为关键路径的情况，这样能最大程度上缩短研发周期（这在任务层面是一种理想状态，但如果甲和乙是两个跨职能的平行团队则完全是可能的）。此时的研发浓度是：100%（ = 10人日 ÷ ( 5个工作日 × 2人 ) × 100%）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image08.png" alt=" ">
<center><span style="font-size:10px">图8. 两人各担一半工作</span></center></p>

<p>在上述各场景中，我们可以看到，在项目中采取不同的资源利用率策略，会形成不同的研发周期效果，进而影响吞吐率，这就是「研发浓度」所要表达的信息。即：资源的利用率越高（包括：掌握的功能模块和技能越全面、越少被外界打扰、越简洁无依赖的工作流），单个项目的研发周期就越短，研发效率就越高。</p>

<h1 id="3">3. 实践运用</h1>

<p>下图是有赞某业务线在某段时期内的研发浓度统计，其中高亮的红色柱子，体现出浓度最集中（超过该业务线的一半项目）的区间是在 12% ~ 28% 的范围里。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image09.png" alt=" ">
<center><span style="font-size:10px">图9. 研发浓度直方图</span></center></p>

<p>我们从这些项目中找几个案例：</p>

<p>【正面案例】A项目（图10），3人参与（前端×1，后端×1，测试×1），项目周期 20 个工作日，总工作量是 45 人日，计算研发浓度 = 75%（45 人日 ÷ (20 工作日 × 3 人) × 100%）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image10.png" alt=" ">
<center><span style="font-size:10px">图10. A项目甘特图</span></center></p>

<p>【反面案例】B项目（图11），8人参与（前端×3，后端×2，测试×3），项目周期 43 个工作日，总工作量是 26 人日，计算研发浓度 = 8%（26 人日 ÷ (43 工作日 × 8 人) × 100%）。</p>

<p><img src="https://tech.youzan.com/content/images/2022/01/image11.png" alt=" ">
<center><span style="font-size:10px">图11. B项目甘特图</span></center></p>

<p>目测相较于A项目，B项目的复杂度略高一些，跨了3条业务线的协作。分析甘特图，体现出来的可改进点非常有意思，笔者罗列一二，期待读者朋友能留言，参与互动：</p>

<p>a）两位核心开发人员的投入时间相差较大（图中两根最长的蓝色柱子），导致形成关键路径，拉长开发周期。我们的疑问是：后进入者是否被上一个项目所牵绊？本项目是否有必要匆忙启动呢？</p>

<p>b）个别参与开发的成员，工作量只有半天（图中最短的两个蓝色颗粒）。我们的疑问是：他们是否有必要参与项目，其工作能否交接给其他人完成呢？</p>

<p>c）开发和测试在工序上形成明显的交接（图中空心蓝色柱子）。我们的疑问是：是否可以把用例设计工作进行左移，以及在测试阶段提升自动化水平来提升测试效率呢？</p>

<h1 id="4">4. 小结</h1>

<p>「研发浓度」的优势在于，它是一项领先指标，能直接体现任意项目的研发效率，并在过程中进行度量，发现问题可以随时介入并进行改进。希望能借助本文，得到读者朋友的垂青，并将其运用到更广泛的度量场景之中。</p>

<h1 id="">延伸阅读</h1>

<ul>
<li><a href="https://mp.weixin.qq.com/s/JguE5FbiXFKP2l-djBE2dw">效能改进中的度量实践</a></li>
<li><a href="https://mp.weixin.qq.com/s/HBlfhbIW3nR2L_FY2ob8eA">如何在项目管理中进行「系统思考」</a></li>
<li><a href="https://mp.weixin.qq.com/s/NkiqHRBWkesKs5nNVOYDgA">组织级敏捷转型的四个阶段</a></li>
<li><a href="https://mp.weixin.qq.com/s/-SBJN-45fOcBi_xf6zRqQA">如何提升「会议效率」</a></li>
<li><a href="https://mp.weixin.qq.com/s/m_iM252oqtpFF2PWCDiIdA">效能改进的「六项修炼」</a></li>
<li><a href="https://mp.weixin.qq.com/s/TvwUyKKdGdqvCAzuCXnvkA">效能改进之项目例会导入实践</a></li>
<li><a href="https://mp.weixin.qq.com/s/YNySWTU5mz8Upov1dhYhwA">项目制实践如何助力组织进化</a></li>
</ul>

<blockquote>
  <p>如果读者对效能改进也有兴趣，欢迎加入有赞效能改进团队，请将简历投递至：feijie@youzan.com，我们共同探讨和实践。</p>
</blockquote>]]></content:encoded></item><item><title><![CDATA[数据测试方法]]></title><description><![CDATA[<p>有赞数据报表中心为商家提供了丰富的数据指标，包括30+页面，100+数据报表以及400+不同类型的数据指标，它们帮助商家更合理、科学地运营店铺，同时也直接提供分析决策方法供商家使用。并且，每天在跑的底层任务和涉及的数据表已经达到千级别。面对如此庞大的数据体系，作为测试如何制定质量保障策略呢？这篇文章将从：1、有赞数据链路 2、数据层测试 3、应用层测试 4、后续规划四个方面展开</p>

<h1 id="">一、有赞数据链路</h1>

<h2 id="1">1、数据链路介绍</h2>

<p>首先介绍有赞的数据总体架构图：</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/-------23---1--1.png" alt="系统架构图"></p>

<p>自顶向下可以大致划分为应用服务层、数据网关层、应用存储层、数据仓库，并且作业开发、元数据管理等平台为数据计算、任务调度以及数据查询提供了基础能力</p>

<p>以上对整体架构做了初步的介绍，对于质量把控来说，最核心的两个部分是：数据仓库以及数据应用部分。因为这两部分属于数据链路中的核心环节，相对于其他层级而言，日常改动也更为频繁，出现问题的风险也比较大</p>

<h1 id="">二、数据层测试</h1>

<h2 id="1">1、整体概览</h2>

<p>首先，针对数据层的质量保障，可以分成三个方面：数据及时性、</p>]]></description><link>https://tech.youzan.com/shu-ju-ce-shi-fang-fa/</link><guid isPermaLink="false">1da362b8-85fb-48ab-b6b1-bb82f9caca2d</guid><dc:creator><![CDATA[傅宇康]]></dc:creator><pubDate>Thu, 30 Dec 2021 11:53:40 GMT</pubDate><content:encoded><![CDATA[<p>有赞数据报表中心为商家提供了丰富的数据指标，包括30+页面，100+数据报表以及400+不同类型的数据指标，它们帮助商家更合理、科学地运营店铺，同时也直接提供分析决策方法供商家使用。并且，每天在跑的底层任务和涉及的数据表已经达到千级别。面对如此庞大的数据体系，作为测试如何制定质量保障策略呢？这篇文章将从：1、有赞数据链路 2、数据层测试 3、应用层测试 4、后续规划四个方面展开</p>

<h1 id="">一、有赞数据链路</h1>

<h2 id="1">1、数据链路介绍</h2>

<p>首先介绍有赞的数据总体架构图：</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/-------23---1--1.png" alt="系统架构图"></p>

<p>自顶向下可以大致划分为应用服务层、数据网关层、应用存储层、数据仓库，并且作业开发、元数据管理等平台为数据计算、任务调度以及数据查询提供了基础能力</p>

<p>以上对整体架构做了初步的介绍，对于质量把控来说，最核心的两个部分是：数据仓库以及数据应用部分。因为这两部分属于数据链路中的核心环节，相对于其他层级而言，日常改动也更为频繁，出现问题的风险也比较大</p>

<h1 id="">二、数据层测试</h1>

<h2 id="1">1、整体概览</h2>

<p>首先，针对数据层的质量保障，可以分成三个方面：数据及时性、完整性、准确性</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/-------20-.png" alt="数据层测试整体概览"></p>

<h2 id="2">2、 数据及时性</h2>

<p>数据及时性，顾名思义就是测试数据需要按时产出。及时性重点关注的三个要素是：定时调度时间、优先级以及数据deadline。其中任务的优先级决定了它获取数据计算资源的多少，影响了任务执行时长。数据deadline则是数据最晚产出时间的统一标准，需要严格遵守</p>

<p>这三要素中，属于“普世规则”且在质量保障阶段需要重点关注的是：数据deadline。那么我们基于数据deadline，针对及时性的保障策略就可分为两种：</p>

<ul>
<li>监控离线数据任务是否执行结束。这种方式依赖于有赞作业开发平台的监控告警，若数据任务在deadline时间点未执行完成，则会有邮件、企微、电话等告警形式，通知到相应人员</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2021/12/deadline.png" alt="deadline告警"></p>

<ul>
<li>检查全表条数或者检查分区条数。这种方式依赖接口自动化平台，通过调用dubbo接口，判断接口返回的数据指标是否为0，监控数据是否产出</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2021/12/------2.png" alt="及时性接口监控"></p>

<p>其次我们可以关注失败、重试次数，当任务执行过程中出现多次失败、重试的异常情况，可以抛出告警让相关人员感知。这部分的告警是对deadline告警的补充，目前在有赞作业开发平台上也有功能集成</p>

<h2 id="3">3、数据完整性</h2>

<p>数据完整性，顾名思义看数据是不是全，重点评估两点：数据不多、数据不少</p>

<ul>
<li>数据不多：一般是检查全表数据、重要枚举值，看数据有没有多余、重复或者数据主键是否唯一</li>
<li>数据不少：一般是检查全表数据、重要字段（比如主键字段、枚举值、日期等），看字段的数值是否为空、为null等</li>
</ul>

<p>可见数据完整性和业务本身关联度没有那么密切，更多的是数仓表的通用内容校验。所以从一些基础维度，我们可以将测试重点拆成表级别、字段级别两个方向</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/------3.png" alt="数据完整性"></p>

<p>表级别完整性：</p>

<ul>
<li>全表维度，通过查看全表的总行数/表大小，若出现表总行数/总大小不变或下降，说明表数据可能出现了问题</li>
<li>分区维度，通过查看当日分区表的数据行数/大小，若和之前分区相比差异太大（偏大或偏小），说明表数据可能出现了问题</li>
</ul>

<p>目前有赞元数据管理平台已集成相关数据视图：</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/------4.png" alt="数据趋势图"></p>

<p>字段级别完整性：</p>

<ul>
<li>唯一性判断：保证主键或某些字段的唯一性，防止数据重复导致和其他表join之后数据翻倍，导致最终统计数据偏大</li>
</ul>

<p>​      比如判断ods层订单表中的订单号是否唯一，编写sql ：select count(order_no),count(distinct order_no) from ods.xx_order。若两者相等，则说明order_no值是表内唯一的；否则说明order_no表内不唯一，表数据存在问题</p>

<ul>
<li>非空判断：保证重要字段非空，防止空数据造成和表join之后数据丢失，导致最终统计数据偏少</li>
</ul>

<p>​     比如判断ods层订单表中的订单号是否出现null，编写sql：select count(*) from ods.xx_order where order_no is null,。若结果等于0，则说明order_no不存在null；若结果大于0，则说明order_no存在null值，表数据存在问题</p>

<ul>
<li>枚举类型判断：保证枚举字段值都在预期范围之内，防止业务脏数据，导致最终统计结果出现遗漏/多余的数据类型</li>
</ul>

<p>​     比如判断ods层订单表中的shop_type字段中所有枚举值是否符合预期，编写sql：select shop_type from ods.xx_order group by shop_type。分析查询结果是否满足预期，确保不会出现遗漏/多余的枚举类型</p>

<ul>
<li>数据有效性判断：判断数据格式是否满足预期，防止字段的数据格式不正确导致数据统计的错误以及缺失。常见的有日期格式yyyymmdd</li>
</ul>

<p>一旦出现数据完整性问题，对数据质量的影响很大。所以完整性策略更适用于ods层，因为我们更期望从源头发现并解决数据不合理问题，及时止损，避免脏数据进入下游之后，数据污染扩大</p>

<p>另外，我们看到完整性校验内容逻辑简单，且比较固定，稍微进行简单的抽象就能将其模板化。那么作为测试，我们更倾向于将数据完整性校验做成工具。目前有赞“数据形态工具”已经落地，下面给出我的一些思路：</p>

<ol>
<li>针对所有表来说，普世性的规则，比如表主键的唯一性  </li>
<li>针对不同类型比如数值、String、枚举、日期格式类型，列举出常见的数据判断规则  </li>
<li>给每项规则进行等级划分，比如表的主键不唯一，记为critical。String类型字段的空值比例大于70%，记为warning  </li>
<li>根据表数据是否满足上述这些规则，最终落地一份可视化报告，测试人员可根据报告内容评估数据质量</li>
</ol>

<p><img src="https://tech.youzan.com/content/images/2021/12/-------1.png" alt="数据形态报告"></p>

<h2 id="4">4、数据准确性</h2>

<p>数据准确性，顾名思义数据要“准确”。“准确”这个概念比较抽象，因为我们很难通过一个强逻辑性的判断，来说明数据有多准，大部分都存在于感性的认知中。所以准确性测试也是在数据质量保障过程中思维相对发散的一个方向。经过总结，我们可以从字段自身检查、数据横向对比、纵向对比、code review等方面，去把控数据的准确性，这些测试点和业务的关联也比较密切</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/------5.png" alt="数据准确性"></p>

<h3 id="41">4.1 自身检查</h3>

<p>数据自身检查，是指在不和其他数据比较的前提下，用自身数据来检查准确的情况，属于最基本的一种检查。常见的自身检查包括：检查数值类指标大于0、比值类指标介于0-1范围。这类基础规则，同数据完整性，也可以结合“数据形态工具”辅助测试</p>

<p>举个例子，比如针对订单表，支付金额必然是大于等于0，不会出现负数的情况，编写sql：select count(pay_price) from dw.dws_xx_order where par = 20211025 and pay_price&lt;0, 若结果为0，说明支付金额都是大于0，满足预期；否则若count结果大于0，说明数据存在问题</p>

<h3 id="42">4.2 表内横向数据对比</h3>

<p>表内横向对比可以理解为同一张表内，业务上相关联的两个或多个字段，他们存在一定的逻辑性关系，那么就可以用来做数据对比</p>

<p>比如针对订单表，根据实际业务分析易得：针对任何一家店铺的任意一款商品，都满足订单数 >=下单人数，编写sql：</p>

<p>select kdt_id,goods_id,count(order_no),count(distinct buyer_id) from dw.dws_xx_order</p>

<p>where par = '20211025' <br>
group by kdt_id,goods_id <br>
having count(order_no)&lt;count(distinct buyer_id)</p>

<p>若查询结果不存在记录，则说明不存在 订单数&lt;下单人数，反向说明订单数>=下单人数，则符合预期；否则若查询结果的记录大于0，则不符合预期</p>

<h3 id="43">4.3 表间横向数据对比</h3>

<p>表间横向对比可以理解为两张表或多张表之间，其中具有业务关联或者业务含义一致的字段，可以用来做数据对比</p>

<ul>
<li>同类型表之间对比：针对hive里的支付表A和支付表B，里面都有支付金额字段，那么同样维度下的 表A.支付金额 = 表B.支付金额</li>
<li>多套存储之间对比：比如有赞数据报表中心针对支付表，应用层存储分别用到了mysql和kylin，用作主备切换，那么相同维度下的kylin-表A.支付金额 = mysql-表B.支付金额</li>
<li>多个系统之间对比：跨系统之间，比如有赞的数据报表中心和crm系统，两个系统都有客户指标数据，那么相同维度下的数据报表中心-表A.客户指标 = crm-表B.客户指标</li>
</ul>

<p>我们深度剖析数据横向对比的底层逻辑，本质就是两张表的不同字段，进行逻辑运算符的比较，也比较容易抽象成工具。目前有赞“数据比对工具”已经落地，下面给出我的一些思路：</p>

<ol>
<li>输入两张表，分别设置两表的主键  </li>
<li>输入两张表中需要对比的字段，且设置对比的运算符，比如>、=、&lt;  </li>
<li>根据设置的规则，最终数据对比通过、不通过的记录，落地一份可视化报告，测试人员可根据报告内容评估数据质量</li>
</ol>

<p><img src="https://tech.youzan.com/content/images/2021/12/-------2.png" alt="数据对比工具"></p>

<h3 id="44">4.4 纵向数据对比</h3>

<p>纵向对比就是上下游的数据比较，目的是确保重要字段在上下游的加工过程中没有出现问题</p>

<p>比如数仓dw层存在订单的明细表，数据产品dm层存在订单数的聚合表，那么二者在相同维度下的数据统计结果，应该保持一致</p>

<h3 id="45codereview">4.5 code review</h3>

<p>首先，在进行code review之前的需求评审阶段，我们先要明确数据统计的详细口径是什么，下面举两个实际的需求例子。</p>

<ul>
<li><p>需求1： （错误示例）统计时间内店铺内所有用户的支付金额。问题所在：需求描述太过于简洁，没有阐述清楚数据统计的时间维度以及过滤条件，导致统计口径不清晰，要求产品明确口径</p></li>
<li><p>需求2： （正确示例）有赞全网商家域店铺维度的离线支付金额。支持自然日、自然周、自然月。统计时间内，所有付款订单金额之和（剔除抽奖拼团、剔除礼品卡、剔除分销供货订单）</p></li>
</ul>

<p>明确需求之后，下面详细介绍code review的一些常见关注点：</p>

<p>1）关联关系 &amp; 过滤条件</p>

<ul>
<li>关联表使用 outer join 还是 join，要看数据是否需要做过滤</li>
<li>关联关系 on 字句中，左右值类型是否一致</li>
<li>关联关系如果是1：1，那么两张表的关联键是否唯一。如果不唯一，那么关联会产生笛卡尔导致数据膨胀</li>
<li>where 条件是否正确过滤。以上述需求为例子，关注sql中是否正确剔除抽奖拼团、礼品卡和分销供货订单</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2021/12/sql1.png" alt="where条件过滤"></p>

<p>2）指标的统计口径处理</p>

<p>数据指标的统计涉及到两个基本概念：</p>

<ul>
<li>可累加指标：比如支付金额，浏览量等，可以通过简单数值相加来进行统计的指标，针对这类指标，sql中使用的函数一般是sum</li>
<li>不可累加指标：比如访客数，不能通过简单相加，而是需要先去重再求和的方式进行统计，针对这类指标，sql中一般使用count(distinct )</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2021/12/sql2.png" alt="统计逻辑"></p>

<p>3）insert插入数据</p>

<ul>
<li>是否支持重跑。等价于看插入时是否有overwrite关键字，如果没有该关键字，重跑数据（多次执行该工作流）时不会覆盖脏数据，而是增量往表插入数据，进而可能会导致最终数据统计翻倍</li>
<li>插入的数据顺序和被插入表结构顺序是否完全一致。我们要保证数据字段写入顺序没有出错，否则会导致插入值错乱</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2021/12/sql3.png" alt="insert插入"></p>

<h1 id="">三、应用层测试</h1>

<h2 id="1">1、整体概览</h2>

<p><img src="https://tech.youzan.com/content/images/2021/12/------6.png" alt="应用层测试整体概览"></p>

<p>基本的前端页面 + 服务端接口测试，和一般业务测试关注点是一致的，不再赘述。本篇重点展开“数据应用“测试需要额外关注的地方</p>

<h2 id="2">2、 降级策略</h2>

<ul>
<li>在页面新增数据表的时候，需求、技术评审阶段确认是否需要支持“蓝条”的功能，属于“测试左移”</li>
</ul>

<p>蓝条介绍：有赞告知商家离线数据尚未产出的页面顶部蓝条，其中的“产出时间” = 当前访问时间 +2小时，动态计算得到</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/--1.png" alt="降级策略"></p>

<p><img src="https://tech.youzan.com/content/images/2021/12/--.png" alt="蓝条功能"></p>

<ul>
<li>测试比率类指标时，关注被除数 = 0 的特殊场景。在后端code review、测试页面功能阶段，关注该点。目前有赞针对这种情况，前端统一展示的是“-”</li>
</ul>

<p><img src="https://tech.youzan.com/content/images/2021/12/--2.png" alt="比率无法计算场景"></p>

<h2 id="3">3、 主备策略</h2>

<p>遇到有主备切换策略时，测试过程中注意数据正常双写，且通过配置，取数时能在主备数据源之间切换</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/--3.png" alt="主备策略"></p>

<h2 id="4">4、 数据安全</h2>

<p>关注数据查询的权限管控，重点测试横向越权、纵向越权的场景</p>

<h1 id="">四、后续规划</h1>

<ol>
<li>目前在实际项目的数据准确性对比中，数据对比工具因为暂不支持sql函数，所以只能代替50%的手工测试，一些复杂的横向和纵向数据对比还是需要编写sql。后续计划支持sum、count、max、min等sql函数，把工具覆盖范围提升到75%以上，大大降低数据对比的成本  </li>
<li>目前“数据形态报告”、“数据对比工具”更多的运用项目测试当中，后续计划将形态检查和数据对比做成线上巡检，将自动化和数据工具相结合，持续保障数仓表的质量  </li>
<li>目前针对sql code review的方式主要靠人工，我们计划把一些基础的sql检查，比如insert into检查，join on条件的唯一性检查、字段插入顺序检查等作成sql静态扫描，整合到大数据测试服务中，并且赋能给其他业务线</li>
</ol>

<p>最后，欢迎更多的朋友加入有赞业务中台-数据风控测试团队，一起去落地这些规划～
<img src="https://tech.youzan.com/content/images/2021/12/--20211230-191853.png" alt="招聘"></p>]]></content:encoded></item><item><title><![CDATA[Redis源码解析]]></title><description><![CDATA[<h1 id="">一、引言</h1>

<p>作为后端开发，redis是工作中最绕不开的中间件之一，在工作中通常有以下几个常用用途</p>

<p>1.缓存，可以抗十万级别的qps <br>
2.计数器，如点赞数，pv等 <br>
3.分布式锁 <br>
4.限流</p>

<p>另外丰富的redis数据类型支持了一些扩展功能，如排行榜，消息队列，布隆过滤器，位图等等。而redis的底层实现是十分简单的，核心源码也仅有几万行。本文就带大家来领略，小小的redis是如何实现这些复杂功能的~</p>

<p><em>*注:本文介绍的源码为redis 5.0.14版本
*</em></p>

<h1 id="">二、字符串</h1>

<h2 id="c">C语言存储字符串的问题</h2>

<h3 id="1">1.二进制安全</h3>

<p>C语言中表示字符串结尾的符号是'\0',如果字符串本身就具有'\0'字符，就会被截断，即非二进制安全。  </p>

<h3 id="2">2.计算字符串的长度性能低</h3>

<p>C语言中有一个计算字符串长度的函数strlen，但这个函数与Java的不一样，需要遍历整个字符串来计算长度，时间复杂度是O（n），如果需要在循环中计算，性能将十分低下</p>]]></description><link>https://tech.youzan.com/redisyuan-ma-jie-xi/</link><guid isPermaLink="false">4506cac2-364d-46dd-afce-53016f8561b1</guid><dc:creator><![CDATA[jianghongyi]]></dc:creator><pubDate>Sun, 19 Dec 2021 08:13:00 GMT</pubDate><media:content url="https://tech.youzan.com/content/images/2021/12/image-1431.png" medium="image"/><content:encoded><![CDATA[<h1 id="">一、引言</h1>

<img src="https://tech.youzan.com/content/images/2021/12/image-1431.png" alt="Redis源码解析"><p>作为后端开发，redis是工作中最绕不开的中间件之一，在工作中通常有以下几个常用用途</p>

<p>1.缓存，可以抗十万级别的qps <br>
2.计数器，如点赞数，pv等 <br>
3.分布式锁 <br>
4.限流</p>

<p>另外丰富的redis数据类型支持了一些扩展功能，如排行榜，消息队列，布隆过滤器，位图等等。而redis的底层实现是十分简单的，核心源码也仅有几万行。本文就带大家来领略，小小的redis是如何实现这些复杂功能的~</p>

<p><em>*注:本文介绍的源码为redis 5.0.14版本
*</em></p>

<h1 id="">二、字符串</h1>

<h2 id="c">C语言存储字符串的问题</h2>

<h3 id="1">1.二进制安全</h3>

<p>C语言中表示字符串结尾的符号是'\0',如果字符串本身就具有'\0'字符，就会被截断，即非二进制安全。  </p>

<h3 id="2">2.计算字符串的长度性能低</h3>

<p>C语言中有一个计算字符串长度的函数strlen，但这个函数与Java的不一样，需要遍历整个字符串来计算长度，时间复杂度是O（n），如果需要在循环中计算，性能将十分低下</p>

<h3 id="3">3.字符串拼接性能低</h3>

<p>因为C语言字符串不记录长度，对于一个长度n的字符串来说，底层是n+1的字符数组</p>

<pre><code>char a[n+1]  
</code></pre>

<p>如果需要增长字符串，则需要对底层的字符数组进行重分配的操作</p>

<p>接下来由数据结构入手，看看redis是如何解决这几个问题的</p>

<h2 id="21">2.1 数据结构</h2>

<pre><code class="language- ">struct sds{  
    int len; //buf中已占字符数
    int free; //buf中空闲字符数
    char buf[];
}
</code></pre>

<p>除了保存字符串的指针buf，还需要记录使用空间和空闲的空间。redis老版本也是这样设计的，这样的设计解决了开头的三个问题:</p>

<p>1.计算字符串长度的时候，时间复杂度是O(1)</p>

<p>2.使用len变量得出字符串的长度，而不是’\0‘，保证了二进制安全。</p>

<p>3.对于字符串的拼接操作，进行预分配空间，减少内存重分配的次数</p>

<h3 id="">小字符串空间浪费的问题</h3>

<p>在64位系统中，字符串头部的len和free各占四个字节，对于大字符串而言，这个数字还好，但是如果是小字符串呢，比如buf本身只有一个字节，而头部就占了八个字节，肯定不合适。</p>

<p>redis新版本就给了一种方案，根据buf字符串的长度不同，使用不同的结构体存储，同时新增一个单字节变量flags，保存不同的类型。</p>

<p>但是对于那种只有一个字节长的字符串，如何优化呢？ 对于那种小字符串，redis中使用一个字节的标志位flags表示 低三位存储类型（type），高五位存储长度（len)，而高五位 2^5-1=31 可以存储最多31个字节的字符串。</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/Image.png" alt="Redis源码解析"></p>

<p>而大于31个字节的其他几种类型字符串，一个字节存不下，就使用两个变量保存已使用空间和总长度（保留flags字段标识类型，新增len字段标记长度）。
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_13-46-331.png" alt="Redis源码解析"></p>

<p>sdshdr8,sdshdr16,sdshdr32,sdshdr64 结构都是一样的，区别在于存储的变量大小。</p>

<pre><code>struct __attribute__ ((__packed__)) sdshdr5 {  
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {  
    uint8_t len; //已使用
    uint8_t alloc; // 总长度
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {  
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {  
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {  
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
</code></pre>

<h2 id="22">2.2 基本操作</h2>

<p>只介绍扩容操作，其它操作都比较简单，可自行阅读
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_13-48-37.png" alt="Redis源码解析">
扩容源码如下</p>

<pre><code>sds sdsMakeRoomFor(sds s, size_t addlen) {  
    struct sdshdr *sh, *newsh; //定义两个 sdshdr 结构体指针
    size_t free = sdsavail(s); // 获取 s 目前空闲空间长度
    size_t len, newlen; // 前者存储扩展前 sds 字符串长度，后者存储扩展后 sds 字符串长度

    if (free &gt;= addlen) return s; // 如果空余空间足够，直接返回
    len = sdslen(s); // 获取 s 目前已占用空间的长度
    sh = (void*) (s-(sizeof(struct sdshdr))); //结构体指针赋值
    newlen = (len+addlen); // 字符串数组 s 最少需要的长度
    // 根据新长度，为 s 分配新空间所需的大小
    if (newlen &lt; SDS_MAX_PREALLOC) // 如果新长度小于 SDS_MAX_PREALLOC（默认1M），那么为它分配两倍于所需长度的空间
        newlen *= 2; 
    else
        newlen += SDS_MAX_PREALLOC; // 否则，分配长度等于目前长度加上 SDS_MAX_PREALLOC（默认1M）
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;

    newsh-&gt;free = newlen - len;
    return newsh-&gt;buf;
}
</code></pre>

<h1 id="">三、跳跃表</h1>

<p>跳跃表类似一个多层的链表，首先从最高层开始查找，如果下一个节点的值大于要查找的值或者下一个节点为null,则往下一层查找。通过空间换时间的策略，将时间复杂度控制在O(logn)。</p>

<h2 id="31">3.1 一个例子</h2>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_13-50-17.png" alt="Redis源码解析">
例如查找51这个数</p>

<p>首先从第一层开始查找，找到第二个节点，发现后面为null。</p>

<p>从第二层查找 查找到第四个节点，发现后面的节点为61，大于当前的数。</p>

<p>从第三层查找 查找到第六个节点 结束 一共查找四次，比遍历一次少了两次。数据量大的情况下，这个性能会提升的很明显。</p>

<h2 id="32">3.2 跳跃表结构</h2>

<p>首先看一下zskiplistNode的数据结构，zskiplistNode表示跳跃表中的一个节点。</p>

<pre><code>typedef struct zskiplistNode {  
    sds ele;// 数据
    double score; //权重比
    struct zskiplistNode *backward; //后退指针，指向当前节点底层 前一个节点
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 指向当前层的前一个节点
        unsigned long span; //forward 指向前一个节点的与当前节点的间距
    } level[];
} zskiplistNode;
</code></pre>

<p>zskiplist 表示跳跃表</p>

<pre><code>typedef struct zskiplist {  
    struct zskiplistNode *header, *tail; //分别指向头结点和尾结点
    unsigned long length; //跳跃表总长度
    int level; //跳跃表总高度
} zskiplist;
</code></pre>

<p>其中，头节点是跳跃表的一个特殊节点，它的level数组元素个数为64。头节点在有序集合中不存储任何member和score值，ele值为NULL, score值为0；也不计入跳跃表的总长度。头节点在初始化时，64个元素的forward都指向NULL, span值都为0。</p>

<h2 id="33">3.3基本操作</h2>

<h3 id="331">3.3.1 创建跳跃表</h3>

<pre><code>zskiplist *zslCreate(void) {  
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl));
    zsl-&gt;level = 1;
    zsl-&gt;length = 0;
    // 头结点
    zsl-&gt;header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j &lt; ZSKIPLIST_MAXLEVEL; j++) {
        zsl-&gt;header-&gt;level[j].forward = NULL;
        zsl-&gt;header-&gt;level[j].span = 0;
    }
    zsl-&gt;header-&gt;backward = NULL;
    zsl-&gt;tail = NULL;
    return zsl;
}
</code></pre>

<p>简单来说就是创建了头结点，创建了64个level数组。</p>

<h3 id="332">3.3.2 随机层高</h3>

<p>创建和插入节点的之前，当前节点需要在哪几层出现，是通过计算当前节点的level值，
而level值是redis通过伪随机得出的，层数越高，节点出现的概率越小。</p>

<pre><code>int zslRandomLevel(void) {  
    int level = 1;
    while ((random()&amp;0xFFFF) &lt; (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level&lt;ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_13-54-58.png" alt="Redis源码解析"></p>

<h3 id="">层高的数学期望</h3>

<p>高度为1的概率 (1-p)</p>

<p>高度为2的概率 p(1-p)</p>

<p>高度为3的概率 (p2)*(1-p)</p>

<p>….</p>

<p>高度为n的概率(p(n-1))*(1-p)</p>

<p>期望层高 E=1*(1-p)+2p(1-p)+3p2(1-p)... =1/(1-p)</p>

<p>当 p=0.25（redis默认值） 时，跳跃表节点的期望层高为 1/(1-0.25)≈1.33。
即多浪费了30%的空间，redis的跳表使用了较低的空间成本，实现了时间复杂度的大减少</p>

<h3 id="333">3.3.3 插入节点</h3>

<p>插入节点总的来说一共四步</p>

<p>1.查找插入位置 <br>
2.调整高度 <br>
3.插入节点 <br>
4.调整backward</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_13-56-31.png" alt="Redis源码解析"></p>

<p>源码如下：</p>

<pre><code>zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {  
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    // 查找节点
    x = zsl-&gt;header;
    for (i = zsl-&gt;level-1; i &gt;= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        rank[i] = i == (zsl-&gt;level-1) ? 0 : rank[i+1];
        while (x-&gt;level[i].forward &amp;&amp;
                (x-&gt;level[i].forward-&gt;score &lt; score ||
                    (x-&gt;level[i].forward-&gt;score == score &amp;&amp;
                    sdscmp(x-&gt;level[i].forward-&gt;ele,ele) &lt; 0)))
        {
            rank[i] += x-&gt;level[i].span;
            x = x-&gt;level[i].forward;
        }
        update[i] = x;
    }
    /* we assume the element is not already inside, since we allow duplicated
     * scores, reinserting the same element should never happen since the
     * caller of zslInsert() should test in the hash table if the element is
     * already inside or not. */
    //调整高度
    level = zslRandomLevel();
    if (level &gt; zsl-&gt;level) {
        for (i = zsl-&gt;level; i &lt; level; i++) {
            rank[i] = 0;
            update[i] = zsl-&gt;header;
            update[i]-&gt;level[i].span = zsl-&gt;length;
        }
        zsl-&gt;level = level;
    }
    x = zslCreateNode(level,score,ele);
    //插入节点
    for (i = 0; i &lt; level; i++) {
        x-&gt;level[i].forward = update[i]-&gt;level[i].forward;
        update[i]-&gt;level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        x-&gt;level[i].span = update[i]-&gt;level[i].span - (rank[0] - rank[i]);
        update[i]-&gt;level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    for (i = level; i &lt; zsl-&gt;level; i++) {
        update[i]-&gt;level[i].span++;
    }

    x-&gt;backward = (update[0] == zsl-&gt;header) ? NULL : update[0];
    if (x-&gt;level[0].forward)
        x-&gt;level[0].forward-&gt;backward = x;
    else
        zsl-&gt;tail = x;
    zsl-&gt;length++;
    return x;
}
</code></pre>

<h2 id="334">3.3.4.删除节点</h2>

<p>1.查找节点 (同插入节点) <br>
2.删除节点 <br>
3.修改高度 <br>
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_13-57-58.png" alt="Redis源码解析"></p>

<pre><code>void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {  
    int i;
    for (i = 0; i &lt; zsl-&gt;level; i++) {
        if (update[i]-&gt;level[i].forward == x) { // update[i].level[i] 的 forward 节点是 x 的情况，需要更新 span 和 forward
            update[i]-&gt;level[i].span += x-&gt;level[i].span - 1;
            update[i]-&gt;level[i].forward = x-&gt;level[i].forward;
        } else {// update[i].level[i] 的 forward 节点不是 x 的情况，只需要更新 span
            update[i]-&gt;level[i].span -= 1;
        }
    }
    if (x-&gt;level[0].forward) { // 如果 x 不是尾节点，更新 backward 节点
        x-&gt;level[0].forward-&gt;backward = x-&gt;backward;
    } else { // 否则 更新尾节点
        zsl-&gt;tail = x-&gt;backward;
    }
    while(zsl-&gt;level &gt; 1 &amp;&amp; zsl-&gt;header-&gt;level[zsl-&gt;level-1].forward == NULL)
        zsl-&gt;level--; //更新跳跃表 level
    zsl-&gt;length--; // 更新跳跃表长度
}
</code></pre>

<h3 id="345">3.4.5 跳跃表的应用</h3>

<p>zset集合插入第一个元素时，会判断下面两种条件：</p>

<p>zset-max-ziplist-entries 的值是否等于 0； <br>
zset-max-ziplist-value 小于要插入元素的字符串长度。</p>

<p>满足任一条件 Redis 就会采用跳跃表作为底层实现，否则采用压缩列表作为底层实现方式。</p>

<h3 id="346">3.4.6 题外话</h3>

<p>Q:为什么redis使用跳跃表而不是红黑树呢</p>

<p>引用一下原作者的话</p>

<blockquote>
  <p>There are a few reasons:
  They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees. <br>
  A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees. <br>
  They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.About the Append Only durability &amp; speed, I don't think it is a good idea to optimize Redis at cost of more code and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big anyway.About threads: our experience shows that Redis is mostly I/O bound. I'm using threads to serve things from Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with number of cores), and using the "Redis Cluster" solution that I plan to develop in the future.</p>
</blockquote>

<p>简单翻译一下</p>

<p>1.这并不会浪费太多的空间，并且树的高度可以动态调整的。</p>

<p>2.ZRANGE 和 ZREVRANGE命令，跳表性能比红黑树好</p>

<p>3.红黑树比较复杂...作者懒得实现</p>

<h1 id="">四、整数集合</h1>

<p>整数集合（intset）是一个有序的、存储整型数据的结构。
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-47-9.png" alt="Redis源码解析">
conding决定了的element的长度，对应关系如下 <br>
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-48-1.png" alt="Redis源码解析"></p>

<h2 id="41">4.1 基本数据结构</h2>

<pre><code>typedef struct intset {  
    //编码
    uint32_t encoding;
    //元素个数
    uint32_t length;
    // 柔性数组，根据encoding 决定几个字节表示一个数组
    int8_t contents[];
} intset;
</code></pre>

<h2 id="42">4.2 基本操作</h2>

<h3 id="421">4.2.1 查询元素</h3>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-48-45.png" alt="Redis源码解析"></p>

<pre><code>uint8_t intsetFind(intset *is, int64_t value) {  
　　uint8_t valenc = _intsetValueEncoding(value); //判断编码方式
　　//编码方式如果大于当前intset的编码方式，直接返回0。否则调用intsetSearch函数进行查找
　　return valenc &lt;= intrev32ifbe(is-&gt;encoding) &amp;&amp; intsetSearch(is,value,NULL);


static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {  
int min = 0, max = intrev32ifbe(is-&gt;length)-1, mid = -1;  
int64_t cur = -1;  
/*如果intset中没有元素，直接返回0 */
if (intrev32ifbe(is-&gt;length) == 0) {  
　　if (pos) *pos = 0;
　　return 0;
} else {
/* 如果元素大于最大值或者小于最小值，直接返回0 */
if (value &gt; _intsetGet(is,max)) {  
　　if (pos) *pos = intrev32ifbe(is-&gt;length);
　　　　return 0;
} else if (value &lt; _intsetGet(is,0)) {
　　if (pos) *pos = 0;
　　return 0;
    }
}

while(max &gt;= min) { //二分查找该元素  
　　mid = ((unsigned int)min + (unsigned int)max) &gt;&gt; 1;
　　cur = _intsetGet(is,mid);
if (value &gt; cur) {  
　　min = mid+1;
} else if (value &lt; cur) {
　　max = mid-1;
} else {
　　break;
    }
}

if (value == cur) { //查找到返回1，未查找到返回0  
　　if (pos) *pos = mid;
　　return 1;
} else {
　　if (pos) *pos = min;
　　return 0;
    }
  }
}
</code></pre>

<h3 id="422">4.2.2 插入元素</h3>

<p>插入元素比较简单，不再赘述源代码，感兴趣的同学可以查看方法</p>

<pre><code>intset *intsetAdd(intset *is, int64_t value, uint8_t *success);  
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-48-45-1.png" alt="Redis源码解析"></p>

<h3 id="423">4.2.3 删除元素</h3>

<p>删除元素比较简单，不再赘述源代码，感兴趣的同学可以查看方法</p>

<pre><code>intset *intsetRemove（intset *is, int64_t value, int*success）  
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-52-33.png" alt="Redis源码解析"></p>

<h2 id="43">4.3 应用场景</h2>

<p>当Redis集合类型的元素都是整数并且都处在64位有符号整数范围之内时，使用该结构体存储。</p>

<p>在两种情况下，底层编码会发生转换。</p>

<p>1.一种情况为当元素个数超过一定数量之后（默认值为512），即使元素类型仍然是整型，也会将编码转换为hashtable。 <br>
2.往集合中添加了非整型变量</p>

<h1 id="">五、字典</h1>

<p>字典底层类似Java的HashMap，但是扩容的方式有一定的区别。</p>

<h2 id="51">5.1 基本数据结构</h2>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-39-48.png" alt="Redis源码解析"></p>

<p>哈希表</p>

<pre><code>typedef struct dictht {  
    // 二维数组
    dictEntry **table;
    // table总大小
    unsigned long size;
    // 掩码=size-1
    unsigned long sizemask;
    // 已经保存的键值对
    unsigned long used;
} dictht;
</code></pre>

<p>二维数组中的键值对</p>

<pre><code>typedef struct dictEntry {  
    //键
    void *key;
    //值
    union {
        void *val; //值
        uint64_t u64;
        int64_t s64; //过期时间
        double d;
    } v;
    // hash冲突的next指针
    struct dictEntry *next;
} dictEntry;
</code></pre>

<p>字典，使用Hash表包了一层</p>

<pre><code>typedef struct dict {  
    //操作类型
    dictType *type;
    // 依赖的数据
    void *privdata;
    // Hash表
    dictht ht[2];
    // -1代表没有进行rehash值，否则代表hash操作进行到了哪个索引
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 当前运行的迭代器数
    unsigned long iterators; /* number of iterators currently running */
} dict;
</code></pre>

<h2 id="52">5.2基本操作</h2>

<h3 id="521">5.2.1 添加元素</h3>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-44-30.png" alt="Redis源码解析"></p>

<pre><code>dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing) /* 入参字典、键、Hash 表节点地址 */  
{
    long index;
    dictEntry *entry;
    dictht *ht;

    if (dictIsRehashing(d)) _dictRehashStep(d); /* 该字典是否在进行 rehash 操作，是则执行一次 rehash */

    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1) /* 查找键，找到则直接返回 -1，并把老节点存入 existing 字段，否则把新节点的索引值返回。如果遇到 Hash 表容量不足，则进行扩容 */
        return NULL;

    ht = dictIsRehashing(d) ? &amp;d-&gt;ht[1] : &amp;d-&gt;ht[0]; /* 是否在进行 rehash 操作中，是则插入至散列表 ht[1] 中，否则插入散列表 ht[0] */
    entry = zmalloc(sizeof(*entry)); /* 申请新节点内存 */
    entry-&gt;next = ht-&gt;table[index]; /* 将该节点的 next 指针指向 ht-&gt;table[index] 指针指向的位置 */
    ht-&gt;table[index] = entry; /* 将 ht-&gt;table[index] 指针指向该节点 */
    ht-&gt;used++;

    dictSetKey(d, entry, key); /* 给新节点存入键信息 */
    return entry;
}
</code></pre>

<p>其中查找元素的代码</p>

<pre><code>dictHashKey(d,key), existing)// 根据字典的hash函数得到key的hash值

idx = hash &amp; d-&gt;ht[table].sizemask; //利用key的hash值与掩码进行与操作(因为与操作的速度比取余快，也就是为什么要存一个掩码)  
</code></pre>

<h3 id="522">5.2.2 扩容</h3>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-45-19.png" alt="Redis源码解析"></p>

<pre><code>int dictExpand(dict *d, unsigned long size)  
{
    if (dictIsRehashing(d) || d-&gt;ht[0].used &gt; size) /* 如果此时正在扩容，或者是扩容大小小于 ht[0] 的表大小，则抛错 */
        return DICT_ERR;

    dictht n; /* 新 hash 表 */
    unsigned long realsize = _dictNextPower(size); /* 重新计算扩容后的值，必须为 2 的 N 次方幂 */

    /* Rehashing to the same table size is not useful. */
    if (realsize == d-&gt;ht[0].size) return DICT_ERR; /* 重新计算的值如果和原来的 size 相等，则无效 */

    /* 分配新 Hash 表，并初始化所有指针为 NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;

    /* 初始化的情况，而不是进行 rehash 操作，就用 ht[0] 来接收值 */
    if (d-&gt;ht[0].table == NULL) {
        d-&gt;ht[0] = n;
        return DICT_OK;
    }

    /* 准备第二个 Hash 表，以便执行渐进式哈希操作 */
    d-&gt;ht[1] = n; /* 扩容后的新内存放入 ht[1] 中 */
    d-&gt;rehashidx = 0; /* 非默认的 -1，表示需进行 rehash */
    return DICT_OK;
}
</code></pre>

<p>redis中的key可能有成千上万，如果一次性扩容，会对性能造成巨大的影响，所以redis使用渐进式扩容，每次执行插入，删除，查找，修改等操作前，都先判断当前字典的rehash操作是否在进行，如果是在进行中，就对当前节点进行rehash操作，只执行一次。除此之外，当服务器空闲时，也会调用incrementallyRehash函数进行批量操作，每次100个节点，大概一毫秒。将rehash操作进行分而治之。</p>

<h3 id="rehash">渐进式rehash源码</h3>

<pre><code>int dictRehash(dict *d, int n) {  
    int empty_visits = n*10; /* 最大访问的空桶的数量，n*10 */
    if (!dictIsRehashing(d)) return 0; /* dict 没有正在进行 rehash 时，直接返回 */

    while(n-- &amp;&amp; d-&gt;ht[0].used != 0) { /* n 为最多迁移元素数量 */
        dictEntry *de, *nextde;

        assert(d-&gt;ht[0].size &gt; (unsigned long)d-&gt;rehashidx); /* 为防止 rehashidx 越界，当 rehashidx 大于 ht[0] 的数组大小时，不继续执行 */
        while(d-&gt;ht[0].table[d-&gt;rehashidx] == NULL) { /* 当 rehashidx 位置的桶为空时，继续向下遍历，直到桶不为空或者达到最大访问空桶的数量 */
            d-&gt;rehashidx++;
            if (--empty_visits == 0) return 1; //最大访问空桶数量-1,若减完，则退出
        }
        de = d-&gt;ht[0].table[d-&gt;rehashidx]; 

        while(de) { // 遍历桶中元素，移动元素至新表
            uint64_t h;

            nextde = de-&gt;next;
            h = dictHashKey(d, de-&gt;key) &amp; d-&gt;ht[1].sizemask; 
            de-&gt;next = d-&gt;ht[1].table[h]; // 头插法
            d-&gt;ht[1].table[h] = de;
            d-&gt;ht[0].used--;
            d-&gt;ht[1].used++;
            de = nextde;
        }
        d-&gt;ht[0].table[d-&gt;rehashidx] = NULL; // ht[0] 对应桶置为空 
        d-&gt;rehashidx++;
    }

    if (d-&gt;ht[0].used == 0) { // 检查是否已经 rehash 完成
        zfree(d-&gt;ht[0].table);
        d-&gt;ht[0] = d-&gt;ht[1];
        _dictReset(&amp;d-&gt;ht[1]);
        d-&gt;rehashidx = -1;
        return 0;
    }
    return 1;
}
</code></pre>

<h3 id="523">5.2.3 查找元素</h3>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_12-46-7.png" alt="Redis源码解析">
更新和删除操作大同小异，不在赘述</p>

<h2 id="53">5.3 应用场景</h2>

<p>1.总长度超过512字节或者单个元素长度大于64的Hash <br>
2.总长度超过512字节或者单个元素长度大于64的set</p>

<h1 id="">六、压缩列表</h1>

<p>redis使用字节数据表示压缩列表，尽最大可能节省空间。</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-25-24.png" alt="Redis源码解析">
其中，coding字段表示content的编码，其长度是动态变化的。如下表</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-25-48.png" alt="Redis源码解析">
encoding字段第1个字节的前2位，可以判断content字段存储的是整数或者字节数组。当content存储的是字节数组时，后续字节标识字节数组的实际长度；当content存储的是整数时，可根据第3、第4位判断整数的具体类型。而当encoding字段标识当前元素存储的是0～12的立即数时，数据直接存储在encoding字段的最后4位，此时没有content字段。</p>

<p>举个例子
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-26-20.png" alt="Redis源码解析"></p>

<h2 id="61">6.1 数据结构</h2>

<p>因为解码过程比较繁琐，每次解码都需要性能损耗，为此定义了结构体zlentry，用于表示解码后的压缩列表元素</p>

<pre><code>typedef struct zlentry {  
    //previous_entry_length 长度
    unsigned int prevrawlensize; /* Bytes used to encode the previous entry len*/
    // previous_entry_length
    unsigned int prevrawlen;     /* Previous entry len. */
    //encoding 长度
    unsigned int lensize;        /* Bytes used to encode this entry type/len.
                                    For example strings have a 1, 2 or 5 bytes
                                    header. Integers always use a single byte.*/
    // 内容的长度
    unsigned int len;            /* Bytes used to represent the actual entry.
                                    For strings this is just the string length
                                    while for integers it is 1, 2, 3, 4, 8 or
                                    0 (for 4 bit immediate) depending on the
                                    number range. */
    //首部长度
    unsigned int headersize;     /* prevrawlensize + lensize. */
    //编码
    unsigned char encoding;      /* Set to ZIP_STR_* or ZIP_INT_* depending on
                                    the entry encoding. However for 4 bits
                                    immediate integers this can assume a range
                                    of values and must be range-checked. */
    // 当前元素的首地址
    unsigned char *p;            /* Pointer to the very start of the entry, that
                                    is, this points to prev-entry-len field. */
} zlentry;
</code></pre>

<h2 id="62">6.2 解码</h2>

<p>解码分为两步，解码previous<em>entry</em>length和解码coding</p>

<h3 id="1previous_entry_length">1.解码previous<em>entry</em>length</h3>

<pre><code>#define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do { 
    ZIP_DECODE_PREVLENSIZE(ptr, prevlensize); 
    // prevlensize=1时，则 ptr 的第一个字节标识上一个节点的长度
    if ((prevlensize) == 1) {
        (prevlen) = (ptr)[0]; 
    } else if ((prevlensize) == 5) {            
        assert(sizeof((prevlen)) == 4);        
        // 如果 prevlensize = 5，取后面 4 个字节作为上一节点的长度
        memcpy(&amp;(prevlen), ((char*)(ptr)) + 1, 4);          
        memrev32ifbe(&amp;prevlen);                                               
    }                                                                          
} while(0);
</code></pre>

<h3 id="2coding">2.解码coding</h3>

<pre><code>#define ZIP_DECODE_LENGTH(ptr, encoding, lensize, len) do {
    // 获取当前的编码类型
    ZIP_ENTRY_ENCODING((ptr), (encoding));
    // 如果编码类型为字节数组
    if ((encoding) &lt; ZIP_STR_MASK) {
        // encoding == 00000000
        if ((encoding) == ZIP_STR_06B) {
            // 存储元素的长度数值所需要的字节数设置为 1
            (lensize) = 1;
            // 元素长度为 (ptr)[0] 和 111111 做位运算
            (len) = (ptr)[0] &amp; 0x3f; 
        // encoding == 10000000
        } else if ((encoding) == ZIP_STR_14B) { 
            // 存储元素的长度数值所需要的字节数设置为 2
            (lensize) = 2; 
            // 元素长度为 高八位：(ptr)[0] 和 111111 做位运算 低八位：(ptr)[1]
            (len) = (((ptr)[0] &amp; 0x3f) &lt;&lt; 8) | (ptr)[1];
        // encoding == 11000000
        } else if ((encoding) == ZIP_STR_32B) {
            // 存储元素的长度数值所需要的字节数设置为 5
            (lensize) = 5; 
            // 元素长度为后 4 位
            (len) = ((ptr)[1] &lt;&lt; 24) |
                    (ptr)[2] &lt;&lt; 16) |
                    (ptr)[3] &lt;&lt;  8) |
                    ((ptr)[4]);
        } else {
            panic("Invalid string encoding 0x%02X", (encoding)); 
        } 
    } else { 
        // 数值类型长度存储为 1 字节
        (lensize) = 1;
        // 元素长度 
        (len) = zipIntSize(encoding);            
    }            
} while(0);
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-27-8.png" alt="Redis源码解析"></p>

<h2 id="63">6.3 编码</h2>

<pre><code>static unsigned int zipPrevEncodeLength(unsigned char *p, unsigned int len) {

    // 仅返回编码 len 所需的字节数量
    if (p == NULL) {
        return (len &lt; ZIP_BIGLEN) ? 1 : sizeof(len)+1;

    // 写入并返回编码 len 所需的字节数量
    } else {

        // 1 字节
        if (len &lt; ZIP_BIGLEN) {
            p[0] = len;
            return 1;

        // 5 字节
        } else {
            // 添加 5 字节长度标识
            p[0] = ZIP_BIGLEN;
            // 写入编码
            memcpy(p+1,&amp;len,sizeof(len));
            // 如果有必要的话，进行大小端转换
            memrev32ifbe(p+1);
            // 返回编码长度
            return 1+sizeof(len);
        }
    }
}
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-28-46.png" alt="Redis源码解析"></p>

<h2 id="64">6.4 连锁更新</h2>

<p>重新回顾一下 如果前驱节点的长度小于254，那么prev<em>entry</em>len成员需要用1字节长度来保存这个长度值。 如果前驱节点的长度大于等于254，那么prev<em>entry</em>len成员需要用5字节长度来保存这个长度值。
举个例子
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-30-4.png" alt="Redis源码解析"></p>

<p>比如有这么连续的四个节点，大小都是253字节，当最前面加入一个大于254字节的节点，会导致后面的节点因为因为previous<em>entry</em>length从一个字节变成五个字节而频繁扩容，每次扩容缩容都需要分配空间和复制数据，对性能损耗巨大。</p>

<p>因为连锁更新发生的概率十分十分低，所以redis并没有采取相关的措施去避免 最后附一张连锁更新的流程图</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-8_14-30-24.png" alt="Redis源码解析"></p>

<h2 id="65">6.5 应用场景</h2>

<p>1.所有字符串元素的长度都小于 64 字节并且保存的元素数量小于512个的列表（list） <br>
2.所有字符串元素的长度都小于 64 字节并且保存的元素数量小于512个的哈希表（Hash） <br>
3.所有字符串元素的长度都小于 64 字节并且保存的元素数量小于512个的有序集合（Sorted Set）</p>

<h1 id="stream">七、Stream流</h1>

<h1 id="71stream">7.1 Stream流结构</h1>

<p>redis中Stream流是Redis5.0以后新加入的数据结构，由生产者，消息，消费者，消费组四个部分组成</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-25-30.png" alt="Redis源码解析">
生产者负责向消息队列中生产消息，消费者消费某个消息流 对于消费组，有以下三点</p>

<p>1.每个消费组可以消费消息队列中的所有消息，且消费组之间独立。 <br>
2.每个消费组里有多个消费者，消息队列中的一条消息只能被其中的一个消费者消费。 <br>
3.消费组和消费者都维护了已消费待确认的队列</p>

<h2 id="72listpack">7.2 listpack结构</h2>

<p>listpack可以理解为一个字符串序列化队列，可以存储字符串或者整型 <br>
c语言中没有定义listpack的结构体，因为listpack本身可以理解为是一个字符串数组。</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-26-17.png" alt="Redis源码解析"></p>

<p>其中encode编码字段，决定了后面的content的内容形式，具体如下表所示</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-26-30.png" alt="Redis源码解析"></p>

<p>backlen所占用的每个字节的第一个bit用于标识；0代表结束，1代表尚未结束，每个字节只有7 bit有效，用于从后向前遍历，能够快速找到上一个元素的首字符。</p>

<h3 id="721listpack">7.2.1 listpack基本操作</h3>

<p>该结构查找效率低下，所以只适合在结尾增删，这刚好符合消息队列的操作。</p>

<h4 id="7211">7.2.1.1 增删改节点</h4>

<p>listpack中增删改操作都是用的同一个方法lpInsert,实现了在任意位置插入元素。</p>

<p>删除操作转换为用空元素替换操作</p>

<p>代码比较多，主要介绍一下流程
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-26-51.png" alt="Redis源码解析"></p>

<p>插入元素的流程也比较简单，就和在数组中插入一个元素类似，不做过多的介绍。</p>

<h4 id="7212">7.2.1.2 遍历</h4>

<p>类似数组的遍历，从前向后遍历。</p>

<h2 id="73rax">7.3 RAX结构</h2>

<p>我们经常会用到前缀树来查找一个单词，查找时间复杂度是O(len(单词的长度))</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-27-40.png" alt="Redis源码解析"></p>

<p>比如上图这样一棵前缀树，包含了两个单词，App，Apear</p>

<p>这是一种典型的空间换时间的方式，但是每个节点存储一个字母，是不是有点浪费了呢，Rax的出现就解决了这个问题。</p>

<p>下图为使用Rax结构保存App和Apear结构
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-27-59.png" alt="Redis源码解析"></p>

<p>其中使用带中括号表示的是非压缩节点，其它节点为压缩节点。</p>

<p>另外，非压缩节点是按照字典序排序的</p>

<h2 id="731">7.3.1 数据结构</h2>

<pre><code>typedef struct rax {  
    // 头结点
    raxNode *head;
    // 元素数量(key的数量)
    uint64_t numele;
    // 节点数量
    uint64_t numnodes;
} rax;
</code></pre>

<pre><code>typedef struct raxNode {  
    // 当前节点是否包含key
    uint32_t iskey:1;     /* Does this node contain a key? */
    // 当前key对应的value是否为null
    uint32_t isnull:1;    /* Associated value is NULL (don't store it). */
    // 是否压缩
    uint32_t iscompr:1;   /* Node is compressed. */
    // 压缩节点长度或者非压缩节点个数
    uint32_t size:29;
    unsigned char data[];
}
</code></pre>

<p>压缩节点
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-29-5.png" alt="Redis源码解析"></p>

<p>非压缩节点
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-29-25.png" alt="Redis源码解析"></p>

<p>压缩节点与非压缩节点最大的不同，除了iscomper标志字段不同外，压缩节点只有最后一个字符有子节点，而非压缩节点每个字符都有子节点。</p>

<h3 id="732stream">7.3.2 Stream底层实现</h3>

<p>如果Stream底层将消息都存放在listpack中，会存在性能问题。当查询消息的时候，需要遍历listpack，插入消息的时候，需要重新分配一块很大的空间。</p>

<h4 id="7321">7.3.2.1 初始化</h4>

<pre><code>rax *raxNew(void) {  
    rax *rax = rax_malloc(sizeof(*rax));
    if (rax == NULL) return NULL;
    rax-&gt;numele = 0;
    rax-&gt;numnodes = 1;
    rax-&gt;head = raxNewNode(0,0);
    if (rax-&gt;head == NULL) {
        rax_free(rax);
        return NULL;
    } else {
        return rax;
    }
}
</code></pre>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-29-59.png" alt="Redis源码解析"></p>

<h4 id="7322">7.3.2.2 查找元素</h4>

<pre><code>/**
 * 根据key获取对应的value
 * @param rax 待查找的rax
 * @param s 待查找的key
 * @param len s的长度
 * @return
 */
void *raxFind(rax *rax, unsigned char *s, size_t len) {  
    raxNode *h;

    debugf("### Lookup: %.*s\n", (int)len, s);
    int splitpos = 0;
    size_t i = raxLowWalk(rax,s,len,&amp;h,NULL,&amp;splitpos,NULL);
    if (i != len || (h-&gt;iscompr &amp;&amp; splitpos != 0) || !h-&gt;iskey)
        return raxNotFound;
    return raxGetData(h); //返回对应的value
}
</code></pre>

<p>可以看到，主要的代码在raxLowWalk方法中</p>

<pre><code>/**
 *
 * @param rax 待查找的rax
 * @param s 待查找的key
 * @param len s的长度
 * @param stopnode 终止的节点 要么匹配完成，要么没找到。。
 * @param plink 父节点指向stopnode的指针的地址
 * @param splitpos 压缩节点的匹配位置
 * @param ts 记录路径
 * @return
 */
static inline size_t raxLowWalk(rax *rax, unsigned char *s, size_t len, raxNode **stopnode, raxNode ***plink, int *splitpos, raxStack *ts) {  
    // 从根节点开始查找
    raxNode *h = rax-&gt;head;
    raxNode **parentlink = &amp;rax-&gt;head;

    // 当前匹配字符位置
    size_t i = 0; /* Position in the string. */
    // 当前匹配节点位置
    size_t j = 0; /* Position in the node children (or bytes if compressed).*/

    // 当前节点有子节点且s字符串没有遍历完
    while(h-&gt;size &amp;&amp; i &lt; len) {
        debugnode("Lookup current node",h);
        unsigned char *v = h-&gt;data;

        if (h-&gt;iscompr) {
            //压缩节点判断是否完全匹配
            for (j = 0; j &lt; h-&gt;size &amp;&amp; i &lt; len; j++, i++) {
                if (v[j] != s[i]) break;
            }
            // 没有遍历完字符串，退出
            if (j != h-&gt;size) break;
        } else {
            /* Even when h-&gt;size is large, linear scan provides good
             * performances compared to other approaches that are in theory
             * more sounding, like performing a binary search. */
            // 非压缩节点
            for (j = 0; j &lt; h-&gt;size; j++) {
                if (v[j] == s[i]) break;
            }
            // 未在非压缩节点找到字符串
            if (j == h-&gt;size) break;
            // 压缩节点可以匹配
            i++;
        }

        // 记录路径
        if (ts) raxStackPush(ts,h); /* Save stack of parent nodes. */

        raxNode **children = raxNodeFirstChildPtr(h);
        if (h-&gt;iscompr) j = 0; /* Compressed node only child is at index 0. */
        // 移动到第j个子节点
        memcpy(&amp;h,children+j,sizeof(h));
        parentlink = children+j;
        j = 0; /* If the new node is compressed and we do not
                  iterate again (since i == l) set the split
                  position to 0 to signal this node represents
                  the searched key. */
    }
    debugnode("Lookup stop node is",h);
    if (stopnode) *stopnode = h;
    if (plink) *plink = parentlink;
    if (splitpos &amp;&amp; h-&gt;iscompr) *splitpos = j;
    return i;
}
</code></pre>

<p>这里的步骤比较简单</p>

<p>1.初始化变量 <br>
2.从根节点查找直到当前节点无子节点或者s字符串遍历完毕 如果是压缩节点，节点中字符需要和s中的字符完全匹配 如果是非压缩节点，需要找到至少一个与S中字符匹配的字符 <br>
3.如果匹配成功，就查找子节点。</p>

<h4 id="7323">7.3.2.3 添加元素</h4>

<p>向rax中添加key-value对有两种方式，覆盖和不覆盖原有key</p>

<p>对应的方法分别为</p>

<pre><code>int raxInsert(rax *rax, unsigned char *s, size_t len, void *data, void **old) {  
    return raxGenericInsert(rax,s,len,data,old,1);
}
</code></pre>

<pre><code>int raxTryInsert(rax *rax, unsigned char *s, size_t len, void *data, void **old) {  
    return raxGenericInsert(rax,s,len,data,old,0);
}
</code></pre>

<p>两者都是调用同一个方法raxGenericInsert。 该接口方法主要分为以下几步</p>

<p>1.查找key是否存在  </p>

<pre><code>i = raxLowWalk(rax,s,len,&amp;h,&amp;parentlink,&amp;j,NULL);  
</code></pre>

<p>2 key存在的情况下，直接更新节点数据  </p>

<pre><code>       // 如果之前节点没有数据，就分配一个空间
        if (!h-&gt;iskey || (h-&gt;isnull &amp;&amp; overwrite)) {
            h = raxReallocForData(h,data);
            if (h) memcpy(parentlink,&amp;h,sizeof(h));
        }
        if (h == NULL) {
            errno = ENOMEM;
            return 0;
        }

        /* Update the existing key if there is already one. */
        // 更新数据
        if (h-&gt;iskey) {
            if (old) *old = raxGetData(h);
            if (overwrite) raxSetData(h,data);
            errno = 0;
            return 0; /* Element already exists. */
        }
</code></pre>

<p>3 key不存在的情况下，最后停留在某个压缩节点上</p>

<p>key不存在时，就分为多种情况，这里借用redis源码注解的例子</p>

<p>原来有个rax树，长这样</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-32-37.png" alt="Redis源码解析"></p>

<p>有以下几种插入情况 </p>

<p>1)插入 ANNIENTARE</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-32-57.png" alt="Redis源码解析"></p>

<p>2）插入ANNIBALI</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-33-11.png" alt="Redis源码解析"></p>

<p>3）插入 AGO</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-33-27.png" alt="Redis源码解析"></p>

<p>和第一种类似，只是右边的节点变成了非压缩节点</p>

<p>4）插入 CIAO</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-33-47.png" alt="Redis源码解析"></p>

<p>5)插入 ANNI</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-36-10.png" alt="Redis源码解析"></p>

<p>上面列举了五种情况，可以分为两类：</p>

<p>第一种是新插入的key是当前节点的一部分，这时我们只需要拆分压缩节点，并设置新的key即可</p>

<p>第二种是新插入的key与压缩节点的某个位置不匹配，这时我们需要在拆分后的相应位置的非压缩节点中，插入新key的不匹配字符，之后将新key的剩余部分，插入到这个非压缩节点的子节点中</p>

<p>源码中细节较多，不再细讲
感兴趣的同学可以查看</p>

<pre><code>int raxGenericInsert(rax  ＊rax,  unsigned  char  ＊s,  size_t  len,  void  ＊data,  void＊＊old, int overwrite)  
</code></pre>

<h4 id="7324">7.3.2.4 添加元素删除节点</h4>

<p>删除接口用于删除rax中某个key，依旧拿redis源码中注释的例子 原来有这样一个rax，其有两个key，foo和foobar
<img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-36-36.png" alt="Redis源码解析"></p>

<p>也有以下几种情况 1）删除foo</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-36-50.png" alt="Redis源码解析"></p>

<p>2）假设原来的rax是这样的，其有两个key，foobar,footer</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-37-7.png" alt="Redis源码解析"></p>

<h2 id="73stream">7.3 stream消息</h2>

<p>stream流就像是一个消息链表，依赖于Rax结构和listpack结构，本节主要介绍消息流的增删查操作。</p>

<pre><code>typedef struct stream {  
    // 指向rax树
    rax *rax;              
    // 元素个数
    uint64_t length;       
    // 指向最后一个消息
    streamID last_id;      
    // 消费组
    rax *cgroups;          
} stream;
</code></pre>

<p>结构如下图所示</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-38-15.png" alt="Redis源码解析"></p>

<h3 id="731">7.3.1 基本操作</h3>

<h4 id="7311">7.3.1.1 添加消息</h4>

<p>redis提供streamAppendItem 函数，向stream中添加一个新的消息  </p>

<pre><code>/**
 *
 * @param s 待插入的数据流
 * @param argv 消息内容
 * @param numfields 消息数量
 * @param added_id 消息id
 * @param use_id 调用方定义的id
 * @return
 */
int streamAppendItem(stream *s, robj **argv, int64_t numfields, streamID *added_id, streamID *use_id)  
</code></pre>

<p>代码比较多就不贴了，画一张流程图描述细节</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-39-4.png" alt="Redis源码解析"></p>

<h4 id="7312">7.3.1.2 新增消费组</h4>

<p>消费组也是保存在rax树中，以消费组的名称为key，消费组的streamCG结构为value。</p>

<pre><code>treamCG *streamCreateCG(stream *s, char *name, size_t namelen, streamID *id) {  
    //当前消息流没有消费组，就新建一个
    if (s-&gt;cgroups == NULL) s-&gt;cgroups = raxNew();

    // 查找是否有重名消费组，有就直接返回
    if (raxFind(s-&gt;cgroups,(unsigned char*)name,namelen) != raxNotFound)
        return NULL;

    // 新建消费组
    streamCG *cg = zmalloc(sizeof(*cg));
    cg-&gt;pel = raxNew();
    cg-&gt;consumers = raxNew();
    cg-&gt;last_id = *id;
    // 将消费组插入到消费组树中
    raxInsert(s-&gt;cgroups,(unsigned char*)name,namelen,cg,NULL);
    return cg;
}
</code></pre>

<h4 id="7313">7.3.1.3 新建消费者</h4>

<p>redis没有提供为消费组中新增消费者的方法。在查询消费者的时候，如果不存在，就会新增。</p>

<h4 id="7314">7.3.1.4 删除消息</h4>

<p>删除消息通过将listpack中消息的标志位设为已删除，并不是真正的删除。 如果整个listpack的消息都被删除了，才会从rax中释放该节点。</p>

<pre><code>void streamIteratorRemoveEntry(streamIterator *si, streamID *current) {  
    // 当前消息所在的listpack
    unsigned char *lp = si-&gt;lp;
    int64_t aux;

    // 标记删除位
    int flags = lpGetInteger(si-&gt;lp_flags);
    flags |= STREAM_ITEM_FLAG_DELETED;
    lp = lpReplaceInteger(lp,&amp;si-&gt;lp_flags,flags);


    //修改有效的消息数量
    unsigned char *p = lpFirst(lp);
    aux = lpGetInteger(p);

    // 如果只有待删除的消息，就直接释放listpack
    if (aux == 1) {
        /* If this is the last element in the listpack, we can remove the whole
         * node. */
        lpFree(lp);
        raxRemove(si-&gt;stream-&gt;rax,si-&gt;ri.key,si-&gt;ri.key_len,NULL);
    } else {
    // 更新统计信息
        lp = lpReplaceInteger(lp,&amp;p,aux-1);
    // 查找删除节点p
        p = lpNext(lp,p);
        aux = lpGetInteger(p);
        lp = lpReplaceInteger(lp,&amp;p,aux+1);

        // 更新listpack指针，可能因为扩容缩容而变化
        if (si-&gt;lp != lp)
            raxInsert(si-&gt;stream-&gt;rax,si-&gt;ri.key,si-&gt;ri.key_len,lp,NULL);
    }
</code></pre>

<h4 id="7315">7.3.1.5.查找消费组</h4>

<p>主要是利用rax查询接口</p>

<pre><code>streamCG *streamLookupCG(stream *s, sds groupname) {  
    if (s-&gt;cgroups == NULL) return NULL;
    streamCG *cg = raxFind(s-&gt;cgroups,(unsigned char*)groupname,
                           sdslen(groupname));
    return (cg == raxNotFound) ? NULL : cg;
}
</code></pre>

<h4 id="7316">7.3.1.6 查找消费者</h4>

<pre><code>streamConsumer *streamLookupConsumer(streamCG *cg, sds name, int create) {  
    // 在消费者的rax中查找指定的消费者
    streamConsumer *consumer = raxFind(cg-&gt;consumers,(unsigned char*)name,
                               sdslen(name));
    if (consumer == raxNotFound) {
        if (!create) return NULL;
        // 如果没有找到，新建一个消费者，插入到消费者rax树中
        consumer = zmalloc(sizeof(*consumer));
        consumer-&gt;name = sdsdup(name);
        consumer-&gt;pel = raxNew();
        raxInsert(cg-&gt;consumers,(unsigned char*)name,sdslen(name),
                  consumer,NULL);
    }
    consumer-&gt;seen_time = mstime();
    return consumer;
}
</code></pre>

<h1 id="redis">八、Redis线程模型演进</h1>

<h2 id="81redis30">8.1 redis3.0</h2>

<p>redis内部使用文件事件处理器file event handler,这个文件处理器是单线程的，所以我们经常说的redis是单线程模型。</p>

<p><img src="https://tech.youzan.com/content/images/2021/12/image2021-12-9_13-19-1.png" alt="Redis源码解析">
客户端 Socket01 向 Redis 的 Server Socket 请求建立连接，此时 Server Socket 会产生一个 AE<em>READABLE 事件，IO 多路复用程序监听到 server socket 产生的事件后，将该事件压入队列中。文件事件分派器从队列中获取该事件，交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 Socket01，并将该 Socket01 的 AE</em>READABLE 事件与命令请求处理器关联。</p>

<p>假设此时客户端发送了一个 set key value 请求，此时 Redis 中的 Socket01 会产生 AE<em>READABLE 事件，IO 多路复用程序将事件压入队列，此时事件分派器从队列中获取到该事件，由于前面 Socket01 的 AE</em>READABLE 事件已经与命令请求处理器关联，因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 Scket01 的 set key value 并在自己内存中完成 set key value 的设置。操作完成后，它会将 Scket01 的 AE_WRITABLE 事件与令回复处理器关联。</p>

<p>如果此时客户端准备好接收返回结果了，那么 Redis 中的 Socket01 会产生一个 AE<em>WRITABLE 事件，同样压入队列中，事件分派器找到相关联的命令回复处理器，由命令回复处理器对 socket01 输入本次操作的一个结果，比如 ok，之后解除 Socket01 的 AE</em>WRITABLE 事件与命令回复处理器的关联。</p>

<h2 id="82redis40">8.2 redis4.0</h2>

<p>redis4.0开始引入了多线程，除了主线程，redis有后台线程进行一些边缘的缓慢的操作，比如释放无用连接，rehash迁移等操作。</p>

<h2 id="83redis60">8.3 redis6.0</h2>

<p>在redis6.0中，真正引入了多线程
<img src="https://tech.youzan.com/content/images/2021/12/redis-----drawio.png" alt="Redis源码解析"></p>

<h1 id="">九、总结</h1>

<p>本文介绍了redis基本数据类型以及redis5.0以后的新特性。工作中我们也常常使用redis进行各种逻辑的处理，而了解其源码可以避免踩很多坑。另外，redis底层的设计也有很多值得学习的地方，比如更高效的使用内存和提升运算的时间复杂度，了解这些可以帮助我们在性能优化中有更多的思路。</p>]]></content:encoded></item><item><title><![CDATA[Java锁与线程的那些事]]></title><description><![CDATA[<p>pdf版本下载地址： <a href="https://pan.baidu.com/s/1kcdXOMgtxjEWOecQzdXjOA?pwd=xkgb">https://pan.baidu.com/s/1kcdXOMgtxjEWOecQzdXjOA?pwd=xkgb</a> 提取码: xkgb <br>
<center><font size="6">Java锁与线程的那些事</font> </center></p>

<h2 id="">一.引言</h2>

<p><strong>引言</strong>：“操作系统的线程状态和java的线程状态有什么关系？”这是校招时被问到的一个问题。当时只顾着看博文、面经等零散的资料，没有形成系统的知识体系，一时语塞，答的不是很对。在网上也没找到足够细致的讲解博文，于是整理出了这篇内容。</p>

<div align="center">  
<img src="https://tech.youzan.com/content/images/2021/06/image-20210627214603558-1.png" style="zoom:50%;">  
</div>  

<p>Java的线程状态牵扯到了同步语义，要探讨Java的线程状态的，必不可免要回顾其锁机制。因此本文的主要分为两大块：一是Synchronized源码粗析，分析了各类锁的进入、释放、升级过程，并大致说明了monitor原理；二是介绍了线程的实现方式和Java线程状态转换的部分细节。</p>

<p><strong>P.S.</strong>
本文内容较啰嗦，时间不充裕的同学可以直接看<strong>2.6小结</strong>及<strong>3.3小结</strong>。</p>

<h2 id="synchronized">二. Synchronized锁</h2>

<p>Java采用synchronized关键字、以互斥同步的方式的解决线程安全问题，那么什么是线程安全呢？这里引用《Java并发编程实战》</p>]]></description><link>https://tech.youzan.com/javasuo-yu-xian-cheng-de-na-xie-shi/</link><guid isPermaLink="false">4c125402-c9af-4add-9d17-cb17e8a0405f</guid><dc:creator><![CDATA[JobsHwang]]></dc:creator><pubDate>Tue, 14 Dec 2021 06:33:41 GMT</pubDate><media:content url="https://tech.youzan.com/content/images/2021/07/-----3.svg" medium="image"/><content:encoded><![CDATA[<img src="https://tech.youzan.com/content/images/2021/07/-----3.svg" alt="Java锁与线程的那些事"><p>pdf版本下载地址： <a href="https://pan.baidu.com/s/1kcdXOMgtxjEWOecQzdXjOA?pwd=xkgb">https://pan.baidu.com/s/1kcdXOMgtxjEWOecQzdXjOA?pwd=xkgb</a> 提取码: xkgb <br>
<center><font size="6">Java锁与线程的那些事</font> </center></p>

<h2 id="">一.引言</h2>

<p><strong>引言</strong>：“操作系统的线程状态和java的线程状态有什么关系？”这是校招时被问到的一个问题。当时只顾着看博文、面经等零散的资料，没有形成系统的知识体系，一时语塞，答的不是很对。在网上也没找到足够细致的讲解博文，于是整理出了这篇内容。</p>

<div align="center">  
<img src="https://tech.youzan.com/content/images/2021/06/image-20210627214603558-1.png" style="zoom:50%;" alt="Java锁与线程的那些事">  
</div>  

<p>Java的线程状态牵扯到了同步语义，要探讨Java的线程状态的，必不可免要回顾其锁机制。因此本文的主要分为两大块：一是Synchronized源码粗析，分析了各类锁的进入、释放、升级过程，并大致说明了monitor原理；二是介绍了线程的实现方式和Java线程状态转换的部分细节。</p>

<p><strong>P.S.</strong>
本文内容较啰嗦，时间不充裕的同学可以直接看<strong>2.6小结</strong>及<strong>3.3小结</strong>。</p>

<h2 id="synchronized">二. Synchronized锁</h2>

<p>Java采用synchronized关键字、以互斥同步的方式的解决线程安全问题，那么什么是线程安全呢？这里引用《Java并发编程实战》作者Brian Goetz给出的定义：</p>

<blockquote>
  <p>当多个线程同时访问一个对象时，如果不用考虑这些线程在运行时环境下的调度和交替执行，也不需要进行额外的同步，或者在调用方进行任何其他的协调操作，调用这个对象的行为都可以获得正确的结果，那就称这个对象是线程安全的。          —— Brian Goetz</p>
</blockquote>

<h3 id="21synchronized">2.1 Synchronized的使用</h3>

<p>先写过个demo，大致过一下<code>synchronized</code>的使用，包含同步代码块、实例方法和静态方法。</p>

<pre><code class="language-java">  public synchronized void test1(){
  }

  public void test2(){
    synchronized(new Test()){
    }
  }

  public static synchronized void test3(){
  }
</code></pre>

<p>反编译可查看字节码：</p>

<pre><code class="language-java">  public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED    // here

  public void test2();
    descriptor: ()
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class com/easy/helloworld/Test
         3: dup
         4: invokespecial #3                  // Method "&lt;init&gt;":()V
         7: dup
         8: astore_1
         9: monitorenter                   // here
        10: aload_1
        11: monitorexit                    // here
        12: goto          20
        15: astore_2
        16: aload_1
        17: monitorexit                    // here
        18: aload_2
        19: athrow
        20: return

  public static synchronized void test3();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED   // here
</code></pre>

<p>可以观察到：</p>

<ul>
<li>同步代码：通过moniterenter、moniterexit 关联到到一个monitor对象，进入时设置Owner为当前线程，计数+1、退出-1。除了正常出口的 monitorexit，还在异常处理代码里插入了 monitorexit。</li>
<li>实例方法：隐式调用moniterenter、moniterexit</li>
<li>静态方法：隐式调用moniterenter、moniterexit</li>
</ul>

<h3 id="22moniterentermoniterexit">2.2 Moniterenter、Moniterexit</h3>

<p>monitorenter和monitorexit这两个jvm指令，主要是基于 <code>Mark Word</code>和<code>Object monitor</code>来实现的。</p>

<p>在 JVM 中，对象在内存中分为三块区域： </p>

<ul>
<li><p>对象头：由<code>Mark Word</code>和<code>Klass Point</code>构成。</p>

<ul><li><strong>Mark Word</strong>（标记字段）：用于存储对象自身的运行时数据，例如存储对象的HashCode，分代年龄、锁标志位等信息，是synchronized实现轻量级锁和偏向锁的关键。  64位JVM的Mark Word组成如下：<center>
<img src="https://tech.youzan.com/content/images/2021/06/image-20210627210825952.png" style="zoom:50%;" alt="Java锁与线程的那些事"></center></li>
<li><strong>Klass Point</strong>（类型指针）：对象指向它的类元数据的指针，虚拟机通过这个指针来确定这个对象是哪个类的实例。</li></ul></li>
<li><p>实例数据：这部分主要是存放类的数据信息，父类的信息。</p></li>
<li>字节对齐：为了内存的IO性能，JVM要求对象起始地址必须是8字节的整数倍。对于不对齐的对象，需要填充数据进行对齐。</li>
</ul>

<p>在JDK 1.6之前,<code>synchronized</code>只有传统的锁机制，直接关联到<code>monitor</code>对象，存在性能上的瓶颈。在JDK 1.6后，为了提高锁的获取与释放效率，JVM引入了两种锁机制：偏向锁和轻量级锁。它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。这几种锁的实现和转换正是依靠对象头中的<code>Mark Word</code>。</p>

<h3 id="23">2.3 偏向锁</h3>

<p>引入偏向锁的目的：在没有多线程竞争的情况下，尽量减少不必要的轻量级锁的执行。轻量级锁的获取及释放依赖多次CAS原子指令，而偏向锁只依赖一次CAS原子指令。但在多线程竞争时，需要进行偏向锁撤销步骤，因此其撤销的开销必须小于节省下来的CAS开销，否则偏向锁并不能带来收益。JDK 1.6中默认开启偏向锁，可以通过-XX:-UseBiasedLocking来禁用偏向锁。    </p>

<h3 id="231">2.3.1 进入偏向锁</h3>

<p>关于HotSpot虚拟机中获取锁的入口，网上主要有两种看法：一为<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/interpreterRuntime.cpp#l608">interpreterRuntime.cpp#monitorenter#1608</a>；二为<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816">bytecodeInterpreter.cpp#1816</a>。在HotSpot的中，有两处地方对<code>monitorenter</code>指令进行解析：一个是<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816">bytecodeInterpreter.cpp#1816</a> ，另一个在<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/cpu/x86/vm/templateTable_x86_64.cpp#l3667">templateTable<em>x86</em>64.cpp#3667</a>。其中，<code>bytecodeInterpreter</code>是JVM中的字节码解释器， <code>templateInterpreter</code>为模板解释器。HotSpot对运行效率有着极其执着的追求，显然会倾向于用模板解释器来实现。R大的<a href="https://book.douban.com/annotation/31407691/">读书笔记</a>中有说明，HotSpot中只用到了模板解释器，并没有用到字节码解释器。因此，本文认为<code>montorenter</code>的解析入口为<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/cpu/x86/vm/templateTable_x86_64.cpp#l3667">templateTable<em>x86</em>64.cpp#3667</a>。</p>

<p>但模板解释器<code>templateInterpreter</code>都是汇编代码，不易读，且实现逻辑与字节码解释器<code>bytecodeInterpreter</code>大体一致。因此本文的源码都以<code>bytecodeInterpreter</code>来说明，借此窥探<code>synchronized</code>的实现原理。在看代码之前，先介绍几个在偏向锁中会被大量应用的概念，以便后续理解：</p>

<p><code>prototype_header</code>：JVM中的每个类有一个类似<code>mark word</code>的<code>prototype_header</code>，用来标记该class的<code>epoch</code>和偏向开关等信息。</p>

<p><code>匿名偏向状态</code>：锁对象mark word标志位为101，且存储的<code>Thread ID</code>为空时的状态(即锁对象为偏向锁，且没有线程偏向于这个锁对象)。</p>

<p><code>Atomic::cmpxchg_ptr</code>：CAS函数。这个方法有三个参数，依次为<code>exchange_value</code>、<code>dest</code>、<code>compare_value</code>。如果dest的值为<code>compare_value</code>则更新为<code>exchange_value</code>，并返回<code>compare_value</code>。否则，不更新并返回<code>实际原值</code>。</p>

<p>接下来开始源码实现分析，HotSpot中偏向锁的具体实现可参考<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816">bytecodeInterpreter.cpp#1816</a>，代码如下：</p>

<pre><code class="language-java">CASE(_monitorenter): {  
  //锁对象
  oop lockee = STACK_OBJECT(-1);
  // derefing's lockee ought to provoke implicit null check
  CHECK_NULL(lockee);
  // 步骤1
  // 在栈中找到第一个空闲的Lock Record
  // 会找到栈中最高的
  BasicObjectLock* limit = istate-&gt;monitor_base();
  BasicObjectLock* most_recent = (BasicObjectLock*) istate-&gt;stack_base();
  BasicObjectLock* entry = NULL;
  while (most_recent != limit ) {
    if (most_recent-&gt;obj() == NULL) entry = most_recent;
    else if (most_recent-&gt;obj() == lockee) break;
    most_recent++;
  }
  // entry不为null，代表还有空闲的Lock Record
  if (entry != NULL) {
    // 将Lock Record的obj指针指向锁对象
    entry-&gt;set_obj(lockee);
    int success = false;
    uintptr_t epoch_mask_in_place = (uintptr_t)markOopDesc::epoch_mask_in_place;
    // markoop即对象头的mark word
    markOop mark = lockee-&gt;mark();
    intptr_t hash = (intptr_t) markOopDesc::no_hash;
    // 步骤2
    // implies UseBiasedLocking
    // 如果为偏向模式，即判断标识位是否为101
    if (mark-&gt;has_bias_pattern()) {
      ...
      // 一顿操作
      anticipated_bias_locking_value =
        (((uintptr_t)lockee-&gt;klass()-&gt;prototype_header() | thread_ident) ^ (uintptr_t)mark) &amp;
        ~((uintptr_t) markOopDesc::age_mask_in_place);
      // 步骤3
      if  (anticipated_bias_locking_value == 0) {
        // already biased towards this thread, nothing to do
        // 偏向的是自己，啥都不做
        if (PrintBiasedLockingStatistics) {
          (* BiasedLocking::biased_lock_entry_count_addr())++;
        }
        success = true;
      }
      // class的prototype_header不是偏向模式
      else if ((anticipated_bias_locking_value &amp; markOopDesc::biased_lock_mask_in_place) != 0) {
        // 尝试撤销偏向
                ...
      }
      // epoch过期，重新偏向
      else if ((anticipated_bias_locking_value &amp; epoch_mask_in_place) !=0) {
        // try rebias
                ...
        success = true;    
      }
      else {
        // try to bias towards thread in case object is anonymously biased
        // 尝试偏向该线程，只有匿名偏向能成功
        // 构建了匿名偏向的mark word
        markOop header = (markOop) ((uintptr_t) mark &amp; ((uintptr_t)markOopDesc::biased_lock_mask_in_place |(uintptr_t)markOopDesc::age_mask_in_place |epoch_mask_in_place));
        if (hash != markOopDesc::no_hash) {
          header = header-&gt;copy_set_hash(hash);
        }
        // 用「或」操作设置thread ID
        markOop new_header = (markOop) ((uintptr_t) header | thread_ident);、
        // 只有匿名偏向才能成功
        if (Atomic::cmpxchg_ptr((void*)new_header, lockee-&gt;mark_addr(), header) == header) {
          // cas修改成功    
          if (PrintBiasedLockingStatistics)
            (* BiasedLocking::anonymously_biased_lock_entry_count_addr())++;
        }
        else {
          // 失败说明存在竞争，进入monitorenter
          CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);
        }
        success = true;
      }
    }
    // 步骤4
    // traditional lightweight locking
    // false走轻量级锁逻辑
    if (!success) {
      // 构造一个无锁状态的Displaced Mark Word，并将lock record指向它
      markOop displaced = lockee-&gt;mark()-&gt;set_unlocked();
      entry-&gt;lock()-&gt;set_displaced_header(displaced);
      bool call_vm = UseHeavyMonitors;
      if (call_vm || Atomic::cmpxchg_ptr(entry, lockee-&gt;mark_addr(), displaced) != displaced) {
        // 如果CAS替换不成功，代表锁对象不是无锁状态，这时候判断下是不是锁重入
        // Is it simple recursive case?
        if (!call_vm &amp;&amp; THREAD-&gt;is_lock_owned((address) displaced-&gt;clear_lock_bits())) {
          // 如果是锁重入，则直接将Displaced Mark Word设置为null
          // 轻量级锁重入是使用lock record的数量来计入的
          entry-&gt;lock()-&gt;set_displaced_header(NULL);
        } else {
          CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);
        }
      }
    }
    UPDATE_PC_AND_TOS_AND_CONTINUE(1, -1);
  } else {
    // 没拿到lock record，重新执行
    istate-&gt;set_msg(more_monitors);
    UPDATE_PC_AND_RETURN(0); // Re-execute
  }
}
</code></pre>

<p>偏向锁流程：</p>

<p><code>步骤 1</code>、从当前线程的栈中找到一个空闲的<code>Lock Record</code>，并指向当前锁对象。</p>

<p><code>步骤 2</code>、获取对象的markOop数据mark，即对象头的Mark Word；</p>

<p><code>步骤 3</code>、判断锁对象的<code>mark word</code>是否是偏向模式，即低3位是否为101。若不是，进入步骤4。若是，计算<code>anticipated_bias_locking_value</code>，判断偏向状态：</p>

<p><code>步骤 3.1</code>、<code>anticipated_bias_locking_value</code>若为0，代表<strong>偏向的线程是当前线程</strong>且<code>mark word</code>的epoch等于class的epoch，这种情况下直接执行同步代码块，什么都不用做。</p>

<p><code>步骤 3.2</code>、判断class的<code>prototype_header</code>是否为非偏向模式。若为非偏向模式，CAS尝试将对象恢复为无锁状态。无论cas是否成功都会进入轻量级锁逻辑。</p>

<p><code>步骤 3.3</code>、如果epoch偏向<strong>时间戳已过期</strong>，则需要重偏向。利用CAS指令将锁对象的<code>mark word</code>替换为一个偏向当前线程且epoch为类的epoch的新的<code>mark word</code>。</p>

<p><code>步骤 3.4</code>、CAS将偏向线程改为当前线程，如果当前是<strong>匿名偏向</strong>（即对象头中的bit field存储的Thread ID为空）且<strong>无并发冲突</strong>，则能<code>修改成功</code>获取偏向锁，否则进入<code>锁升级</code>的逻辑。</p>

<p><code>步骤 4</code>、走到一步会进行轻量级锁逻辑。构造一个无锁状态的<code>mark word</code>，然后存储到<code>Lock Record</code>。设置为无锁状态的原因是：轻量级锁解锁时是将对象头的<code>mark word</code>cas替换为<code>Lock Record</code>中的<code>Displaced Mark Word</code>，所以设置为无锁状态。如果是锁重入，则将<code>Lock Record</code>的<code>Displaced Mark Word</code>设置为null，放到栈帧中，起到计数作用。</p>

<p>以上是偏向锁加锁的大致流程，如果当前锁<strong>已偏向其他线程</strong>||<strong>epoch值过期</strong>||<strong>class偏向模式关闭</strong>||<strong>获取偏向锁的过程中存在并发冲突</strong>，都会进入到<code>InterpreterRuntime::monitorenter</code>方法， 在该方法中会进行偏向锁撤销和升级。流程如下图所示：</p>

<div align="center">  
<img src="https://tech.youzan.com/content/images/2021/07/---.svg" alt="Java锁与线程的那些事" style="zoom:67%;">  
</div>

<p><strong>Issue</strong>：有的同学可能会问了，对象一开始不是无锁状态吗，为什么上述偏向锁逻辑没有判断<strong>无锁状态的锁对象</strong>（001）？</p>

<p><strong>只有匿名偏向的对象才能进入偏向锁模式</strong>。JVM启动时会延时初始化偏向锁，默认是4000ms。初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式。当创建一个对象时，会通过Klass的prototype_header来初始化该对象的对象头。简单的说，偏向锁初始化结束后，后续所有对象的对象头都为<strong>匿名偏向</strong>样式，在此之前创建的对象则为<strong>无锁状态</strong>。而对于无锁状态的锁对象，如果有竞争，会直接进入到轻量级锁。这也是为什么JVM启动前4秒对象会直接进入到轻量级锁的原因。</p>

<p>为什么需要延迟初始化？</p>

<p>JVM启动时必不可免会有大量sync的操作，而偏向锁并不是都有利。如果开启了偏向锁，会发生大量锁撤销和锁升级操作，大大降低JVM启动效率。</p>

<p>因此，我们可以明确地说，只有锁对象处于<strong>匿名偏向</strong>状态，线程才能拿到到我们通常意义上的偏向锁。而处于无锁状态的锁对象，只能进入到轻量级锁状态。</p>

<h3 id="232">2.3.2 偏向锁的撤销</h3>

<p>偏向锁的 <code>撤销</code>（revoke）是一个很特殊的操作，为了执行撤销操作，需要等待<code>全局安全点</code>，此时所有的工作线程都停止了执行。偏向锁的撤销操作并不是将对象恢复到无锁可偏向的状态（<strong>注意区分偏向锁撤销和释放这两个概念，撤销的触发见上图</strong>），而是在偏向锁的获取过程中，发现竞争时，直接将一个被偏向的对象<code>升级到</code>被加了轻量级锁的状态。这个操作的具体完成方式如下：</p>

<pre><code class="language-Java">IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))  
  ...
  Handle h_obj(thread, elem-&gt;obj());
  assert(Universe::heap()-&gt;is_in_reserved_or_null(h_obj()),
         "must be NULL or an object");
    // 开启了偏向锁
  if (UseBiasedLocking) {
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, elem-&gt;lock(), true, CHECK);
  } else {
    ObjectSynchronizer::slow_enter(h_obj, elem-&gt;lock(), CHECK);
  }
  ...
</code></pre>

<p>如果开启了JVM偏向锁，则会进入到<code>ObjectSynchronizer::fast_enter</code>方法中。</p>

<pre><code class="language-java">void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) {  
 //再次校验
 if (UseBiasedLocking) {
    if (!SafepointSynchronize::is_at_safepoint()) {
      //不在安全点的执行
      BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
      if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
        return;
      }
    } else {
      assert(!attempt_rebias, "can not rebias toward VM thread");    
      //批量撤销,底层调用bulk_revoke_or_rebias_at_safepoint
      BiasedLocking::revoke_at_safepoint(obj);
    }
    assert(!obj-&gt;mark()-&gt;has_bias_pattern(), "biases should be revoked by now");
 }
 slow_enter (obj, lock, THREAD) ;
}
</code></pre>

<p>主要看<code>BiasedLocking::revoke_and_rebias</code>方法。这个方法的主要作用像它的方法名：撤销或者重偏向。第一个参数封装了锁对象和当前线程，第二个参数代表是否允许重偏向，这里是true。</p>

<pre><code class="language-java">BiasedLocking::Condition BiasedLocking::revoke_and_rebias(Handle obj, bool attempt_rebias, TRAPS) {  
  assert(!SafepointSynchronize::is_at_safepoint(), "must not be called while at safepoint");
  markOop mark = obj-&gt;mark(); //获取锁对象的对象头
  if (mark-&gt;is_biased_anonymously() &amp;&amp; !attempt_rebias) {
    // 如果锁对象为匿名偏向状态且不允许重偏向下，进入该分支。在一个非全局安全点进行偏向锁撤销
    markOop biased_value       = mark;
    // 创建一个匿名偏向的markword
    markOop unbiased_prototype = markOopDesc::prototype()-&gt;set_age(mark-&gt;age());
    // 通过cas重新设置偏向锁状态
    markOop res_mark = (markOop) Atomic::cmpxchg_ptr(unbiased_prototype, obj-&gt;mark_addr(), mark);
    if (res_mark == biased_value) {// 如果CAS成功，返回偏向锁撤销状态
      return BIAS_REVOKED;
    }
  } else if (mark-&gt;has_bias_pattern()) {
    // 锁为偏向模式（101）会走到这里 
    Klass* k = obj-&gt;klass(); 
    markOop prototype_header = k-&gt;prototype_header();
    // 如果对应class关闭了偏向模式
    if (!prototype_header-&gt;has_bias_pattern()) {
      markOop biased_value       = mark;
      // CAS更新对象头markword为非偏向锁
      markOop res_mark = (markOop) Atomic::cmpxchg_ptr(prototype_header, obj-&gt;mark_addr(), mark);
      assert(!(*(obj-&gt;mark_addr()))-&gt;has_bias_pattern(), "even if we raced, should still be revoked");
      return BIAS_REVOKED; // 返回偏向锁撤销状态
    } else if (prototype_header-&gt;bias_epoch() != mark-&gt;bias_epoch()) {
      // 如果epoch过期，则进入当前分支
      if (attempt_rebias) {
        // 如果允许重偏
        assert(THREAD-&gt;is_Java_thread(), "");
        markOop biased_value       = mark;
        markOop rebiased_prototype = markOopDesc::encode((JavaThread*) THREAD, mark-&gt;age(), prototype_header-&gt;bias_epoch());
        // 通过CAS操作， 将本线程的 ThreadID 、时间戳、分代年龄尝试写入对象头中
        markOop res_mark = (markOop) Atomic::cmpxchg_ptr(rebiased_prototype, obj-&gt;mark_addr(), mark);
        if (res_mark == biased_value) { //CAS成功，则返回撤销和重新偏向状态
          return BIAS_REVOKED_AND_REBIASED;
        }
      } else {
        // 如果不允许尝试获取偏向锁，进入该分支取消偏向
        // 通过CAS操作更新分代年龄
        markOop biased_value       = mark;
        markOop unbiased_prototype = markOopDesc::prototype()-&gt;set_age(mark-&gt;age());
        markOop res_mark = (markOop) Atomic::cmpxchg_ptr(unbiased_prototype, obj-&gt;mark_addr(), mark);
        if (res_mark == biased_value) { //如果CAS操作成功，返回偏向锁撤销状态
          return BIAS_REVOKED;
        }
      }
    }
  }
  //执行到这里有以下两种情况：
  //1.对象不是偏向模式
  //2.上面的cas操作失败
  HeuristicsResult heuristics = update_heuristics(obj(), attempt_rebias);
  if (heuristics == HR_NOT_BIASED) {
    // 非偏向从这出去
    // 轻量级锁、重量级锁
    return NOT_BIASED;
  } else if (heuristics == HR_SINGLE_REVOKE) {
    // 撤销单个线程
    // Mark，最常见的执行分支
    // Mark，最常见的执行分支
    // Mark，最常见的执行分支
    Klass *k = obj-&gt;klass();
    markOop prototype_header = k-&gt;prototype_header();
    if (mark-&gt;biased_locker() == THREAD &amp;&amp;
        prototype_header-&gt;bias_epoch() == mark-&gt;bias_epoch()) {
      // 偏向当前线程且不过期
      // 这里撤销的是偏向当前线程的锁，调用Object#hashcode方法时也会走到这一步
      // 因为只要遍历当前线程的栈就能拿到lock record了，所以不需要等到safe point再撤销。
      ResourceMark rm;
      if (TraceBiasedLocking) {
        tty-&gt;print_cr("Revoking bias by walking my own stack:");
      }
      BiasedLocking::Condition cond = revoke_bias(obj(), false, false, (JavaThread*) THREAD);
      ((JavaThread*) THREAD)-&gt;set_cached_monitor_info(NULL);
      assert(cond == BIAS_REVOKED, "why not?");
      return cond;
    } else {
      // 下面代码最终会在safepoint调用revoke_bias方法撤销偏向
      VM_RevokeBias revoke(&amp;obj, (JavaThread*) THREAD);
      VMThread::execute(&amp;revoke);
      return revoke.status_code();
    }
  }
  assert((heuristics == HR_BULK_REVOKE) ||
         (heuristics == HR_BULK_REBIAS), "?");
   //批量撤销、批量重偏向的逻辑
  VM_BulkRevokeBias bulk_revoke(&amp;obj, (JavaThread*) THREAD,
                                (heuristics == HR_BULK_REBIAS),
                                attempt_rebias);
  VMThread::execute(&amp;bulk_revoke);
  return bulk_revoke.status_code();
}
</code></pre>

<p>这块代码注释写的算是比较清楚，只简单介绍下最常见的情况：锁已经偏向线程A，此时线程B尝试获取锁。这种情况下会走到Mark标记的分支。如果需要撤销的是当前线程，只要遍历当前线程的栈就能拿到lock record，可以直接调用<code>revoke_bias</code>，不需要等到safe point再撤销。在调用Object#hashcode时，也会走到该分支将为偏向锁的锁对象直接恢复为无锁状态。若不是当前线程，会被push到VM Thread中等到<code>safepoint</code>的时候再执行。</p>

<p>VMThread内部维护了一个VMOperationQueue类型的队列，用于保存内部提交的VM线程操作VM_operation。GC、偏向锁的撤销等操作都是在这里被执行。</p>

<p>撤销调用的<code>revoke_bias</code>方法的代码就不贴了。大致逻辑是：</p>

<p><code>步骤 1</code>、查看偏向的线程是否存活，如果已经死亡，则直接撤销偏向锁。JVM维护了一个集合存放所有存活的线程，通过遍历该集合判断某个线程是否存活。</p>

<p><code>步骤 2</code>、偏向的线程是否还在同步块中，如果不在，则撤销偏向锁。如果在同步块中，执行步骤3。这里<strong>是否在同步块的判断</strong>基于上文提到的偏向锁的重入计数方式：在偏向锁的获取中，每次进入同步块的时候都会在栈中找到第一个可用（即栈中最高的）的<code>Lock Record</code>，将其obj字段指向锁对象。每次解锁的时候都会把最低的<code>Lock Record</code>移除掉，所以可以通过遍历线程栈中的<code>Lock Record</code>来判断是否还在同步块中。轻量级锁的重入也是基于<code>Lock Record</code>的计数来判断。</p>

<p><code>步骤 3</code>、升级为轻量级锁。将偏向线程所有相关<code>Lock Record</code>的<code>Displaced Mark Word</code>设置为null，再将最高位的<code>Lock Record</code>的<code>Displaced Mark Word</code> 设置为无锁状态，然后将对象头指向最高位的<code>Lock Record</code>。这里没有用到CAS指令，因为是在<code>safepoint</code>，可以直接升级成轻量级锁。</p>

<h3 id="233">2.3.3 偏向锁的释放</h3>

<p>偏向锁的释放可参考<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1923">bytecodeInterpreter.cpp#1923</a>，这里也不贴了。偏向锁的释放只要将对应<code>Lock Record</code>释放就好了，但这里的释放并不会将mark word里面的thread ID去掉，这样做是为了下一次更方便的加锁。而轻量级锁则需要将<code>Displaced Mark Word</code>替换到对象头的mark word中。如果CAS失败或者是重量级锁则进入到<code>InterpreterRuntime::monitorexit</code>方法中。</p>

<h3 id="234">2.3.4 批量重偏向与撤销</h3>

<p>从上节偏向锁的加锁解锁过程中可以看出，当只有一个线程反复进入同步块时，偏向锁带来的性能开销基本可以忽略，但是当有其他线程尝试获得锁时，就需要等到<code>safe point</code>时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。因此，JVM中增加了一种批量重偏向/撤销的机制以减少锁撤销的开销，而mark word中的epoch也是在这里被大量应用，这里不展开说明。但无论怎么优化，偏向锁的撤销仍有一定不可避免的成本。如果业务场景存在大量多线程竞争，那偏向锁的存在不仅不能提高性能，而且会导致性能下降（<strong>偏向锁并不都有利，jdk15默认不开启</strong>）。</p>

<h3 id="24">2.4 轻量级锁</h3>

<p>引入轻量级锁的目的：在多线程交替执行同步块的情况下，尽量避免重量级锁使用的操作系统互斥量带来的开销，但是如果多个线程在同一时刻进入临界区，会导致轻量级锁膨胀升级重量级锁，所以轻量级锁的出现并非是要替代重量级锁。</p>

<h3 id="241">2.4.1 进入轻量级锁</h3>

<p>轻量级锁在上文或多或少已经涉及到，其获取流程入口为<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816">bytecodeInterpreter.cpp#1816</a>。前大半部分都是偏向锁逻辑，还有一部分为轻量级锁逻辑。在偏向锁逻辑中，cas失败会执行到<code>InterpreterRuntime::monitorenter</code>。在轻量级锁逻辑中，如果当前线程不是轻量级锁的重入，也会执行到<code>InterpreterRuntime::monitorenter</code>。我们再看看<code>InterpreterRuntime::monitorenter</code>方法：</p>

<pre><code class="language-java">IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))  
  ...
  Handle h_obj(thread, elem-&gt;obj());
  assert(Universe::heap()-&gt;is_in_reserved_or_null(h_obj()),
         "must be NULL or an object");
  if (UseBiasedLocking) {
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, elem-&gt;lock(), true, CHECK);
  } else {
    ObjectSynchronizer::slow_enter(h_obj, elem-&gt;lock(), CHECK);
  }
  ...
IRT_END  
</code></pre>

<p><code>fast_enter</code>的流程在偏向锁的撤销小节中已经分析过，主要逻辑为<code>revoke_and_rebias</code>：如果当前是偏向模式且偏向的线程还在使用锁，会将锁的<code>mark word</code>改为轻量级锁的状态，并将偏向的线程栈中的<code>Lock Record</code>修改为轻量级锁对应的形式（此时Lock Record是无锁状态），且返回值不是<code>BIAS_REVOKED_AND_REBIASED</code>，会继续执行<code>slow_enter</code>。</p>

<p>我们直接看<code>slow_enter</code>的流程:</p>

<pre><code class="language-java">void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {  
  // 步骤1
  markOop mark = obj-&gt;mark();
  assert(!mark-&gt;has_bias_pattern(), "should not see bias pattern here");
  // 步骤2
  // 如果为无锁状态
  if (mark-&gt;is_neutral()) {
    // 步骤3
    // 设置mark word到栈 
    lock-&gt;set_displaced_header(mark);
    // CAS更新指向栈中Lock Record的指针
    if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()-&gt;mark_addr(), mark)) {
      TEVENT (slow_enter: release stacklock) ;
      return ;
    }
    // Fall through to inflate() ... cas失败走下面锁膨胀方法
  } else if (mark-&gt;has_locker() &amp;&amp; THREAD-&gt;is_lock_owned((address)mark-&gt;locker())) {
    // 步骤4
    // 为轻量级锁且owner为当前线程
    assert(lock != mark-&gt;locker(), "must not re-lock the same lock");
    assert(lock != (BasicLock*)obj-&gt;mark(), "don't relock with same BasicLock");
    // 设置Displaced Mark Word为null，重入计数用
    lock-&gt;set_displaced_header(NULL);
    return;
  }
  // 步骤5
  // 走到这一步说明已经是存在多个线程竞争锁了，需要膨胀或已经是重量级锁
  lock-&gt;set_displaced_header(markOopDesc::unused_mark());
  // 进入、膨胀到重量级锁的入口
  // 膨胀后再调用monitor的enter方法竞争锁
  ObjectSynchronizer::inflate(THREAD, obj())-&gt;enter(THREAD);
}
</code></pre>

<p><code>步骤 1</code>、<code>markOop mark = obj-&gt;mark()</code>方法获取对象的markOop数据mark；</p>

<p><code>步骤 2</code>、<code>mark-&gt;is_neutral()</code>方法判断mark是否为无锁状态，标识位<strong>001</strong>；</p>

<p><code>步骤 3</code>、如果mark处于无锁状态，把mark保存到BasicLock对象(Lock Record的属性)的displaced_header字段；</p>

<p><code>步骤 3.1</code>、通过CAS尝试将Mark Word更新为指向BasicLock对象的指针，如果更新成功，表示竞争到锁，则执行同步代码，否则执行步骤4；</p>

<p><code>步骤 4</code>、如果是重入，则设置Displaced Mark Word为null。</p>

<p><code>步骤 5</code>、到这说明有多个线程竞争轻量级锁，轻量级锁需要膨胀升级为重量级锁；</p>

<p>结合上文偏向锁的流程，可以整理得到如下的流程图：
<center> <br>
<img src="https://tech.youzan.com/content/images/2021/06/-----2.svg" alt="Java锁与线程的那些事" style="zoom:67%;"></center></p>

<h3 id="242">2.4.2 轻量级锁的释放</h3>

<p>轻量级锁释放的入口在<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1923">bytecodeInterpreter.cpp#1923</a>。</p>

<p>轻量级锁释放时需要将<code>Displaced Mark Word</code>替换回对象头的<code>mark word</code>中。如果CAS失败或者是重量级锁则进入到<code>InterpreterRuntime::monitorexit</code>方法中。<code>monitorexit</code>直接调用<code>slow_exit</code>方法释放<code>Lock Record</code>。直接看<code>slow_exit</code>：</p>

<pre><code class="language-java">IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorexit(JavaThread* thread, BasicObjectLock* elem))  
  Handle h_obj(thread, elem-&gt;obj());
  assert(Universe::heap()-&gt;is_in_reserved_or_null(h_obj()),
         "must be NULL or an object");
  if (elem == NULL || h_obj()-&gt;is_unlocked()) {
    THROW(vmSymbols::java_lang_IllegalMonitorStateException());
  }
  // 直接调用slow_exit
  ObjectSynchronizer::slow_exit(h_obj(), elem-&gt;lock(), thread);
  // Free entry. This must be done here, since a pending exception might be installed on
  // exit. If it is not cleared, the exception handling code will try to unlock the monitor again.
  elem-&gt;set_obj(NULL);
IRT_END

void ObjectSynchronizer::slow_exit(oop object, BasicLock* lock, TRAPS) {  
  fast_exit (object, lock, THREAD) ;
}

void ObjectSynchronizer::fast_exit(oop object, BasicLock* lock, TRAPS) {  
  ...
  // displaced header就是对象mark word的拷贝
  markOop dhw = lock-&gt;displaced_header();
  markOop mark ;
  if (dhw == NULL) {
     // 什么也不做
     // Recursive stack-lock. 递归堆栈锁
     // Diagnostics -- Could be: stack-locked, inflating, inflated. 
     ...
     return ;
  }
  mark = object-&gt;mark() ;
  // 此处为轻量级锁的释放过程，使用CAS方式解锁。
  // 如果对象被当前线程堆栈锁定，尝试将displaced header和锁对象中的MarkWord替换回来。
  // If the object is stack-locked by the current thread, try to
  // swing the displaced header from the box back to the mark.
  if (mark == (markOop) lock) {
     assert (dhw-&gt;is_neutral(), "invariant") ;
     if ((markOop) Atomic::cmpxchg_ptr (dhw, object-&gt;mark_addr(), mark) == mark) {
        TEVENT (fast_exit: release stacklock) ;
        return;
     }
  }
  //走到这里说明已经是重量级锁或者解锁时发生了竞争，膨胀后再调用monitor的exit方法释放
  ObjectSynchronizer::inflate(THREAD, object)-&gt;exit (true, THREAD) ;
}
</code></pre>

<p>最后执行的是如果是fast_exit方法。如果是轻量级锁，尝试cas替换<code>mark word</code>。若解锁时有竞争，会调用<code>inflate</code>方法进行重量级锁膨胀，升级到到重量级锁后再执行<code>exit</code>方法。</p>

<h3 id="25">2.5 重量级锁</h3>

<h3 id="251">2.5.1 重量级锁的进入</h3>

<p>重量级锁通过对象内部的监视器（monitor）实现，其依赖于底层操作系统的<code>Mutex Lock</code>实现，需要额外的用户态到内核态切换的开销。由上文分析，<code>slow_enter</code>获取轻量级锁未成功时，会在<code>inflate</code>中完成锁膨胀：</p>

<pre><code class="language-Java">ObjectMonitor * ATTR ObjectSynchronizer::inflate (Thread * Self, oop object) {  
  ...
  for (;;) {
      const markOop mark = object-&gt;mark() ;
      assert (!mark-&gt;has_bias_pattern(), "invariant") ;  
      // mark是以下状态中的一种：
      // *  Inflated（重量级锁状态）     - 直接返回
      // *  Stack-locked（轻量级锁状态） - 膨胀
      // *  INFLATING（膨胀中）    - 忙等待直到膨胀完成
      // *  Neutral（无锁状态）      - 膨胀
      // *  BIASED（偏向锁）       - 非法状态，在这里不会出现

      // CASE: inflated
      if (mark-&gt;has_monitor()) {
          // 已经是重量级锁状态了，直接返回
          ObjectMonitor * inf = mark-&gt;monitor() ;
          ...
          return inf ;
      }
      // CASE: inflation in progress
      if (mark == markOopDesc::INFLATING()) {
         // 正在膨胀中，说明另一个线程正在进行锁膨胀，continue重试
         TEVENT (Inflate: spin while INFLATING) ;
         // 在该方法中会进行spin/yield/park等操作完成自旋动作 
         ReadStableMark(object) ;
         continue ;
      }
      // 当前是轻量级锁，后面分析
      // CASE: stack-locked
          if (mark-&gt;has_locker()) {
        ...
      }
      // 无锁状态
      // CASE: neutral
      // 分配以及初始化ObjectMonitor对象
      ObjectMonitor * m = omAlloc (Self) ;
      // prepare m for installation - set monitor to initial state
      m-&gt;Recycle();
      m-&gt;set_header(mark);
      // owner为NULL
      m-&gt;set_owner(NULL);
      m-&gt;set_object(object);
      m-&gt;OwnerIsThread = 1 ;
      m-&gt;_recursions   = 0 ;
      m-&gt;_Responsible  = NULL ;
      m-&gt;_SpinDuration = ObjectMonitor::Knob_SpinLimit ;       // consider: keep metastats by type/class
        // 用CAS替换对象头的mark word为重量级锁状态
      if (Atomic::cmpxchg_ptr (markOopDesc::encode(m), object-&gt;mark_addr(), mark) != mark) {
          // 不成功说明有另外一个线程在执行inflate，释放monitor对象
          m-&gt;set_object (NULL) ;
          m-&gt;set_owner  (NULL) ;
          m-&gt;OwnerIsThread = 0 ;
          m-&gt;Recycle() ;
          omRelease (Self, m, true) ;
          m = NULL ;
          continue ;
          // interference - the markword changed - just retry.
          // The state-transitions are one-way, so there's no chance of
          // live-lock -- "Inflated" is an absorbing state.
      }

      ...
      return m ;
}
</code></pre>

<p><code>inflate</code>其中是一个for循环，主要是为了处理多线程同时调用inflate的情况。然后会根据锁对象的状态进行不同的处理：</p>

<p>1.已经是重量级状态，说明膨胀已经完成，返回并继续执行ObjectMonitor::enter方法。 <br>
2.如果是轻量级锁则需要进行膨胀操作。 <br>
3.如果是膨胀中状态，则进行忙等待。 <br>
4.如果是无锁状态则需要进行膨胀操作。</p>

<p>轻量级锁膨胀流程如下：</p>

<pre><code class="language-java">if (mark-&gt;has_locker()) {  
  // 步骤1
  // 当前轻量级锁状态，先分配一个ObjectMonitor对象，并初始化值
  ObjectMonitor * m = omAlloc (Self) ;          
  m-&gt;Recycle();
  m-&gt;_Responsible  = NULL ;
  m-&gt;OwnerIsThread = 0 ;
  m-&gt;_recursions   = 0 ;
  m-&gt;_SpinDuration = ObjectMonitor::Knob_SpinLimit ;   // Consider: maintain by type/class
  // 步骤2
  // 将锁对象的mark word设置为INFLATING (0)状态 
  markOop cmp = (markOop) Atomic::cmpxchg_ptr (markOopDesc::INFLATING(), object-&gt;mark_addr(), mark) ;
  if (cmp != mark) {
    omRelease (Self, m, true) ;
    continue ;       // Interference -- just retry
  }
  // 步骤3
  // 栈中的displaced mark word
  markOop dmw = mark-&gt;displaced_mark_helper() ;
  assert (dmw-&gt;is_neutral(), "invariant") ;
  // 设置monitor的字段
  m-&gt;set_header(dmw) ;
  // owner为Lock Record
  m-&gt;set_owner(mark-&gt;locker());
  m-&gt;set_object(object);
  ...
  // 步骤4
  // 将锁对象头设置为重量级锁状态
  object-&gt;release_set_mark(markOopDesc::encode(m));
  ...
  return m ;
}
</code></pre>

<p><code>步骤 1</code>、调用<code>omAlloc</code>获取一个可用的<code>ObjectMonitor</code>对象。在<code>omAlloc</code>方法中会先从<strong>线程私有</strong>的<code>monitor</code>集合<code>omFreeList</code>中分配对象，如果<code>omFreeList</code>中已经没有<code>monitor</code>对象，则从<strong>JVM全局</strong>的<code>gFreeList</code>中分配一批<code>monitor</code>到<code>omFreeList</code>中；</p>

<p><code>步骤 2</code>、通过CAS尝试将Mark Word设置为markOopDesc:INFLATING，标识当前锁正在膨胀中。如果CAS失败，说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING，当前线程进行自旋等待膨胀完成。</p>

<p><code>步骤 3</code>、如果CAS成功，设置monitor的各个字段：设置<code>monitor</code>的header字段为<code>displaced mark word</code>，owner字段为<code>Lock Record</code>，obj字段为锁对象等；</p>

<p><code>步骤 4</code>、设置锁对象头的<code>mark word</code>为重量级锁状态，指向第一步分配的<code>monitor</code>对象；</p>

<h3 id="252monitor">2.5.2 monitor竞争</h3>

<p>当锁膨胀<code>inflate</code>执行完并返回对应的<code>ObjectMonitor</code>时，并不表示该线程竞争到了锁，真正的锁竞争发生在<code>ObjectMonitor::enter</code>方法中。</p>

<pre><code class="language-java">void ATTR ObjectMonitor::enter(TRAPS) {  
  Thread * const Self = THREAD ;
  void * cur ;
  // 步骤1
  // owner为null，如果能CAS设置成功，则当前线程直接获得锁
  cur = Atomic::cmpxchg_ptr (Self, &amp;_owner, NULL) ;
  if (cur == NULL) {
     ...
     return ;
  }
  // 如果是重入的情况
  if (cur == Self) {
     // TODO-FIXME: check for integer overflow!  BUGID 6557169.
     _recursions ++ ;
     return ;
  }
  // 步骤2
  // 如果当前线程是之前持有轻量级锁的线程
  // 上节轻量级锁膨胀将owner指向之前Lock Record的指针
  // 这里利用owner判断是否第一次进入。
  if (Self-&gt;is_lock_owned ((address)cur)) {
    assert (_recursions == 0, "internal state error");
    // 重入计数重置为1
    _recursions = 1 ;
    // 设置owner字段为当前线程
    _owner = Self ;
    OwnerIsThread = 1 ;
    return ;
  }
  ...
  // 步骤3
  // 在调用系统的同步操作之前，先尝试自旋获得锁
  if (Knob_SpinEarly &amp;&amp; TrySpin (Self) &gt; 0) {    
     ...
     //自旋的过程中获得了锁，则直接返回
     Self-&gt;_Stalled = 0 ;
     return ;
  }
  ...
  { 
    ...
    // 步骤4
    for (;;) {
      jt-&gt;set_suspend_equivalent();
      // 在该方法中调用系统同步操作
      EnterI (THREAD) ;
      ...
    }
    Self-&gt;set_current_pending_monitor(NULL); 
  }
  ...
}
</code></pre>

<p><code>步骤 1</code>、当前是无锁、锁重入，简单操作后返回。</p>

<p><code>步骤 2</code>、当前线程是之前持有轻量级锁的线程，则为首次进入，设置recursions为1，owner为当前线程，该线程成功获得锁并返回。</p>

<p><code>步骤 3</code>、先<strong>自旋尝试</strong>获得锁，尽可能减少同步操作带来的开销。</p>

<p><code>步骤 4</code>、调用EnterI方法。</p>

<p>这里注意，轻量级锁膨胀成功时，会把owner字段设置为<code>Lock Record</code>的指针，并在竞争时判断。这么做的原因是，假设当前线程A持有锁对象的锁，线程B进入同步代码块，并把锁对象升级为重量级锁。但此时，线程A可能还在执行，并无法感知其持有锁对象的变化。因此，需要线程B在执行<code>ObjectMonitor::enter</code>时，将自己放入到阻塞等列等待。并需要线程A第二次进入、或者退出的时候对monitor进行一些操作，以此保证代码块的同步。</p>

<p>这里有个<strong>自旋</strong>操作，直接看<code>TrySpin</code>对应的方法：</p>

<pre><code class="language-Java">// TrySpin对应的方法
int ObjectMonitor::TrySpin_VaryDuration (Thread * Self) {  
    // Dumb, brutal spin.  Good for comparative measurements against adaptive spinning.
    int ctr = Knob_FixedSpin ;  // 固定自旋次数
    if (ctr != 0) {
        while (--ctr &gt;= 0) {
            if (TryLock (Self) &gt; 0) return 1 ;
            SpinPause () ;
        }
        return 0 ;
    }
    // 上一次自旋次数
    for (ctr = Knob_PreSpin + 1; --ctr &gt;= 0 ; ) {
      if (TryLock(Self) &gt; 0) {  // 尝试获取锁
        // Increase _SpinDuration ...
        // Note that we don't clamp SpinDuration precisely at SpinLimit.
        // Raising _SpurDuration to the poverty line is key.
        int x = _SpinDuration ;
        if (x &lt; Knob_SpinLimit) {
           if (x &lt; Knob_Poverty) x = Knob_Poverty ;
           _SpinDuration = x + Knob_BonusB ;
        }
        return 1 ;
      }
      ...
      ...
</code></pre>

<p>从方法名和注释可以看出，这就是自适应自旋，<strong>和网上说的轻量级锁cas失败会自旋的说法并不一致</strong>。实际上，无论是轻量级锁cas自旋还是重量级锁cas自旋，都是在用户态尽可能减少同步操作带来的开销，并没有太多本质上的区别。
到此为止，我们可以再结合上述的内容，整理出如下的状态转换图：</p>

<p><center> <br>
<img src="https://tech.youzan.com/content/images/2021/06/-----3.svg" alt="Java锁与线程的那些事" style="zoom:60%;"></center></p>

<h3 id="253monitor">2.5.3 monitor等待</h3>

<p><code>ObjectMonitor</code>竞争失败的线程，通过自旋执行<code>ObjectMonitor::EnterI</code>方法等待锁的释放，EnterI方法的部分逻辑实现如下：</p>

<pre><code class="language-java">void ATTR ObjectMonitor::EnterI (TRAPS) {  
        // 尝试自旋
    if (TrySpin (Self) &gt; 0) {
        ...
        return ;
    }
    ...
    // 将线程封装成node节点中
    ObjectWaiter node(Self) ;
    Self-&gt;_ParkEvent-&gt;reset() ;
    node._prev   = (ObjectWaiter *) 0xBAD ;
    node.TState  = ObjectWaiter::TS_CXQ ;
    // 将node节点插入到_cxq队列的头部，cxq是一个单向链表
    ObjectWaiter * nxt ;
    for (;;) {
        node._next = nxt = _cxq ;
        if (Atomic::cmpxchg_ptr (&amp;node, &amp;_cxq, nxt) == nxt) break ;
        // CAS失败的话 再尝试获得锁，这样可以降低插入到_cxq队列的频率
        if (TryLock (Self) &gt; 0) {
            ...
            return ;
        }
    }
        ...
}
</code></pre>

<p>EnterI大致原理：一个<code>ObjectMonitor</code>对象包括两个同步队列（<code>_cxq</code>和<code>_EntryList</code>） ，以及一个等待队列<code>_WaitSet</code>。cxq、EntryList 、WaitSet都是由ObjectWaiter构成的链表结构。其中，<code>_cxq</code>为单向链表，<code>_EntryList</code>为双向链表。 <br>
<center> <br>
<img src="https://tech.youzan.com/content/images/2021/06/---------.svg" alt="Java锁与线程的那些事" style="zoom:100%;"></center></p>

<p>当一个线程尝试获得重量级锁且没有竞争到时，该线程会被封装成一个<code>ObjectWaiter</code>对象插入到cxq的队列的队首，然后调用<code>park</code>函数挂起当前线程，进入BLOCKED状态。当线程释放锁时，会根据唤醒策略，从cxq或EntryList中挑选一个线程<code>unpark</code>唤醒。如果线程获得锁后调用<code>Object#wait</code>方法，则会将线程加入到WaitSet中，进入WAITING或TIMED_WAITING状态。当被<code>Object#notify</code>唤醒后，会将线程从WaitSet移动到cxq或EntryList中去，进入BLOCKED状态。需要注意的是，当调用一个锁对象的<code>wait</code>或<code>notify</code>方法时，若当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。</p>

<h3 id="254monitor">2.5.4 monitor释放</h3>

<p>当某个持有锁的线程执行完同步代码块时，会进行锁的释放。在HotSpot中，通过退出monitor的方式实现锁的释放，并通知被阻塞的线程，具体实现位于<code>ObjectMonitor::exit</code>方法中。</p>

<pre><code class="language-java">void ATTR ObjectMonitor::exit(bool not_suspended, TRAPS) {  
   Thread * Self = THREAD ;
   // 如果_owner不是当前线程
   if (THREAD != _owner) {
     // 轻量级锁膨胀上来，还没调用过enter方法，_owner还指向之前轻量级锁Lock Record的指针。
     if (THREAD-&gt;is_lock_owned((address) _owner)) {
       assert (_recursions == 0, "invariant") ;
       _owner = THREAD ;
       _recursions = 0 ;
       OwnerIsThread = 1 ;
     } else {
       // 异常情况:当前不是持有锁的线程
       TEVENT (Exit - Throw IMSX) ;
       assert(false, "Non-balanced monitor enter/exit!");
       if (false) {
          THROW(vmSymbols::java_lang_IllegalMonitorStateException());
       }
       return;
     }
   }
   // 重入计数器还不为0，则计数器-1后返回
   if (_recursions != 0) {
     _recursions--;        // this is simple recursive enter
     TEVENT (Inflated exit - recursive) ;
     return ;
   }
   ...
   //这块开始是唤醒操作
   for (;;) {
     ...
     ...
     ObjectWaiter * w = NULL ;
     // 根据QMode的不同会有不同的唤醒策略，默认为0
     int QMode = Knob_QMode ;
     if (QMode == 2 &amp;&amp; _cxq != NULL) {
          ...
          ...
</code></pre>

<p><code>步骤 1</code>、处理owner不是当前线程的状况。这里特指之前持有轻量级锁的线程，由于没有调用过enter，owner指向仍为Lock Record的指针，以及其他异常情况。</p>

<p><code>步骤 2</code>、重入计数器还不为0，则计数器-1后返回。</p>

<p><code>步骤 3</code>、唤醒操作。根据不同的策略（由QMode指定），从cxq或EntryList中获取头节点，通过<code>ObjectMonitor::ExitEpilog</code>方法唤醒该节点封装的线程，唤醒操作最终由unpark完成。</p>

<p>根据QMode的不同(默认为0)，有不同的处理方式：</p>

<p>QMode = 0：暂时什么都不做； <br>
QMode = 2且cxq非空：取cxq队列队首的ObjectWaiter对象，调用ExitEpilog方法，该方法会唤醒ObjectWaiter对象的线程，然后立即返回，后面的代码不会执行了； <br>
QMode = 3且cxq非空：把cxq队列插入到EntryList的尾部； <br>
QMode = 4且cxq非空：把cxq队列插入到EntryList的头部；</p>

<p>只有QMode=2的时候会提前返回，等于0、3、4的时继续执行：</p>

<p>1.如果EntryList的首元素非空，就取出来调用ExitEpilog方法，该方法会唤醒ObjectWaiter对象的线程，然后立即返回； <br>
2.如果EntryList的首元素为空，就将cxq的所有元素放入到EntryList中，然后再从EntryList中取出来队首元素执行ExitEpilog方法，然后立即返回； <br>
3.被唤醒的线程，继续竞争monitor；</p>

<h3 id="26">2.6 本章小节</h3>

<p>本章介绍了Synchronized的底层实现和锁升级过程。对于锁升级，再看看本文整理的图，一图胜千言：</p>

<h6 id="centerimgsrchttpstechyouzancomcontentimages2021063svgaltstylezoom60center"><center><img src="https://tech.youzan.com/content/images/2021/06/-----3.svg" alt="Java锁与线程的那些事" style="zoom:60%;"></center></h6>

<p>这里有几个点可以注意一下:</p>

<p>1.HotSpot中，只用到了<strong>模板解释器</strong>，并没有用到字节码解释器，<code>monitorenter</code>的实际入口位于<a href="http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/cpu/x86/vm/templateTable_x86_64.cpp#l3667">templateTable<em>x86</em>64.cpp#3667</a>。本文的分析是基于字节码解释器的，因此部分结论不能作为实际执行情况。本章的内容只能作为Synchronized锁升级原理、各类锁的适用场景的一种<strong>窥探</strong>。 <br>
2.再次强调，无锁状态只能升级为轻量级锁，<strong>匿名偏向状态</strong>才能进入到偏向锁。 <br>
3.偏向锁<strong>并不都有利，</strong>其适用于<strong>单个线程重入</strong>的场景，原因为：偏向锁的撤销需要进入<code>safepoint</code>，开销较大。需要进入<code>safepoint</code>是由于，偏向锁的撤销需要对锁对象的<code>lock record</code>进行操作，而<code>lock record</code>要到其他线程的栈帧中遍历寻找。在非safepoint，栈帧是动态的，会引入更多的问题。目前看来，偏向锁存在的价值是为历史遗留的Collection类如Vector和HashTable等做优化，迟早药丸。Java 15中默认不开启。 <br>
4.执行Object类的<code>hashcode</code>方法，偏向锁撤销并且锁会膨胀为轻量级锁或者重量锁。执行Object类的<code>wait/notify/notifyall</code>方法，偏向锁撤销并膨胀成重量级锁。 <br>
5.轻量级锁适用于<strong>两个线程的交替执行</strong>场景：线程A进入轻量级锁，退出同步代码块并释放锁，会将锁对象恢复为无锁状态；线程B再进入锁，发现为无锁状态，会cas尝试获取该锁对象的轻量级锁。如果有竞争，则直接膨胀为重量级锁，没有自旋操作，详情看10。 <br>
6.唤醒策略依赖于<strong>QMode</strong>。重量级锁获取失败后，线程会加入cxq队列。当线程释放锁时，会从cxq或EntryList中挑选一个线程唤醒。线程获得锁后调用<code>Object#wait</code>方法，则会将线程加入到WaitSet中。当被<code>Object#notify</code>唤醒后，会将线程从WaitSet移动到cxq或EntryList中去。 <br>
7.重量级锁，会将线程放进等待队列，等待操作系统调度。而偏向锁和轻量级锁，未交由操作系统调度，依然处于用户态，只是采用CAS无锁竞争的方式获取锁。CAS通过Unsafe类中compareAndSwap方法，jni调用C++方法，通过汇编指令锁住cpu中的北桥信号。 <br>
8.许多文章声称一个对象关联到一个monitor，这个说法不够准确。如果对象已经是重量级锁了，对象头的确指向了一个<code>monitor</code>。但对于正在膨胀的锁，会先从<strong>线程私有</strong>的<code>monitor</code>集合<code>omFreeList</code>中分配对象。如果<code>omFreeList</code>中已经没有<code>monitor</code>对象，再从<strong>JVM全局</strong>的<code>gFreeList</code>中分配一批<code>monitor</code>到<code>omFreeList</code>中。 <br>
9.在编译期间还有<strong>锁消除</strong>和<strong>锁粗化</strong>这两步锁优化操作，本章没做介绍。 <br>
10.<strong>字节码实现中没有体现轻量级锁自旋逻辑</strong>。这可能是模板解释器中的实现，或者是jvm在不同平台、不同jvm版本的不同实现。但本文分析的字节码链路中没有发现该逻辑，倒是发现了<strong>重量级锁会自适应自旋竞争锁</strong>。因此个人对轻量级锁自适应自旋的说法存疑，至少hotspot jdk8u字节码实现中没有这个逻辑。但两者都是在用户态进行自适应自旋，以尽可能减少同步操作带来的开销，没有太多本质上的区别，并不需要特别关心。</p>

<h2 id="">三、线程的实现与状态转换</h2>

<h3 id="31">3.1 线程的实现</h3>

<p>（1）内核线程实现</p>

<p><strong>内核线程</strong>（Kernel-Level Thread，KLT）：由<strong>内核</strong>来完成线程切换，内核通过<strong>调度器</strong>对线程进行调度，并负责将线程的任务<strong>映射</strong>到各个处理器上。 程序一般不会直接去使用内核线程，而是使用内核线程的一种高级接口——<strong>轻量级进程</strong>（Light Weight Process，LWP），也就是通常意义上的线程。</p>

<p><strong>优点</strong>：每个LWP都是独立的调度单元。一个LWP被阻塞，不影响其他LWP。</p>

<p><strong>缺点</strong>：基于KLT，耗资源。线程的创建、析构、同步都需要进行系统调用，频繁的用户态、内核态切换。
<center> <br>
<img src="https://tech.youzan.com/content/images/2021/06/-----4.svg" alt="Java锁与线程的那些事" style="zoom:67%;"></center></p>

<p>（2) 用户线程实现（User Thread，UT）</p>

<p><strong>广义：非内核线程</strong>，都可认为是用户线程。（包括LWT，虽然LWT的大多操作都要映射到KLT）</p>

<p><strong>狭义</strong>：完全建立在<strong>用户空间</strong>的线程库上，系统内核不能感知线程存在的实现。UT也只感知到掌管这些UT的进程P。</p>

<p><strong>优点</strong>：用户线程的创建、同步、销毁和调度完全在用户态中完成，不需要内核的帮助。</p>

<p><strong>缺点</strong>：线程的创建、销毁、切换和调度都是用户必须考虑到问题。“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难。
<center> <br>
<img src="https://tech.youzan.com/content/images/2021/07/----2.svg" alt="Java锁与线程的那些事" style="zoom:67%;"></center></p>

<p>（3) 混合实现
混合模式下，<strong>即存在用户线程，也存在轻量级进程</strong>。用户线程的创建、切换、析构等操作依然廉价，可以支持大规模的用户线程并发，且可以使用内核线程提供的线程调度功能及处理器映射。</p>

<p><center><img src="https://tech.youzan.com/content/images/2021/06/----3-1.svg" alt="Java锁与线程的那些事" style="zoom:67%;"></center></p>

<p>线程的实现依赖操作系统支持的线程模型。在主流的操作系统上，hotspot、classic、art等虚拟机默认是 1:1的线程模型。在Solaris平台上，hotspot支持1:1、N：M两种线程模型。</p>

<h3 id="32">3.2 线程的转换</h3>

<p>首先明确一点，当我们讨论一个线程的状态，指的是Thread 类中threadStatus的值。</p>

<pre><code class="language-java">private volatile int threadStatus = 0;  
</code></pre>

<p>该值映射后对应的枚举为：</p>

<pre><code class="language-java">public enum State {  
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
</code></pre>

<p>也就是说，线程的具体状态，看threadStatus就行了。</p>

<p><strong>NEW</strong></p>

<p>先要创建Thread 类的对象，才能谈其状态。</p>

<pre><code class="language-java">Thread t = new Thread();  
</code></pre>

<p>这个时候，线程t就处于新建状态。但他还不是“线程”。</p>

<p><strong>RUNNABLE</strong></p>

<p>然后调用start()方法。</p>

<pre><code class="language-java">t.start();  
</code></pre>

<p>调用start()后，会执行一个native方法创建内核线程，以linux为例：</p>

<pre><code class="language-java">private native void start0();

// 最后走到这
hotspot/src/os/linux/vm/os_linux.cpp  
pthread_create(...);  
</code></pre>

<p>这时候才有一个真正的线程创建出来，并即刻开始运行。这个内核线程与线程t进行1：1的映射。这时候t具备运行能力，进入RUNNABLE状态。
RUNNABLE可以细分为READY和RUNNING，两者的区别只是是否等待到了资源并开始运行。 <br>
处于RUNNABLE且未运行的线程，会进入一个就绪队列中，等待操作系统的调度。处于就绪队列的线程都在等待资源，这个资源可以是cpu的时间片、也可以是系统的IO。
JVM并不关系READY和RUNNING这两种状态，毕竟上述的枚举类都不对RUNNABLE进行细分。</p>

<p><strong>TERMINATED</strong></p>

<p>当一个线程执行完毕（或者调用已经不建议的 stop 方法），线程的状态就变为 TERMINATED。进入TERMINATED后，线程的状态不可逆，无法再复活。</p>

<p><strong>关于BLOCKED、WAITING、TIMED_WAITING</strong></p>

<p>BLOCKED、WAITING、TIMED_WAITING都是带有同步语义的状态，我们先看一下<code>wait</code>和<code>notify</code>方法的底层实现。 <br>
wait方法的底层实现:</p>

<pre><code class="language-java">void ObjectSynchronizer::wait(Handle obj, jlong millis, TRAPS) {  
    ...
    ...
  //获得Object的monitor对象
  ObjectMonitor* monitor = ObjectSynchronizer::inflate(THREAD, obj());
  DTRACE_MONITOR_WAIT_PROBE(monitor, obj(), THREAD, millis);
  //调用monitor的wait方法
  monitor-&gt;wait(millis, true, THREAD);
    ...
}

  inline void ObjectMonitor::AddWaiter(ObjectWaiter* node) {
    ...
  if (_WaitSet == NULL) {
    //_WaitSet为null，就初始化_waitSet
    _WaitSet = node;
    node-&gt;_prev = node;
    node-&gt;_next = node;
  } else {
    //否则就尾插
    ObjectWaiter* head = _WaitSet ;
    ObjectWaiter* tail = head-&gt;_prev;
    assert(tail-&gt;_next == head, "invariant check");
    tail-&gt;_next = node;
    head-&gt;_prev = node;
    node-&gt;_next = head;
    node-&gt;_prev = tail;
  }
}
</code></pre>

<p>主要流程：通过object获得objectMonitor，将Thread封装成OjectWaiter对象，然后<code>addWaiter</code>将它插入<code>waitSet</code>中，进入waiting或timed_waiting状态。最后释放锁，并通过底层的<code>park</code>方法挂起线程；</p>

<p>notify方法的底层实现：</p>

<pre><code class="language-java">void ObjectSynchronizer::notify(Handle obj, TRAPS) {  
    ...
    ...
    ObjectSynchronizer::inflate(THREAD, obj())-&gt;notify(THREAD);
}
    //通过inflate方法得到ObjectMonitor对象
    ObjectMonitor * ATTR ObjectSynchronizer::inflate (Thread * Self, oop object) {
    ...
     if (mark-&gt;has_monitor()) {
          ObjectMonitor * inf = mark-&gt;monitor() ;
          assert (inf-&gt;header()-&gt;is_neutral(), "invariant");
          assert (inf-&gt;object() == object, "invariant") ;
          assert (ObjectSynchronizer::verify_objmon_isinpool(inf), "monitor is inva;lid");
          return inf 
      }
    ...
      }
    //调用ObjectMonitor的notify方法
    void ObjectMonitor::notify(TRAPS) {
    ...
    //调用DequeueWaiter方法移出_waiterSet第一个结点
    ObjectWaiter * iterator = DequeueWaiter() ;
    //将上面DequeueWaiter尾插入_EntrySet或cxq等操作
    ...
    ...
  }
</code></pre>

<p>通过object获得objectMonitor，调用objectMonitor的<code>notify</code>方法。这个notify最后会走到<code>ObjectMonitor::DequeueWaiter</code>方法，获取waitSet列表中的第一个ObjectWaiter节点。并根据不同的策略，将取出来的ObjectWaiter节点，加入到<code>EntryList</code>或<code>cxq</code>中。
<code>notifyAll</code>的实现类似于<code>notify</code>，主要差别在多了个for循环。</p>

<p>由这里以及上一章2.5.4 monitor释放小节中可以了解到，<code>notify</code>和<code>notifyAll</code>并不会立即释放所占有的ObjectMonitor对象，其真正释放ObjectMonitor的时间点是在执行<code>monitorexit</code>指令。</p>

<p>一旦释放<code>ObjectMonitor</code>对象了，<code>entryList</code>和<code>cxq</code>中的ObjectWaiter节点会依据<code>QMode</code>所配置的策略，通过ExitEpilog方法唤醒取出来的ObjectWaiter节点。被唤醒的线程，继续参与monitor的竞争。若竞争失败，重新进入BLOCKED状态，再回顾一下monitor的核心结构。<center>
<img src="https://tech.youzan.com/content/images/2021/06/---------.svg" alt="Java锁与线程的那些事" style="zoom:100%;"></center></p>

<p>既然聊到了<code>wait</code>和<code>notify</code>，那顺便也看下<code>join</code>、<code>sleep</code>和<code>park</code>。</p>

<p>打开 Thread.join() 的源码：</p>

<pre><code class="language-java">public final synchronized void join(long millis) throws InterruptedException {  
    ...
  if (millis == 0) {
    while (isAlive()) {
      wait(0);
    }
  } else {
    while (isAlive()) {
      long delay = millis - now;
      if (delay &lt;= 0) {
        break;
      }
      wait(delay);
      now = System.currentTimeMillis() - base;
    }
  }
}
</code></pre>

<p><code>join</code>的本质仍然是 <code>wait()</code> 方法。在使用<code>join</code>时，JVM会帮我们隐式调用<code>notify</code>，因此我们不需要主动notify唤醒主线程(可见析构函数<a href="https://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/runtime/synchronizer.cpp#368">synchronizer.cpp#368</a>)。
而<code>sleep()</code>方法最终是调用<code>SleepEvent</code>对象的park方法：</p>

<pre><code class="language-java">int os::sleep(Thread* thread, jlong millis, bool interruptible) {  
  //获取thread中的_SleepEvent对象
  ParkEvent * const slp = thread-&gt;_SleepEvent ;
  ...
  //如果是允许被打断
  if (interruptible) {
    //记录下当前时间戳，这是时间比较的基准
    jlong prevtime = javaTimeNanos();
    for (;;) {
      //检查打断标记，如果打断标记为true，则直接返回
      if (os::is_interrupted(thread, true)) {
        return OS_INTRPT;
      }
      //线程被唤醒后的当前时间戳
      jlong newtime = javaTimeNanos();
      //睡眠毫秒数减去当前已经经过的毫秒数
      millis -= (newtime - prevtime) / NANOSECS_PER_MILLISEC;
      //如果小于0，那么说明已经睡眠了足够多的时间，直接返回
      if (millis &lt;= 0) {
        return OS_OK;
      }
      //更新基准时间
      prevtime = newtime;
      //调用_SleepEvent对象的park方法，阻塞线程
      slp-&gt;park(millis);
    }
  } else {
    //如果不能打断，除了不再返回OS_INTRPT以外，逻辑是完全相同的
    for (;;) {
      ...
      slp-&gt;park(millis);
      ...
    }
    return OS_OK ;
  }
}
</code></pre>

<p><code>Thread.sleep</code>在jvm层面上是调用thread中<code>SleepEvent</code>对象的<code>park()</code>方法实现阻塞线程，在此过程中会通过判断时间戳来决定线程的睡眠时间是否达到了指定的毫秒。看到这里，对于<code>sleep</code>和<code>wait</code>的区别应该会有更深入的理解。</p>

<p><code>park</code>、<code>unpark</code>方法也与同步语义无关。每个线程都与一个许可(permit)关联。<code>unpark</code>函数为线程提供permit，线程调用<code>park</code>函数则等待并消耗permit。park和unpark方法具体实现比较复杂，这里不展开。
到此为止，我们可以整理出如下的线程状态转换图。</p>

<p><center><img src="https://tech.youzan.com/content/images/2021/06/-----5.svg" alt="Java锁与线程的那些事" style="zoom:80%;"></center>  </p>

<h3 id="33">3.3 本章小节</h3>

<p>Java 将OS经典五种状态中的ready和running，统一为 RUNNABLE。将WAITING（即不可能得到 CPU 运行机会的状态）细分为了 BLOCKED、WAITING、TIMED_WAITING。本章的内容较为简短，因为部分的内容已囊括在第一章中。</p>

<p>这里提个会使人困惑的问题： 使用socket时，调用accept()，read() 等阻塞方法时，线程处于什么状态？</p>

<p>答案是java线程处于RUNNABLE状态，OS线程处于WAITING状态。因为在jvm层面，等待cpu时间片和等待io资源是等价的。</p>

<p>这里有几个点可以注意一下：</p>

<p>1.JVM线程状态不代表内核线程状态。 <br>
2.BLOCKED的线程一定处于entryList或cxq中，而处于WAITING和TIMED WAITING的线程，可能是由于执行了sleep或park进入该状态，不一定在waitSet中。也就是说，处于BLOCKED状态的线程一定是与同步相关。由这可延伸出，调用 jdk 的 lock并获取不到锁的线程，进入的是 WAITING 或 TIMED_WAITING 状态，而不是BLOCKED状态。</p>

<h2 id="">四、相关资料</h2>

<p>本文主要参考资料：</p>

<p><a href="https://github.com/farmerjohngit/myblog/issues/12">死磕Synchronized底层实现--概论</a></p>

<p><a href="https://github.com/farmerjohngit/myblog/issues/13">死磕Synchronized底层实现--偏向锁</a></p>

<p><a href="https://github.com/farmerjohngit/myblog/issues/14">死磕Synchronized底层实现--轻量级锁</a></p>

<p><a href="https://github.com/farmerjohngit/myblog/issues/15">死磕Synchronized底层实现--重量级锁</a></p>]]></content:encoded></item><item><title><![CDATA[有赞实时计算 Flink 1.13 升级实践]]></title><description><![CDATA[<h2 id="">一、背景</h2>

<p>​    随着有赞实时计算业务场景全部以 Flink SQL 的方式接入，对有赞现有的引擎版本——Flink 1.10 的 SQL 能力提出了越来越多无法满足的需求以及可以优化的功能点。目前有赞的 Flink SQL 是在 Yarn 上运行，但是在公司应用容器化的背景下，可以统一使用公司 K8S 资源池，同时考虑到任务之间的隔离性以及任务的弹性调度，Flink SQL 任务 K8S 化是必须进行的，所以我们也希望通过这次升级直接利社区的 on K8S 能力，直接将Flink SQL 集群迁移到 K8S 上。特别是社区在 Flink 1.13 中 on Native  K8S  能力的支持完善，为了紧跟社区同时提升有赞实时计算引擎的能力，经过一些列调研，我们决定将有赞实时计算引擎由</p>]]></description><link>https://tech.youzan.com/flink_13/</link><guid isPermaLink="false">2b42954e-f4b4-407d-9b25-a75fc166b1d1</guid><category><![CDATA[Flink]]></category><category><![CDATA[FlinkSQL]]></category><category><![CDATA[bigdata]]></category><category><![CDATA[Flink 1.13]]></category><dc:creator><![CDATA[LiChuang]]></dc:creator><pubDate>Fri, 03 Dec 2021 08:34:00 GMT</pubDate><media:content url="https://tech.youzan.com/content/images/2021/12/--.jpeg" medium="image"/><content:encoded><![CDATA[<h2 id="">一、背景</h2>

<img src="https://tech.youzan.com/content/images/2021/12/--.jpeg" alt="有赞实时计算 Flink 1.13 升级实践"><p>​    随着有赞实时计算业务场景全部以 Flink SQL 的方式接入，对有赞现有的引擎版本——Flink 1.10 的 SQL 能力提出了越来越多无法满足的需求以及可以优化的功能点。目前有赞的 Flink SQL 是在 Yarn 上运行，但是在公司应用容器化的背景下，可以统一使用公司 K8S 资源池，同时考虑到任务之间的隔离性以及任务的弹性调度，Flink SQL 任务 K8S 化是必须进行的，所以我们也希望通过这次升级直接利社区的 on K8S 能力，直接将Flink SQL 集群迁移到 K8S 上。特别是社区在 Flink 1.13 中 on Native  K8S  能力的支持完善，为了紧跟社区同时提升有赞实时计算引擎的能力，经过一些列调研，我们决定将有赞实时计算引擎由 Flink 1.10 升级到 Flink 1.13.2。  </p>

<h2 id="flink113">二、有赞业务场景下的升级到 Flink 1.13 收益评估</h2>

<p>​    社区在发布 Flink 1.13 后相比于 Flink 1.10 有了很多的新特性和优化，有些新特性在有赞场景下可能并未用到，所以接下来将主要从以下几个方面介绍一下在有赞业务场景下升级到 Flink 1.13 的一些收益。</p>

<h3 id="21flinksql">2.1 Flink SQL 相关收益</h3>

<p>​    由于目前几乎所有的实时计算任务都通过 Flink SQL 方式实现，所以升级后关于 Flink SQL 上的一些优化是我们十分关注的，其中下面几点在升级后在有赞的实时计算业务场景下有很大的收益的：</p>

<h4 id="1flinksql">（1） Flink SQL 语法更为简洁，提高开发效率</h4>

<p>​    Flink 1.10 之后，社区提出了新的 connector 属性 key，SQL开发更为简洁，可以提升实时用户的开发作业效率。</p>

<h4 id="2">（2）时区和时间函数相关优化</h4>

<p>​    由于 Flink 1.10 的时间函数在时区问题的不完善，用户在使用 current<em>timestamp 和 current</em>day 等函数时由于时区问题需要额外的转换。而在 Flink 1.13 中对时区和时间函数进行纠正和优化，包括：</p>

<ul>
<li><p><strong>相关时间函数考虑了时区问题</strong></p>

<ul><li>CURRENT<em>TIMESTAMP/CURRENT</em>TIME/CURRENT_DATE/NOW() 在 Flink 1.13 中考虑了时区问题，且为本地时区。</li>
<li>PROCTIME() 考虑了时区问题，且为本地时区。</li></ul>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FqW7a1jkQXCOf4TGX5PZsN0BtbJ1.png" alt="有赞实时计算 Flink 1.13 升级实践" title=""></p></li>
<li><p><strong>支持了TIMESTAMP_LTZ类型</strong></p>

<p>例如 CURRENT<em>TIMESTAMP 函数返回值为 TIMESTAMP</em>LTZ 类型，而不是 
TIMESTAMP 类型。</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FvVrH23u_KBp7ckM39RETuZo6hbU.png" alt="有赞实时计算 Flink 1.13 升级实践" title=""></p></li>
<li><p><strong>夏令时支持</strong> Flink 支持在 TIMESTAMP<em>LTZ 列上定义时间属性，Flink SQL 在window 处理时结合 TIMESTAMP 和 TIMESTAMP</em>LTZ, 优雅地支持了夏令时。</p></li>
</ul>

<h4 id="3windowtvf">（3）支持 Window TVF 语法标准化</h4>

<p>​    在官方的介绍中，关于 Window TVF 包含四部分内容：Window TVF 语法，近实时累计计算，Window 性能优化，多维数据分析。
​    其中 Window TVF 语法在Flink 1.13 中用 Table-Valued Function 进行了语法标准化，在新的语法中支持 TUMBLE 和 HOP 窗口，我们通过以下两个例子来展示这一特性在某些场景下的应用：</p>

<ul>
<li><strong>用户在 table-valued 窗口函数中可以访问窗口的起始和终止时间，从而使用户可以实现新的功能</strong>。例如，除了常规的基于窗口的聚合和 Join 之外，用户现在也可以实现基于窗口的 Top-K 聚合：</li>
</ul>

<pre><code class="language-sql">SELECT *  
  FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY window_start, window_end ORDER BY price DESC) as rownum
    FROM (
      SELECT window_start, window_end, supplier_id, SUM(price) as price, COUNT(*) as cnt
      FROM TABLE(
        TUMBLE(TABLE Bid, DESCRIPTOR(bidtime), INTERVAL '10' MINUTES))
      GROUP BY window_start, window_end, supplier_id
    )
  ) WHERE rownum &lt;= 3;
</code></pre>

<ul>
<li><p><strong>新增了CUMULATE WINDOW 窗口</strong>，它可以支持按特定步长扩展的窗口，直到达到最大窗口大小，例如计算一段时间内的 PV, UV 等指标：</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FnF5ol-U4uY8F_YURmnVquIPO7Uh.png" alt="有赞实时计算 Flink 1.13 升级实践" title=""></p></li>
</ul>

<pre><code class="language-sql">  SELECT window_time, window_start, window_end, SUM(price) AS total_price 
    FROM TABLE(CUMULATE(TABLE Bid, DESCRIPTOR(bidtime), INTERVAL '2' MINUTES, INTERVAL '10' MINUTES))
  GROUP BY window_start, window_end, window_time;
</code></pre>

<ul>
<li>window1 统计的是第一个区间的数据；</li>
<li>window2 统计的是第一区间和第二个区间的数据；</li>
<li>window3 统计的是第一区间，第二个区间和第三个区间的数据。</li>
</ul>

<p>​  累积窗口可以对迟到的数据进行处理，比如某一个数据是 window1 的迟到数据，无法被 window1 统计进去，但是触发 window2 时，会把 window1 迟到的数据统计进去，而且 window2 会复用 window1 的统计结果，而不是重新计算一遍。</p>

<h4 id="4flinkonhive">（4）Flink On Hive 的能力</h4>

<p>​    目前在有赞已经开始有部分实时业务方希望 Flink 能够支持 Hive，比如 Flink-Hive 近实时的数仓中间层【小时表可更快产出】，以及 Flink 实时任务和离线数据对比功能。而在 Flink 1.12 中，已经支持生产级别 Flink On Hive 任务运行（社区 Commitor 说），所以基于这次 Flink 1.13 引擎版本升级，能够支持 Flink on hive 生产功能。因此本次升级可以解决部分实时业务方， Flink On Hive 的业务需求，下面是 Flink 1.13具体 Hive 相关功能：</p>

<ul>
<li>支持 Hive 的 SQL 语法，支持常用的 Hive DML，DQL语法。</li>
<li>Hive 写入：FLIP-115 完善扩展了 FileSystem connector 的基础能力和实现，Table/SQL 层的 sink 可以支持各种格式（CSV、Json、Avro、Parquet、ORC），而且支持 Hive table 的所有格式。</li>
<li>FLIP-123 通过 Hive Dialect 为用户提供语法兼容，可以直接迁移 Hive 脚本到 Flink 中执行等等。</li>
</ul>

<h4 id="5">（5）其他功能及需求</h4>

<ul>
<li><strong>支持查看 SQL 的执行计划</strong></li>
<li><strong>解决复杂 Quary 语句无法推断出 primary key 的问题</strong></li>
<li><strong>SQL Connectors 中的 Metadata 处理</strong>。如果可以将某些 source（和 format）的元数据作为额外字段暴露给用户，对于需要将元数据与记录数据一起处理的用户来说很有意义。一个常见的例子是 Kafka，用户可能需要访问 offset、partition 或 topic 信息、读写 kafka 消息中的 key 或 使用消息 metadata 中的时间戳进行时间相关的操作。</li>
</ul>

<pre><code class="language-sql">CREATEF TABLE table (  
id BIGINT,  
name STRING,  
event_time TIMESTAMP(3) METADATA FROM 'timestamp', -- access Kafka 'timestamp' metadata  
headers MAP METADATA -- access Kafka 'headers' metadata  
) WITH (
'connector' = 'kafka',  
'topic' = 'test-topic',  
'format' = 'avro'  
);
</code></pre>

<h3 id="22flinkonk8s">2.2 Flink on K8S 相关收益</h3>

<p>​    在 on K8S 层面考虑升级到 Flink 1.13 主要有以下几个方面收益：</p>

<h4 id="1flink113onk8s">（1） Flink 1.13 on K8S 更成熟稳定</h4>

<p>​    相比于Flink 1.11 和 Flink 1.12，在Flink 1.13 版本中 on K8S 模式上更加丰富，更为成熟稳定。而且社区后续肯定是在 Native K8S 或者 Application Level K8S 上面发力。目前 社区在Flink 1.13 中关于  K8S  已经有了下面一些优化和新特性：</p>

<ul>
<li><p><strong>基于 Kubernetes 的高可用 (HA) 方案</strong>
Flink 可以利用 Kubernetes 提供的内置功能来实现 JobManager 的 failover，而不用依赖 ZooKeeper。为了实现不依赖于 ZooKeeper 的高可用方案，社区在 Flink 1.12（FLIP-144）中实现了基于 Kubernetes 的高可用方案。</p></li>
<li><p><strong>引入 Application 模式</strong>
按照 application 粒度来启动一个集群，属于这个 application 的所有 job 在这个集群中运行。核心是 Job Graph 的生成以及作业的提交不在客户端执行，而是转移到 JM 端执行，这样网络下载上传的负载也会分散到集群中，不再有上述 client 单点上的瓶颈。</p></li>
</ul>

<h4 id="2">（2）实时离线弹性扩缩容</h4>

<p>​    目前有赞的离线任务已经实现了较好的弹性扩缩容，当 Flink SQL 任务 K8S 化之后，可以和离线任务之间实现更好的弹性扩缩容，节省集群资源成本，这是十分有意义的。</p>

<h3 id="23">2.3 状态保留和恢复相关收益</h3>

<h4 id="1savepoint">（1）基于Savepoint跨集群迁移的能力</h4>

<p>​    在 Flink 1.10 版本中，Savepoint 中 meta 数据和 state 数据存放的是绝对路径，这就造成了不能进行集群迁移，否则会造成任务状态丢失。而在 Flink 1.10 以后 savepoint 中 meta 数据和 state 数据保存在同一目录，方便整体转移和复用；把 state 引用改成了相对路径，这样即使迁移后路径发生变化依然可用。</p>

<h4 id="2unalignedcheckpoint">（2）生产可用的 Unaligned Checkpoint</h4>

<p>​    用户现在使用 Unaligned Checkpoint 时也可以扩缩容应用。如果用户需要因为性能原因不能使用 Savepoint 而必须使用 Retained checkpoint 时，这一功能会非常方便。
​ <br>
<strong>收益</strong>：这一特性极大提升我们 checkpoint 的性能，同时也优化了在反压场景下 checkpoint超时失败的问题，解决目前一些大状态任务经常 checkpoint 超时的问题。同时也符合我们利用 checkpoint 来做重启状态恢复的场景。</p>

<h4 id="3checkpoint">（3）优化失败 Checkpoint 的异常和失败原因的汇报</h4>

<p>​    Flink 1.13 现在提供了失败或被取消的 Checkpoint 的统计，从而使用户可以更简单的判断 Checkpoint 失败的原因，而不需要去查看日志。Flink 1.13 之前的版本只有在 Checkpoint 成功的时候才会汇报指标（例如持久化数据的大小、触发时间等）。</p>

<h3 id="24upsertkafkaformat">2.4 支持 upsert kafka 和 更丰富 Format 格式</h3>

<h4 id="1upsertkafka">（1）支持 upsert kafka</h4>

<p>​    在 Flink 1.12 中支持了 Upsert kafka，这一特性在有赞的实时计算业务场景中可以在某些数据链路中保障数据一致性。对于公司现有的一些场景，Upsert-kafka可以解决一些典型场景的数据重复问题：比如下图展示的在有赞很常见的一条实时链路，上游数据可以能是 MySQL binlog 或者NSQ -> Kafka 进行数据同步，然后下游对 Kafka 数据进行按照 key 聚合，将聚合数据存到 mysql , tidb 等等。这是很容易产生的问题就是在中间环节写入 Kafka 时很可能因为容错恢复等一些原因造成数据重复，特别是在 checkpoint 时间比较大时，造成的重复的数据量会很大，在现有的解决方案中，往往需要业务方在写入Kafka 时进行幂等操作，比如存入ZanKV等方式进行幂等。但是现有的方式问题就是现在的幂等方式性能有限，同时不能做到完全幂等。 
<img src="https://img01.yzcdn.cn/upload_files/2021/12/02/Funqck9BJ7t1eGHmKYnovS2vfUce.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    而接入 Upsert Kafka 连接器支持以 upsert 方式从 Kafka topic 中读取数据并将数据写入 Kafka topic。 作为 source，upsert-kafka 连接器生产 changelog 流，其中每条数据记录代表一个更新或删除事件。更准确地说，数据记录中的 value 被解释为同一 key 的最后一个 value 的 UPDATE，如果有这个 key（如果不存在相应的 key，则该更新被视为 INSERT）。</p>

<p>​    作为 sink，upsert-kafka 连接器可以消费 changelog 流。它会将 INSERT/UPDATE_AFTER 数据作为正常的 Kafka 消息写入，并将 DELETE 数据以 value 为空的 Kafka 消息写入（表示对应 key 的消息被删除）。Flink 将根据主键列的值对数据进行分区，从而保证主键上的消息有序，因此同一主键上的更新/删除消息将落在同一分区中，实现像 Hbase 一样的幂等写入。
<img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FmWlmDYIaWv_TT0woblLHbQYoQqS.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<h4 id="2format">（2）支持更丰富的 Format 格式</h4>

<p>​    在 Flink1.10 版本中对 Source 和 Sink 的 Format 支持是有限的，这也造成了我们业务方有些任务需要 Source 段支持更多的格式，比如 Kafka 支持 Raw、本地调试功能中Filesystem 需要支持 Json 等，这在 Flink 1.10 版本中是无法做到的。但是如果升级到 Flink 1.13 则可以完美解决这些问题。</p>

<h3 id="25">2.5 其他相关收益</h3>

<h4 id="1jmtm">（1）查看JM和TM的内存相关指标</h4>

<p>​    Flink 1.12 在 WebUI 上暴露了 JobManager 内存相关的指标和配置参数（FLIP-104）。对于 TaskManager 的指标页面也进行了更新，为 Managed Memory、Network Memory 和 Metaspace 添加了新的指标，以反映自 Flink 1.10（FLIP-102）开始引入的 TaskManager 内存模型的更改。</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FliOmBqmnQo2IALTMYMMBstwrRNT.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<h4 id="2webui">（2）WebUI界面查看被压情况</h4>

<p>​    Flink 1.13 带来了一个改进的背压度量系统(使用任务邮箱计时而不是线程堆栈采样)，以及一个重新设计的作业数据流图形表示，用颜色编码和繁忙度和背压比率表示。</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FogLW-coH-WdSh2gEr-PBfut7ukK.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<h4 id="3cpu">（3）CPU 火焰图查看</h4>

<p>​    可以直观的看 CPU 的火焰图来确定以下指标：当前哪些方法在消耗 CPU 的资源、各个方法消耗的 CPU 的资源的多少对比、堆栈上的哪些调用会导致执行特定的方法。</p>

<h4 id="4webui">（4）Web UI 支持历史异常</h4>

<p>​    Flink Web UI 现在可以展示导致作业失败的 n 次历史异常，从而提升在一个异常导致多个后续异常的场景下的调试体验。用户可以在异常历史中找到根异常。</p>

<h2 id="flink113">三、Flink 1.13 升级过程实践与踩坑</h2>

<p>​    实时计算平台 Flink 引擎从 Flink 1.10 升级到 Flink 1.13 的主要工作将主要集中在自定义 connector 的升级、SQL 语法升级转换、任务迁移验证等几个方面的实践和踩坑来介绍此次升级过程。
<img src="https://img01.yzcdn.cn/upload_files/2021/12/03/Fg6q5S6k5VDu4FHp6PPzt4e5MYLS.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<h3 id="31connector">3.1 自定义 connector 升级</h3>

<p>​    目前有赞的实时计算平台的数据流如下图所示，包括有赞自研的 NSQ、Kafka、Mysql、TiDB、Clickhouse、Habse 等大数据组件。那么此次升级需要将一些官方没有提供以及一些已经定制化的 connector 升级，其中包括 NSQ connector，定制的无用户名密码的 jdbc connector, clickhouse connector, 定制的高可用 hbase connector等。</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FoVufekUoUyM6nBlRdPFzgxf1kCN.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    本次升级 connector 的主要工作是在 Flink 1.10 中 DataStream 和 Table connector 都统一是用到的是 Row 这种数据结构。而Flink 1.11 在 FLIP-95 对 TableSource 和 TableSink API 进行了重构，新增了 Flink SQL 内部数据结构 RowData, 在一些场景的序列化有一定的提升。为此，我们需要对上述四种定制或者自定义的 table connector 进行升级重构，对于无用户名密码的 jdbc connector 的链接方式采用的是连接池构建链接的方式，但是采用链接池的方式构建链接时，如果对于 Flink 任务长时间没有数据流入则链接会被释放掉，如果再次过来数据用原来的链接去写入数据时会抛出链接被关闭的异常，导致任务出现频繁的重启：</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FkLwmU-tt439jZ_TTgrS4CDaLSol.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    为解决上述问题，需要在 flush 前检查链接是否有效，如果连接失效需要重新构建链接：</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FuU9f_Zy1UBo86MGP-Aa6ZZl_FG2.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<h3 id="32udf">3.2 UDF 兼容</h3>

<p>​    在 Flink 1.10 版本有赞实时计算平台根据业务需求提供了很多通用的 UDF, 如 Dubbo 调用，JSON 转换，动态过滤条件。同时用户也自定义了一部分 UDF。所以在升级的过程中需要保证 UDF 的兼容性。好在 Flink 本身对 UDF 做了良好的兼容性，我们只需要将 maven 中 flink-table-common 改成对应的 Flink 版本即可。其中要注意的一点是，在 Flink 1.13 版本中如果 UDF 的参数是 Object 需要加上注解 @DataTypeHint(inputGroup = InputGroup.ANY) 帮助 Flink 做类型推荐。</p>

<h3 id="33sql">3.3 SQL 语法转换实践</h3>

<p>​    在 Flink 1.13 SQL 用法中相比于 Flink 1.10 的 SQL 用法主要有以下几部分存在差异：建表语句的配置项简化 、时间函数的优化导致类型不匹配、存在 upsert 操作的的建表语句中需要指定 primary key。为保证任务可以平滑的从 Flink 1.10 升级到 Flink 1.13，我们对目前集群已有的数百个 Flink 1.10 语法的 SQL 任务进行转换，自动生成 Flink 1.13 版本的语法。`</p>

<h4 id="1">（1）建表语句的配置项转换</h4>

<p>​    在Flink 1.13 中社区提出了新的 connector 属性 key，SQL 开发更为简洁，如下图分别展示了 Kafka 作为数据源时在 Flink 1.10 语法中的 connector 属性配置以及转换后在 Flink 1.13 语法中的属性配置。</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FqT8CtuWAmOczNUUAnDB2Zh2jGaP.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    从上图的对比可以看出 Flink 1.13 语法中的 connector 属性配置 相比于 Flink 1.10 语法更为简洁易懂。虽然 Flink 本身对老版本的 SQL connector 的配置依然兼容，但是为了让用户使用新版的语法，我们对 用户在 Flink 1.10 的任务 SQL 进行配置了转换。</p>

<p>​    <strong>值得注意的是</strong>:在一些 connector 的属性配置中，一些属性的 key 进行了改变，以 Kafka connector 为例，其中在 Flink 1.10 中 <strong>format.fail-on-not-json-record = false</strong> 要对应 Flink 1.13 中的  <strong>json.ignore-parse-errors = true</strong> 表示的是按照 JSON 格式解析数据失败则跳过。同样 <strong>connector.startup-mode = earliest-offset</strong> 和 <strong>scan.startup.mode = earliest-offset</strong> 都表示从 consumer 的最早的点位开始消费，但是配置的 key 已经改变了，这是大家在做新老版本语法转换需要注意的事情。</p>

<h4 id="2">（2）时间函数类型逻辑转换及时间数据类型转换</h4>

<p>在 Flink 1.13 中对一些时间函数进行了优化正如上一章的第一节所介绍的，那么在现有的 Flink 1.10 SQL 业务中，有些用户用到了相关的时间函数比如最常见的 current<em>timestamp 函数，那么我们要对任务进行平滑升级时需要对使用 current</em>timestamp 等时间函数进行相应的逻辑转换，主要是时区变更的转化和类型不匹配的转换。</p>

<ul>
<li><strong>时间函数时区逻辑转换</strong>以 current<em>timestamp 函数为例，在 Flink 1.10 版本中 current</em>timestamp 未考虑时区是 UTC+0 时间，而升级 Flink 1.13 之后 current_timestamp 考虑时区，且是本地时区时间。因此在之前的任务中，有些任务为了解决时区问题在任务中加了8小时或者减了16小时（前一天时间）。那么针对已经进行了时区转换的任务，我们需要将对应的 8 小时 时差减去，因此关于这一点我们对 SQL 任务进行匹配分析，对已经做了时区转换的任务逻辑减去 8 小时的时差。</li>
<li><strong>时间函数类型转换</strong>还是以 current<em>timestamp 函数为例，在 Flink 1.10 版本中 current</em>timestamp 返回值类型为 timestamp 而在 Flink 1.13 中 current<em>timestamp 返回值类型为 timestamp</em>ltz 的格式。那么在 Flink 1.10 中的 current_timestamp 一些函数使用在 Flink 1.13中会报错，比如 TIMESTAMPDIFF 和 TIMESTAMPADD。简单举个例子</li>
</ul>

<pre><code class="language-sql">TIMESTAMPDIFF(MINUTE, (current_timestamp - INTERVAL '10' MINUTE), TO_TIMESTAMP(FROM_UNIXTIME(orderTime / 1000, 'yyyy-MM-dd HH:mm:ss')))  
</code></pre>

<p>​    上述语句在 Flink 1.13 中会因为 TIMESTAMPDIFF 函数中一个是 timestamp<em>ltz 格式 一个是 timestamp 而出现异常，为此需要转换成同一种类型，比如将后面的时间转为 timestamp</em>ltz 类型，才能应用 TIMESTAMPDIFF、TIMESTAMPADD 等函数。</p>

<pre><code class="language-sql">TIMESTAMPDIFF(MINUTE, (current_timestamp - INTERVAL '10' MINUTE), TO_TIMESTAMP_LTZ(mainOrderInfo.orderTime , 3))  
</code></pre>

<p>​    如果需要将 CURRENT<em>TIMESTAMP 的 TIMESTAMP</em>LTZ 类型转为 TIMESTAMP 类型，可以使用下面的方式进行转换：    </p>

<pre><code>TO_TIMESTAMP(CAST(CURRENT_TIMESTAMP AS STRING))  
</code></pre>

<p>​    因此在升级到 Flink 1.13 中关于时间函数的使用转换是尤为需要注意的，否则会因为逻辑不对造成数据不准确，或者任务异常无法启动。</p>

<h4 id="3primarykey">（3）Primary key 自动生成</h4>

<p>​    在 Flink 1.10 以后对于存在 upsert 操作时比如写 mysql，tidb 时出现了聚合等操作需要在建表语句中指定 primary key, 这也是为了解决在 Flink 1.10 中对于一个复杂的 SQL 语句无法通过优化器 从Quary 语句中自动推断出 primary key而产生异常的问题。因此，为了平滑升级，我们需要对 upsert 流的建表语句中指定 primary key，否则会提示异常: </p>

<blockquote>
  <p>please declare primary key for sink table when query contains update/delete record.</p>
</blockquote>

<p>​    我们采用优化器推断 Quary 语句推断的方式实现了一套 primary key 自动生成的逻辑，然后判断任务是为 upsert 流 来为需要添加 primary key 的建表语句自动生成对应的 primary key。当然对于一些过于复杂的 SQL 任务如果生成失败会进行提示，联系用户自己去手动添加 primary key, 我们的 primary key 生成逻辑满足 95%的任务的 primary key 的自动生成。 </p>

<h3 id="34">3.4 任务平滑迁移实践与踩坑</h3>

<p>​    在 Flink 1.10 SQL 任务升级到 Flink 1.13 版本的过程中，我们除了做了语法转换之外，还有批量按照 Flink 1.13 语法检查，数据准确性验证，批量重启等工作。整个工作过程如下流程图所示：</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FsKXDhiZB1vy3DAw7ZPRXRWEd_3k.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p><strong>其中有几点需要关注的是：</strong></p>

<ul>
<li>在迁移之前我们对各种任务构建了测试任务，并在第二天将测试任务的数据与老版本的实时任务和离线任务进行数据准确性验证；</li>
<li>同时关于 SQL 转换后关于 current_timestamp 这种时间函数的逻辑转换以及 primary key 的自动生成，需要在 SQL 转换后让用户进行 double check，反正升级后数据不准产生问题。</li>
<li>任务迁移尽量选择流量较小的时间段，防止重启异常时对业务产生很大的数据延迟影响。同时按照任务优先级的高低，以及根据实时任务血缘确定任务的重启顺序，比如在有赞的实时计算任务中，我们会优先重启低优先级和数据链路中下游的任务，在保证任务升级重启稳定运行一段时间后再去重启高优先级的任务，反正一些未发现的异常对升级后的任务产生大的影响。</li>
</ul>

<h3 id="35">3.5 其他踩坑和注意</h3>

<p>​    关于本次有赞实时计算平台引擎升级到 Flink 1.13 过程中也遇到过一些问题和踩过一些坑，一些问题已经在对应的实践中提及过了，那么还有遇到其他的一些升级过程中遇到一些问题在这里可以分享一下：</p>

<h4 id="1checkpoint">（1）任务升级后从之前版本的 checkpoint 文件恢复失败</h4>

<p>当我们升级 Flink 1.13 后的任务想通过之前的任务的 checkpoint 文件进行状态恢复时，会偶尔出现下面的异常：</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FlJK6YohkIGaYrWk0rc772sMRvNH.jpeg" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    通过社区邮件和源码阅读发现根本原因是在 Flink 1.11之后 BaseRowSerializer 改名成 RowDataSerializer了，即使用 state-processor-API 也没办法处理当前不存在的类。目前关于这一个问题社区也没有专门去处理的 Jira。</p>

<p>​    这种问题并不是所有的任务重启时从之前的状态文件恢复都会出现的，所以面对这种问题的比较好的办法就是升级重启的时间尽量选择在流量小的时间段，对于一些按天维度做聚合的任务最好在凌晨的时候重启，这样出现问题也不会对第二天的数据有很大的影响，同时对于恢复异常的任务做好数据重放的处理。</p>

<h4 id="2mysql">（2）Mysql 维表关联出现类型转换异常报错</h4>

<p>在升级 Flink 1.13 过程中，我们发现有几个 mysql 维表关联的任务升级重启后抛出如下异常：</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FlCKiyMrewRPY9yNlOp6hFki3kw8.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    在1.13中由于对 Table connector 数据类型统一为 RowData，在维表关联时如果业务方的 mysql 的字段类型定义为 BIGINT，当 mysql  中是 BIGINT UNSIGNED 时，如果用Flink 的BIGINT 去转成 mysql 的 BIGINT UNSIGNED 时会出现上述的报错。因为最终维表关联的数据要转换成RowData格式，所以不能将mysql 的 BIGINT UNSIGNED 与Flink 的 BIGINT 进行相互转换。</p>

<p>​    为了解决上述问题，在 Flink 1.11 中提出的一个Jira : FLINK-18580 ,官方建议在Flink 的建维表时将 BIGINT 定义为 DECIMAL(20,0)。</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/Fn9cyJRYDTzVGXPSMbcyy23Sm4Zy.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<h4 id="3insert">（3）执行多条 insert 语句任务异常</h4>

<p>​    在 Flink 1.10 中我们底层真正执行 SQL 的是 executeSql() 方法，对于 Flink 1.10 版本去调用该方法不会出现任何异常，且每条 insert 语句均有输出。但是升级到 Flink 1.13 之后，如果依然采用  executeSql() 方法去执行一个任务内的多条 insert 语句时会出现问题，我们发现只有第一条 insert 语句是有结果的，同时集群上出现多个相同的 job 被提交。如下面例子所示：</p>

<pre><code class="language-sql">insert into max_realtimet select guangBusinessId,st_hour,'orderCountHourAll',orderCountHourAll from order_hour_cnt_all_view;

insert into max_realtime select guangBusinessId,st_hour,'orderPaidAmountHourAll',orderPaidAmountHourAll from order_hour_cnt_all_view;

insert into max_realtime select guangBusinessId,st_hour,'orderPaidUserCountHourAll',orderPaidUserCountHourAll from order_hour_cnt_all_view;  
</code></pre>

<p>​    在 Flink 1.10的任务中 print 的结果是正常的：
<img src="https://img01.yzcdn.cn/upload_files/2021/12/02/Fs2dDbpohYPtBDtoSVCjCvrwGRQg.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    但是在 Flink 1.13 中可以明显只有第一条 insert 语句的输出:</p>

<p><img src="https://img01.yzcdn.cn/upload_files/2021/12/02/FnANo8KK3EwpLQqOBCFiPV8QcKwL.png" alt="有赞实时计算 Flink 1.13 升级实践"></p>

<p>​    通过官方文档的解释我们发现执在 Flink 1.13 版本中 executeSql() 方法每执行一条 insert 语句的会立即提交一个 Flink 作业，并返回一个与提交的作业相关联的 TableResult 实例。这也验证了为什么发现确实当启动一个多 insert 语句的任务时在集群会起来了多个 job。
​    为此需要采用 StatementSet 将 insert 语句添加到 StatementSet 中，最后执行 StatementSet.execute()，如下代码所示：</p>

<pre><code class="language-java">StatementSet statementSet = streamTableEnv.createStatementSet();  
sqls.forEach(sql -&gt; {  
  if(isInsertSql(sql)){
    statementSet.addInsertSql(sql);
  }else{
    streamTableEnv.executeSql(sql);
  }
});
statementSet.execute();  
</code></pre>

<p>​    上述是我们从 Flink 1.10 升级到 Flink 1.13 中间遇到的一些问题，因为在 Flink 1.10 以后社区的代码架构改动还是很大的，中间踩了一些坑，也遇到一些问题，其实好多问题在社区邮件和社区的 jira 里面都给出了好的解决方案，我们更多的介绍了实践过程中踩过的一些坑来分享。</p>

<h2 id="">四、总结</h2>

<p>​    目前有赞实时计算平台已经将 Flink 引擎从 Flink 1.10 升级到了 Flink 1.13，并将所有的 Flink SQL 任务平滑迁移升级到 Flink 1.13 版本中，并成功运行了近三个月。随着有赞更多的业务场景不断接入实时任务，目前 Flink SQL 任务接近整体实时任务体量的 60%，实时任务 SQL 化是我们的目标，因此升级到 Flink 1.13 后对于 Flink SQL 开发的简化以及特性增加与性能优化对我们来说是十分有价值的。</p>

<p>​    同时随着实时集群任务体量的增大，对资源的管控以及弹性扩缩容的需求也越来越大。而社区在 Flink on K8S 的投入也在不断增加，后续肯定是在 Native K8S 或者 Application Level K8S 有更多的优化，为此升级 Flink 1.13 之后我们将所有 Flink SQL 任务全部迁移到 K8S 集群，采用 Flink on Native 的 Application 模式运行任务，实现整个集群容器化，为后续的实时任务弹性扩缩容做好准备，目前我们已经完成 Flink on Native 的 Application 模式任务的测试阶段。后面将紧跟 Flink 社区的发展，为有赞的更多业务场景提供更多实践的可能。</p>]]></content:encoded></item></channel></rss>