wenmo8 发布的文章

大家好,我是汤师爷~

在当今的软件行业中,SaaS(软件即服务)模式正在迅速崛起。它改变了传统的软件使用方式。

多租户技术是SaaS模式的核心,这项技术让多个客户可以共享同一个软件系统,但每个客户的数据都是独立的,互不干扰。

本章将深入探讨多租户系统的概念、架构和实现方法。

多租户概述

什么是多租户?

多租户是SaaS(软件即服务)领域里特有的一个概念。在SaaS服务中,“租户”指的就是使用这个SaaS系统的客户。

那么租户和用户有什么区别呢?举个例子,假设你正在使用一款面向企业(ToB)的SaaS产品,"用户"通常指的是公司里实际操作这个SaaS系统的员工。而"租户"则代表整个公司或组织。也就是说,一个租户下可能包含多个用户。

多租户技术是一种软件架构,它允许多个租户共享同一个系统实例,同时确保每个租户的数据和行为互相独立、互不干扰。换句话说,尽管所有租户使用的是同一套系统,但各自的数据完全隔离,并且能够保障安全性。

传统软件模式 VS SaaS模式

传统的软件项目,一般是指软件公司根据客户的需求,专门开发一套特定的软件系统。然后,这个软件被部署在一个独立的环境里,通常就是企业内部的服务器上。

SaaS模式则不同,它将软件服务部署在云端环境中。不同的客户都能通过浏览器或网络访问,使用相同的软件服务。就好比一家自助餐厅,准备了各种各样的菜品,任何人都可以进来品尝,不需要自己下厨做饭。

多租户使用场景

对于面向企业(ToB)的SaaS产品,多租户的使用场景一般涉及到三个使用角色:企业主、员工、SaaS平台运营。

1、企业主

企业主,也就是公司的管理者,他们是SaaS平台的直接客户。企业主想要订购和使用SaaS产品,首先要在SaaS平台上创建一个“企业账号”(也就是租户)。

在这个过程中,企业主需要注册一个公司账户并填写相关信息。完成后,SaaS平台系统会为企业主创建一个专属的"租户空间"。

企业主随后可以在这个空间内订购所需的产品,并添加员工账号。一旦员工被加入到租户中,他们就能通过登录SaaS平台来使用这些产品。

2. 员工

一个员工可能会被多个企业添加,即该用户属于多个租户。这意味着员工在使用SaaS平台时,需要根据情况,切换到对应的租户。

例如,今天处理A公司的任务就切换到A公司,明天处理B公司的任务就切换到B公司。这种租户切换设计让员工能够在多个企业账号间自由切换,避免身份混淆影响使用体验。

3. SaaS平台运营

SaaS平台运营团队的主要职责是管理租户。他们需要确保每个租户的权限、资源分配、产品都得到妥善管理。

例如,当企业主新增员工时,平台必须确保该员工只能访问所属企业的数据。如果企业主想调整订购的产品,SaaS平台运营也能够迅速响应。

租户管理涉及权限控制、资源调度、能力配置、计费管理等一系列复杂操作。SaaS平台运营团队的职责就是确保这一切运转顺畅。

SaaS多租户隔离模式

SaaS多租户隔离模式

在SaaS模式下,多租户之间的资源隔离是基础且关键的一环。SaaS服务商需要在确保运营效率和控制成本的前提下,搭建一个让多个租户能够同时访问的共享环境。

虽然大家都在用同一套SaaS产品服务,但资源访问必须严格隔离,确保租户之间互不干扰。SaaS资源隔离通常包含以下几个层次:

1. 基础设施的隔离

第一层主要指SaaS系统运行所依赖的基础设施资源,比如计算资源(CPU、内存)、存储资源(数据库、文件系统)、网络资源(IP、带宽)等。

这些资源的隔离主要是为了确保不同租户在使用系统时,互相之间不会因为资源竞争而受到影响。

2. 组织权限的隔离

第二层隔离涉及组织权限的隔离,包括组织信息、用户账号、角色、权限配置、产品授权关系等。这些数据决定了谁能用什么功能,谁有操作哪些内容的权限。

组织权限隔离的目的是让每个租户拥有自己独立的组织架构和权限设置,不会因为其他租户的操作而发生冲突。

3. 业务数据的隔离

最后一层隔离涉及系统运行过程中产生的业务数据,如订单、发票、操作记录和数据报表等。通过精细的数据权限配置,这些信息可以在不同组织单元间实现完全隔离,从而保障业务信息的隐私性和安全性。

多租户架构主要解决第一层的隔离问题,即计算、存储和网络等资源的隔离。为了实现多租户隔离架构,我们需要先了解几种常见的多租户隔离模式。

竖井隔离模式

有些 SaaS 服务商选择竖井隔离模式,也就是每个租户都运行在独立的资源环境中。

有人会觉得,这不就是传统软件模式吗?为什么还是 SaaS?其实,如果这些独立资源具备标准化的租户身份识别、入驻流程、计费体系、部署和运营流程,那它仍然符合 SaaS 的定义,只不过是给每个客户都提供了一整套独立的基础设施。

优点

  • 满足强隔离需求:有些客户对系统和数据的安全性有极高要求,期望能在完全独立的环境中运行,避免与其他租户的应用实例或数据混合。
  • 计费逻辑简单:对 SaaS 服务商来说,资源使用计费可能很复杂,尤其是涉及计算、存储和网络资源的场景。但在竖井模式下,每个租户都是独立环境,计费模式相对来说会简单许多。
  • 降低故障影响面:因为每个客户的系统都是独立的,一个环境出问题不会影响其他客户的使用体验。

缺点

  • 规模化困难:每新增一个租户就要建立一套独立的环境,少量租户还能应付,但面对成千上万的租户,管理和运营这些环境的难度会成倍增加。
  • 成本高:每个租户单独的环境让机器成本上升,导致 SaaS 服务的盈利能力大打折扣。
  • 敏捷迭代受限:SaaS 的优势之一是快速迭代响应市场需求,但竖井隔离模式让这点变得不易操作,因为更新和管理每个租户的独立环境非常耗时、复杂。
  • 系统管理和监控复杂:在同一个环境中管理和监控基础设施相对简单,但每个租户都独立后,这种分散模式下的管理和监控会变得极具挑战性。

共享模式

相信很多 SaaS 服务商会优先选择共享模式,也就是多租户共享一套基础设施资源,这样能让 SaaS 软件服务更加高效、敏捷、低成本。

优势

  • 高效管理:在共享模式下,可以集中管理和运营所有租户,极大提升管理效率。同时,基础设施的配置管理和监控也更加简单。相比竖井模式,共享模式下的产品迭代更新速度更快。
  • 成本低:SaaS 服务商的成本中,基础设施占很大比例。在共享模式下,服务商可以根据租户的实际资源使用情况动态调整系统,极大提高基础设施的利用率,从而降低整体成本。

劣势

  • 租户相互影响:因为所有租户共享同一套资源,如果有租户大量占用资源,可能会影响其他租户的使用体验。为了解决这一问题,通常需要在技术架构上设计一些限制措施(如限流、降级、服务器隔离等),以控制影响范围。
  • 租户计费困难:在竖井模式下,很容易统计单个租户的资源消耗。而在共享模式中,由于所有租户共用资源,准确计算每个租户的使用成本会更加复杂,需要更多的精力和技术投入来实现合理的计费。

分域隔离模式

传统大企业往往更青睐私有化部署和个性化交付的传统模式,因为它们需要更强的管控能力和更高的安全性。而中小企业因为预算有限,需求通常也更标准化,因此更倾向于选择价格低、订购方便的 SaaS 产品。

为满足不同客户的需求,还有一种融合了竖井模式和共享模式的分域隔离模式。

在这种模式下,将资源细分为基础域和专用域:基础域使用共享模式,所有租户共享一套资源;专用域则采用竖井模式,每个租户拥有独立的资源环境。

大多数中小客户通常在基础域中使用 SaaS 产品,而只有少数具备强付费能力并有强隔离需求的大客户会选择在专用域中运行 SaaS 产品。

需要注意的是,为避免产生多个产品版本,SaaS 服务商需确保基础域和专用域的产品版本保持一致。

个性化需求尽可能通过 PaaS 平台来实现,让 ISV(独立软件开发商)参与建设。否则,一旦 SaaS 产品的标准化程度降低,多版本的维护将变得极其困难。

多租户系统的定位

在了解了多租户的使用场景,以及各种多租户隔离模式后,我们可以总结一下多租户系统的定位:

多租户系统的核心目的是让多个企业用户共享一套SaaS产品,但同时确保企业之间的数据和行为是完全隔离的。通过这种设计,系统能够在满足不同用户需求的同时,提供灵活的资源配置和高效的管理方式。

根据不同的用户需求,多租户系统可以在资源上做出调整,灵活支持资源的共享或隔离。例如,对于注重成本的客户,可以采用共享的资源模式;而对于数据敏感度高的客户,则可以采用竖井隔离等独立的资源分配方式。

这种灵活性让SaaS服务商可以在资源效率和客户个性化需求之间找到平衡,不论是降低成本,还是满足高安全需求,都有相应的方案。多租户系统需要具备的能力包括:

  • 支持多个租户共享同一套云资源,如计算、存储和网络资源等。同时,也支持单个租户独享一套云资源。
  • 实现租户之间的数据和行为隔离,并能够对租户进行分权分域控制。
  • 支持租户内部的组织架构管理,方便对产品进行授权和管理。
  • 根据客户需求,不同的产品功能可选择运行在不同的云资源上。

多租户概念模型

我们已经了解了多租户系统的定位和它需要具备的能力。现在,让我们深入探讨一下多租户系统的概念模型。概念模型是多租户系统的"骨架",帮助我们理解系统各部分的组织和运作方式。

多租户的核心概念模型

  • 租户
    :通常指一个企业客户,不同租户之间的数据和行为是相互隔离的。
  • 用户
    :某个租户内的具体使用者,使用账号、密码等信息登录到 SaaS 系统,使用软件服务。
  • 组织
    :如果租户是企业客户,通常会有自己的组织架构。
  • 员工
    :指组织内部的具体成员。
  • 解决方案
    :为解决客户的特定业务问题,SaaS 服务商将产品与服务组合打包,提供整体解决方案。
  • 产品
    :SaaS 服务商售卖给客户的软件应用,能够帮助客户实现端到端流程的闭环解决方案。
  • 资源域
    :用于运行一个或多个软件应用的一套云资源环境。
  • 云资源
    :SaaS 产品一般部署在各类云平台上(如阿里云、腾讯云、华为云等),这些平台提供的计算、存储、网络、容器等资源,被抽象为云资源。

SaaS平台可以创建和管理多个平台用户、租户和资源域,这样的模型结构,可以让多个企业客户和用户能在同一平台上运行,而彼此之间的数据和资源独立。

一个平台用户可以关联到多个租户。例如,张三作为一个平台用户,可以同时属于租户A和租户B。这种设计让平台用户能够灵活切换所属租户,方便在不同企业账号间工作。

在单个租户中,也可以拥有多个用户,让企业内多名员工共享租户的资源和服务。

单个租户可以订购多个解决方案,一个解决方案往往包含多个产品,以满足企业客户的多样需求。这些产品可以运行在特定的资源域,保证不同产品在同一租户中的高效管理和资源隔离。

租户内的组织架构支持上下级关系,在单个组织单元内,可以配置多个员工,并将员工与平台用户绑定,便于员工通过自己的账户访问相关服务。

概念模型的应用场景示例

1、租户与内部模型关系

在SaaS产品中,租户是最顶层的概念,可以理解为一个大房子的租赁人,而房子内部的组织、用户、产品、云资源等模型就像是这个房子里的各种家具和设施。

换句话说,租户是SaaS产品为每个客户或企业专门划分出的独立空间,而组织、用户、产品、云资源等模型则是租户内部的细分结构,为不同的使用需求和权限分配提供支持。

  • 租户:相当于整套房子的所有权,即租户拥有这套房子在SaaS平台中的使用权,是所有内部资源的顶层管理者。
  • 组织:类似房间的布局,每个房间有特定的功能(比如子公司、部门),组织架构帮助企业在平台内映射现实中的管理层级关系。
  • 用户:就像在房子里活动的人员,用户被赋予不同的角色和权限,决定了谁可以进入哪些房间,谁可以使用哪些家具。
  • 产品:如同家里各种各样的家具、电器、设施,满足不同的需求。不同租户可以选择不同的产品组合,随需增加或减少,来满足他们的业务需求。
  • 云资源:类似于水电煤等基础设施,支持房子里的各项功能正常运行,确保产品稳定、流畅地提供服务。

通过这种类比可以看出,SaaS产品将租户作为最顶层的概念,为企业提供了一套独立空间,租户内部的各项资源则在这个框架下被灵活管理和使用,让企业客户可以获得定制化的服务和资源隔离的安全保障。

2、租户身份识别

在多租户SaaS系统中,无论采用哪种隔离模式,准确识别租户身份并获取相应资源配置是非常关键的。

当用户登录SaaS系统后,系统会生成并返回租户上下文信息。这个上下文信息包括用户关联的租户和对应的隔离模式(如共享或独立资源)。

租户上下文信息会附加在每次系统交互中,贯穿整个系统调用链。从用户请求到系统内部处理的每一步,都保留了租户上下文。这样,上游的请求处理模块就可以知道如何路由和访问下游资源。

租户上下文信息让系统能够在请求传递过程中精准识别租户身份和配置。系统会根据上下文动态选择数据库、应用实例或网络资源,实现数据和资源隔离,确保不同租户的访问互不干扰。

3、租户计费计量管理

租户的计费计量管理,是SaaS平台不可或缺的一部分。不同的隔离模式下,计费方式有所不同。

竖井隔离模式下,计费相对简单。因为每个租户使用的资源(计算、存储、网络等)都是单独分配的,就像每个租户有自己的一块“独立地盘”。

因此,我们只需统计每个租户占用的资源量,就能计算出费用。这种模式逻辑清晰,一目了然。

共享模式下,计费则比较复杂。因为多个租户共享同一资源池,理想情况是,确保每个租户只为自己实际用到的部分买单。

通常,我们会综合考量几个指标,比如请求的数量、存储容量、创建的数据对象数量等。通过这些数据的组合,我们可以相对准确算出每个租户的费用。

多租户系统应用架构

我们刚刚探讨了多租户系统的概念模型和关键设计。现在,让我们看下多租户系统的应用架构是如何从一层层构建起来的。

1、应用层设计

应用层的主要作用是为具体的用户场景提供应用服务,帮助用户在特定场景下完成操作。通过编排领域层的各项能力,实现SaaS产品的核心功能。应用层包含两个关键模块:

  • 租户运营平台模块
    :这个模块负责SaaS平台的整体运营管理,包含客户管理、租户管理、云资源管理、订单管理、平台用户管理和数据分析等功能。它就像是平台的“指挥中心”,保证租户运营顺畅,帮助平台方掌控整体业务。
  • 商家后台模块
    :这个模块主要为企业客户服务,帮助他们自主管理相关功能。包括能力订购管理、费用账单管理、续费管理和能力授权管理。商家后台让客户在平台上方便管理自己订购的服务,并能随时查看账单和费用情况。
  • 开放平台模块
    :该模块为第三方开发者或合作伙伴提供访问和集成接口的能力。

2、领域层设计

领域层的核心是围绕核心业务对象,提供可复用的业务能力,这是对系统整体复用和抽象的结果。它包含以下模块:

  • 租户管理模块
    :负责租户的入驻管理,包括租户的资质认证与审核,租户隔离模式配置等。这个模块确保新租户顺利入驻,并设置合适的资源隔离模式。为租户提供一个安全、独立的使用环境。
  • 产品管理模块
    :负责产品版本配置、产品能力管理、产品生命周期管理等。
  • 云资源管理模块
    :云资源的管理、资源池管理、弹性伸缩策略等都在这个模块中实现。它为平台提供强大的资源管理能力,确保资源配置合理。
  • 计费计量管理模块
    :涉及计费方案管理、订购管理、履约、续费和账单报表。这个模块是SaaS平台收益的关键,确保平台能够精准收取租户的费用,并且账单清晰,续费顺畅。
  • 运营管理模块
    :包括客户运营、营销管理、订单管理和数据报表。该模块为平台提供全面的运营支撑,帮助平台提升客户体验、优化营销策略,保持订单和数据分析的良好运作。

本文已收录于,我的技术网站:
tangshiye.cn
里面有,算法Leetcode详解,面试八股文、BAT面试真题、简历模版、架构设计,等经验分享。

前言

分类树
查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。

但就是这样一个简单的分类树查询功能,我们却优化了
5
次。

到底是怎么回事呢?

背景

我们的网站使用了
SpringBoot
推荐的模板引擎:
Thymeleaf
,进行动态渲染。

它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。

它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。

前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。

由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询
分类
数据,组装成
分类树
,然后返回给前端。

通过这种方式,简化了数据流程,快速把整个页面功能调通了。

第1次优化

我们将该接口部署到dev环境,刚开始没啥问题。

随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。

我们不得不做优化了。

我们第一个想到的是:
加Redis缓存

流程图如下:

图片

于是暂时这样优化了一下:

  1. 用户访问接口获取分类树时,先从Redis中查询数据。
  2. 如果Redis中有数据,则直接数据。
  3. 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。
  4. 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。
  5. 将分类树返回给用户。

我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。

经过这样优化之后,dev环境的联调和自测顺利完成了。

第2次优化

我们将这个功能部署到st环境了。

刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。

于是,我们马上进行了第2次优化。

我们决定使用
Job
定期
异步
更新分类树到Redis中,在系统上线之前,会先生成一份数据。

当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。

于是,流程图改成了这样:

图片

增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。

其他的流程保持不变。

此外,Redis的过期时间之前设置的5分钟,现在要改成永久。

通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。

第3次优化

测试了一段时间之后,整个网站的功能快要上线了。

为了保险起见,我们需要对网站首页做一次压力测试。

果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。

我们需要做第3次优化。

该怎么优化呢?

答:加内存缓存。

如果加了内存缓存,就需要考虑数据一致性问题。

内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。

但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。

因此,分类树这种业务场景,是可以使用内存缓存的。

于是,我们使用了Spring推荐的
caffine
作为内存缓存。

改造后的流程图如下:
图片

  1. 用户访问接口时改成先从本地缓存分类数查询数据。
  2. 如果本地缓存有,则直接返回。
  3. 如果本地缓存没有,则从Redis中查询数据。
  4. 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。
  5. 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。

需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。

这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。

第4次优化

之后,这个功能顺利上线了。

使用了很长一段时间没有出现问题。

两年后的某一天,有用户反馈说,网站首页有点慢。

我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。

原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。

我们需要做第4次优化。

这时要如何优化呢?

限制分类树的数量?

答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?

这时我们想到最快的办法是开启
nginx

GZip
功能。

让数据在传输之前,先压缩一下,然后进行传输,在用户
浏览器
中,自动解压,将真实的分类树数据展示给用户。

之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。

这样简单的优化之后,性能提升了一些。

第5次优化

经过上面优化之后,用户很长一段时间都没有反馈性能问题。

但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。

我们不得不做第5次优化。

为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。

只保存需要用到的字段。

例如:

@AllArgsConstructor
@Data
public class Category {

    private Long id;
    private String name;
    private Long parentId;
    private Date inDate;
    private Long inUserId;
    private String inUserName;
    private List<Category> children;
}

像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。

修改自动名称。

例如:

@AllArgsConstructor
@Data
public class Category {
    /**
     * 分类编号
     */
    @JsonProperty("i")
    private Long id;

    /**
     * 分类层级
     */
    @JsonProperty("l")
    private Integer level;

    /**
     * 分类名称
     */
    @JsonProperty("n")
    private String name;

    /**
     * 父分类编号
     */
    @JsonProperty("p")
    private Long parentId;

    /**
     * 子分类列表
     */
    @JsonProperty("c")
    private List<Category> children;
}

由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。

由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。

这还不够,需要对存储的数据做压缩。

之前在Redis中保存的key/value,其中的value是json格式的字符串。

其实
RedisTemplate
支持,value保存
byte数组

先将json字符串数据用
GZip
工具类压缩成byte数组,然后保存到Redis中。

再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。

这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

解析

shared pool

图解:
library cache里面,暂时可以认为存储着:
1、SQL以及对应的执行计划(所占空间比较小);
2、存储过程、函数、触发器、包,它们编译后的对象(所占空间往往比较大,特别是包所占的比较大)

对于shared pool管理和研究的时候,row cache一般不会出现问题,所以一般情况我们都不研究row cache,会出问题的经常是library cache,所以我们经常研究它

如果要执行包里面的一个存储过程的时候,oracle就会把整个包头(包规范)的部分调到library cache里面去,这时候就可能造成比较大的一个对象突然被调到library cache里去,这时候有可能会报4013错误;所以写包的时候,尽量不要把过多的存储过程或者函数放到一个包里面去

在shared pool里,有时候可能造成比较大的一个对象突然被调到library cache里去,这时候有可能会报4013错误,所以为shared pool专门设了一个,一个比较大的对象突然被调到library cache里去的空间:reserved空间,用来存储突然来的大对象的空间,占shared pool空间大小的5%,

假设library cache的大小为5G,缓存着大量的SQL以及SQL的执行计划,现在,要去执行一个SQL,就在library cache里面的这些大量的SQL以及SQL的执行计划里面找,这就有一个问题,该怎么找呢?假设library cache里面有100万行,然后一个一个去对比,就要找100万次,这显然就不合适;

管理SQL以及SQL的执行计划

链(chain)
那么library cache里面是如何组织和管理SQL以及SQL的执行计划的,以便于很方便的找到要执行的SQL?

图解:
用链把一个一个的SQL链起来,假设library cache有500M大小,用4个链来管理SQL;对于SQL1来说:SQL1经过hash以后,得到hash值,然后计算:hash值/4的余数(0、1、2、3......):如果余数 = 0,就把SQL1挂在0号链上;如果SQL1在library cache里面,server process就认为SQL1一定在0号链上,然后在0号链上找,不需要在另外的链上找;另外假设SQL2,经过hash,然后计算余数,假设余数 = 1,就在1号链上找,最终没有找到,它就不找了,因为也不可能在其他的链上,然后SQL2就要产生硬解析了

链的特点:
1、链的一种访问方式:只能遍历,不能随机访问(找到链的头部就可以一个一个的找,一直找到链的尾部)
2、一种链一种作用(比如SQL经过hash,然后挂在链上的,找的时候也是SQL经过hash然后在链上找)

library cache里面的链:就是SQL经过hash值的方式挂起来的(当初怎么组织的,找的时候就怎么找)

library cache的大小,会影响链的数量(library cache多大,链的数量是多少,这个是oracle自己去调整、适应的;我们也可以调整相关的参数来调整链的数量)

一个链可以认为是一个bucket(桶)

Hash(其实就是一个函数)

例如对于SQL:select name from t where id = :x;
oracle会把SQL语句里面的每一个字母,转换成一个ASCII码值,每一个字母对应着一个编码,

一个SQL --> 一堆的文本字母 --> 一堆的数字

hash值与SQL的几种对应关系:
1、如果SQL1和SQL2完全相同,那么它们的hash值一定相等
2、如果SQL1和SQL2不相同,那么它们的hash值一定不相等
3、如果SQL1和SQL2的hash值相等,但是SQL1和SQL2不一定相同

所以比较两个SQL时:
1、如果两个SQL的hash值不相等,那么两个SQL就不相;
2、如果两个SQL的hash值相等,那么还要比较两个SQL,一个字母一个字母的去比较

free空间

free里面的chunk是如何管理的?
也是使用链来管理的

从free空间里面找空闲的chunk(内存块),怎么找?
根据大小来找;比如现在我们需要一个4k大小的,就在free里面找一个4k的chunk,不行就找比4k大一点点的(比如:5k、8k、12k),然后找5k的,所以free里面是通过一种以free chunk的大小的链把一个一个的chunk挂在上面的


图解:
有三个链:2k、8k、16k,现在需要一个9k的,就在8k的链上找,找到一个12k的,可以,就用一下,用了9k还剩下3k,又挂到2k的链上。

游标(cursor)

一个SQL以及SQL对应的执行计划,叫做一个cursor,在library cache里面

父游标(parent cursor)

父游标说的是:SQL文本;同一个SQL可能对应多个执行计划(因为访问的用户不同,表的名字一样(都是t表),但是表的内容不一样;或者因为绑定参数的值的不同(同一个值,一个表里有1000万行,另外一个表里只有10行(这里表的名字相同)),执行计划也可能不同)

子游标(children cursor)

子游标就是:执行计划;子游标的个数不定,根据实际情况而定(比如10个、100个不确定)

version count(版本数量)
例如一个父游标有10个子游标,那么它的版本数量就是10

latch:锁(内存锁、闩锁),用来保护链的


图解:
现在有一个问题:oracle有好多上的server process,;假设server process1执行SQL1,server process2执行SQL2,然后server process1执行SQL1的时候要解析,在library cache里面没找到,SQL1就要发生硬解析;执行SQL2的时候,同样也如此,SQL2也需要硬解析;同时解析;
在解析的时候,就需要在free里面找一个free chunk,把它写进去;假设解析SQL1需要一个,9k的free chunk,解析SQL2,需要一个10k的free chunk,所以都需要在8k的链上找,这就有一个并发的问题:假设SQL1和SQL2找到相邻的两个free chunk;SQL1就要把它找到的free chunk2拿下来,把free chunk1指向free chunk3;SQL2把找到的free chunk3拿下来,然后把free chunk2指向free chunk4;这时候,free chunk2和free chunk3都没了,链就断了,所以对于这种情况链就需要并发保护,使用锁(latch)来进行保护;
latch(对于0号链申请的一个内存结构),用来保护0号链的,现在server process1读这个latch(里面有没有写相关信息),发现里面是空的,server process1就以S的方式写上,server process1以S的方式来访问0号链;
latch里面是空的,说明没有进程来对这个链进行保护修改;然后server process2也要来访问0号链,发现有一个进程在以S的方式访问,server process2想加一个X方式,但是server process1以S的方式持有着latch,server process2也想持有着latch,但是S和X不兼容,所以server process2就不能持有着latch,这时候server process2就发生一次latch misses(latch丢失);
假设server process1在cpu1上工作,server process2在cpu2上工作(有两个cpu),这时候,还有一个server process3在等着cpu空出来再进去,现在对于server process3来说两种选择:
1、server process2退出来(latch丢失),再进入cpu2,server process3工作一段时间以后退出来,server process2再进去,继续执行之前未执行完的操作,这个过程叫做:context switch(CS);
2、server process1持有latch的时间非常短,很短的时间内就执行完了,这时候,server process2不出来,它执行一个for循环,占用着cpu2,等server process1执行完S之后释放了latch就进去,这时候server process3就可以使用cpu1了;
再有假设,有四个cpu,server process2不出来,占用着cpu2,这时候server process3也持有着latch,server process2又再一次latch丢失了,然后server process4又持有着latch,server process2空转cpu,server process2又再一次latch丢失,server process2多次latch丢失以后,server process2就转为sleep状态,然后就退出cpu了,其他的server process就可以占用这个cpu了

因此,如果数据库出现严重的latch征用,就会表现出cpu很繁忙
sleep状态,说明出现了多次latch misses(latch丢失)

当server process1想访问0号链的时候,有两种访问方式:
1、S(共享锁)的方式(读链,在链上找大小)
2、X(排他锁)的方式(修改链,就是往链上挂东西和往链上摘东西)

S方式和X方式的关系

例如:1、SQL1:S 2、SQL1:S 3、SQL1:X
SQL2:S SQL2:X SQL2:S
1、SQL1和SQL2可以同时进行(S、S可以兼容)
2、SQL1和SQL2不能同时进行,要等SQL1找完了,SQL2再去修改(S、X不兼容)
3、SQL1和SQL2不能同时进行,要等SQL1修改完了,SQL2再去找(S、X不兼容)

latch的另外一种情况:
多个链用一个latch来保护,就很有可能出现latch丢失,所以我们可以通过调整参数来让多个链让多个latch来保护

latch的种类(绝大部分):
1、父latch:library cache latch
2、子latch:每一个链上latch(library cache latch里面一个一个的latch)

latch的工作方式:
1、sp1:S方式持有着latch
sp2:X方式,出现latch misses,空转着cpu,多次latch misses以后,变为sleep状态

2、sp1:S方式持有着latch 0
sp2:X方式,也想持有着latch 0,出现latch misses;但是sp2从latch 0上的链上能找到想要的东西,从latch 1上的链上也能找到想要的东西,一样;这时候,sp2就跑到latch 1去找东西了

但是绝大多数都是以第一种工作方式(willing to waiter)工作:愿意等,或者必须等的

我们中高端的windows笔记本上都可以看到Dolby音效,TV电视上也有支持Dolby显示选项。

杜比主要有几类:Dolby全景声(也叫Atmos)、Dolby视界(Vision)、杜比影院(Dolby Cinema)

作为OEM厂商,如何获取杜比授权呢?可以看下Dolby官网的申请流程:
如何获取杜比授权许可 - Dolby Professional

上面是官方合同的流程了,没啥特别的,只是起步阶段。下面我讲下了解到的杜比合作流程:

先是合同,与杜比签合同时,Dolby全景声、Dolby视界分别需要支付2.5万美金押金,押金后面可以退回的

需要说明的是,签合同只能是以一级母公司(如果有的话)来签,提供各种证明啥的

签完合同,后面就是杜比认证流程,也可以叫联调阶段。杜比会安排专人对接提供驱动和软件,适配后寄产品到台湾实验室调参数,整个认证测试大概半个月。这个阶段是不需要费用的

后面就是集成设备,杜比会给驱动以及管理App,OEM厂商集成到母盘生产。如果需要试用的话,Windows Store里也可以安装Dolby Access、Dolby Audio

杜比音效
杜比音效 - Dolby Professional - Dolby Professional
,在OEM端集成到PC上,需要安装驱动。比如这台联想天启,音频处理器这里有DolbyAPO SWC Device音频处理驱动:

windows系统有默认的windows sonic,如果没有可能是注册了“扬声器保护”。杜比Atoms音效可以通过windows函数注入算法,与windows sonic结合混音输出。

再说说真正的使用费用,杜比会统一设备生产数量(线上或者其它渠道),单方向告知每年需要缴纳多少专利费用也叫版税,大概1-2美金一台。

注意有坑:因为杜比basic原因(下面会讲),一旦与杜比签合同,公司之前所有windows设备也要补缴专利费,这可不是小数字

啥叫杜比basic,这里
杜比Windows许可计划常见问题 - Dolby Professional
有介绍,结合网上其它文章,总结如下:

Dolby Basic是杜比与Windows合作,从Win10开始默认内置的杜比音效(Dolby Audio),它能满足用户对个人视频及优质娱乐的优化期望。通过在 Windows 中提供对杜比音频 (Dolby Audio) 的完全支持,Microsoft 在 Windows 应用程序中为用户提供一致、兼容和高质量的音频/视频体验。

Win10内置杜比Audio音效,Windows版本包含 AC-3 编解码器,Edge浏览器也支持杜比Audio
Dolby Digital Plus - Dolby Professional
,也可以见杜比官网新闻:
微软采用杜比音效提升Windows 10的娱乐体验 | 杜⽐新闻中⼼

AC-3 是一种支持多声道(“环绕声”)音频的音频编解码器,它也被称为杜比数字“Dolby Digital”。

原本,杜比 Windows Basic 如果公司每年生产超过 10万 台设备,只需向杜比支付版税。每年制造不到 10万台设备的 OEM 可以免费获得杜比技术。

而上面申请Dolby全景声、视界,就有坑了,要补杜比Basic的费用。

针对这个问题最近有个好消息,
Win11 24H2不再内置杜比音效,不预装杜比音频解码器

Media Player 中的编解码器 - Microsoft 支持

24H2系统自带的播放器将无法直接播放采用 AC-3 格式的音频文件。所以最近有很多反馈24H2不支持AC-3杜比音效的问题:

win11 24h2 移除了ac-3编解码支持(已找到解决方法) - 电脑讨论(新) - Chiphell - 分享与交流用户体验

[笔记本]如何在 Windows 11 24H2 之后安装杜比音频解码器 |官方支持 |华硕全球

新版杜比(Dolby)的安装及设置-联想知识库

在 Windows 11 版本 24H2 中恢复 Microsoft Dolby Digital 解码器/编码器 MFT 支持-远景论坛-微软极客社区

大家可以从Store安装解码器扩展解决:
Dolby Digital Plus decoder for PC OEMs - Windows官方下载 | 微软应用商店 | Microsoft Store

24H2之前的版本不受影响。另外杜比最新的音频编解码格式是AC-4

默认不支持杜比音效,对OEM厂商是好事,只需要关注杜比认证,出了多少台安装杜比软件就缴纳多少版税。

参考资料:

“杜比音效”究竟是什么? - 知乎
杜比

Dolby Vision、 Dolby Atmos和 DolbyAudio是什么?有什么区别? - 知乎

常见问题 - Dolby Professional

Windows 11 24H2:不再支持 AC-3 音频编码器,及应对措施 - 系统极客

关于杜比全景声的一些概念,终于搞懂了!

Windows 上的空间音效和“杜比音效”_哔哩哔哩_bilibili

技术背景

在Jax的JIT即时编译中,会追踪每一个Tensor的Shape变化。如果在计算的过程中出现了一些动态Shape的Tensor(Shape大小跟输入的数据有关),那么就无法使用Jax的JIT进行编译优化。最常见的就是
numpy.where
这种操作,因为这个操作返回的是符合判定条件的Index序号,而不同输入对应的输出Index长度一般是不一致的,因此在Jax的JIT中无法对该操作进行编译。当然,需要特别说明的是,
numpy.where
这个操作有两种用法,一种是
numpy.where(condition, 1, 0)
直接给输入打上Mask。另一种用法是
numpy.where(condition)
,这种用法返回的就是一个Index序列,也就是我们需要讨论的应用场景。

普通模式

我们考虑一个比较简单的Toy Model用于测试:

\[E=\sum_{|r_i-r_j|\leq \epsilon}q_iq_j
\]

在不采用即时编译的场景下,Jax的代码可以这么写:

import os
os.environ['XLA_PYTHON_CLIENT_PREALLOCATE']='false'

import numpy as np
np.random.seed(0)
import jax
from jax import numpy as jnp

def func(r, q, cutoff=0.2):
    dis = jnp.abs(r[:, None] - r[None])
    maski, maskj = jnp.where(dis<=cutoff)
    qi = q[maski]
    qj = q[maskj]
    return jnp.sum(qi*qj)

N = 100
r = jnp.array(np.random.random(N), jnp.float32)
q = jnp.array(np.random.random(N), jnp.float32)

print (func(r, q))
# 1035.7422

那么我们先记住这个输出的结果,因为采用的随机种子是一致的,一会儿可以直接跟JIT的输出结果进行对比。

JIT模式

Jax的JIT模式的使用方法常见的就是三种,一种是在函数头顶加一个
装饰器
,一种是在函数引用的时候使用
jax.jit(function)
来调用,最后一种是配合
partial
偏函数
来使用,都不是很复杂。那么这里先用装饰器的形式演示一下Jax中即时编译的用法:

import os
os.environ['XLA_PYTHON_CLIENT_PREALLOCATE']='false'

import numpy as np
np.random.seed(0)
import jax
from jax import numpy as jnp

@jax.jit
def func(r, q, cutoff=0.2):
    dis = jnp.abs(r[:, None] - r[None])
    maski, maskj = jnp.where(dis<=cutoff)
    qi = q[maski]
    qj = q[maskj]
    return jnp.sum(qi*qj)

N = 100
r = jnp.array(np.random.random(N), jnp.float32)
q = jnp.array(np.random.random(N), jnp.float32)

print (func(r, q))

正如前面所说,因为
numpy.where
对应的输出是一个动态的Shape,那么在编译阶段就会报错。报错信息如下:

Traceback (most recent call last):
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 21, in <module>
    print (func(r, q))
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/traceback_util.py", line 162, in reraise_with_filtered_traceback
    return fun(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/api.py", line 622, in cache_miss
    execute = dispatch._xla_call_impl_lazy(fun_, *tracers, **params)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/dispatch.py", line 236, in _xla_call_impl_lazy
    return xla_callable(fun, device, backend, name, donated_invars, keep_unused,
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/linear_util.py", line 303, in memoized_fun
    ans = call(fun, *args)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/dispatch.py", line 359, in _xla_callable_uncached
    return lower_xla_callable(fun, device, backend, name, donated_invars, False,
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/profiler.py", line 314, in wrapper
    return func(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/dispatch.py", line 445, in lower_xla_callable
    jaxpr, out_type, consts = pe.trace_to_jaxpr_final2(
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/profiler.py", line 314, in wrapper
    return func(*args, **kwargs)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/interpreters/partial_eval.py", line 2077, in trace_to_jaxpr_final2
    jaxpr, out_type, consts = trace_to_subjaxpr_dynamic2(fun, main, debug_info)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/interpreters/partial_eval.py", line 2027, in trace_to_subjaxpr_dynamic2
    ans = fun.call_wrapped(*in_tracers_)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/linear_util.py", line 167, in call_wrapped
    ans = self.f(*args, **dict(self.params, **kwargs))
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 12, in func
    maski, maskj = jnp.where(dis<=cutoff)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1077, in where
    return nonzero(condition, size=size, fill_value=fill_value)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1332, in nonzero
    size = core.concrete_or_error(operator.index, size,
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/core.py", line 1278, in concrete_or_error
    raise ConcretizationTypeError(val, context)
jax._src.traceback_util.UnfilteredStackTrace: jax._src.errors.ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: Traced<ShapedArray(int32[])>with<DynamicJaxprTrace(level=0/1)>
The size argument of jnp.nonzero must be statically specified to use jnp.nonzero within JAX transformations.
The error occurred while tracing the function func at /home/dechin/projects/gitee/dechin/tests/jax_mask.py:9 for jit. This concrete value was not available in Python because it depends on the value of the argument 'r'.

See https://jax.readthedocs.io/en/latest/errors.html#jax.errors.ConcretizationTypeError

The stack trace below excludes JAX-internal frames.
The preceding is the original exception that occurred, unmodified.

--------------------

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 21, in <module>
    print (func(r, q))
  File "/home/dechin/projects/gitee/dechin/tests/jax_mask.py", line 12, in func
    maski, maskj = jnp.where(dis<=cutoff)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1077, in where
    return nonzero(condition, size=size, fill_value=fill_value)
  File "/home/dechin/anaconda3/envs/jax/lib/python3.10/site-packages/jax/_src/numpy/lax_numpy.py", line 1332, in nonzero
    size = core.concrete_or_error(operator.index, size,
jax._src.errors.ConcretizationTypeError: Abstract tracer value encountered where concrete value is expected: Traced<ShapedArray(int32[])>with<DynamicJaxprTrace(level=0/1)>
The size argument of jnp.nonzero must be statically specified to use jnp.nonzero within JAX transformations.
The error occurred while tracing the function func at /home/dechin/projects/gitee/dechin/tests/jax_mask.py:9 for jit. This concrete value was not available in Python because it depends on the value of the argument 'r'.

See https://jax.readthedocs.io/en/latest/errors.html#jax.errors.ConcretizationTypeError

想避免这个报错,要么就是对该函数不做编译(牺牲性能),要么就是自己写一个CUDA算子(增加工作量),再就是我们这里用到的NonZero定长输出的方法(预置条件)。

NonZero的使用

使用Jax的NonZero函数时,也有一点需要注意:虽然NonZero可以做到固定长度的输出,但是这个固定的长度本身也是一个名为
size
的传入参数。也就是说,NonZero的输出Shape也是要取决于输入参数的。Jax开发时也考虑到了这一点,所以在编译时提供了一个功能可以设置静态参量:
static_argnames
,例如我们的案例中,将
size
这个名称的传参设置为静态参量,这样就可以使用Jax的即时编译了:

import os
os.environ['XLA_PYTHON_CLIENT_PREALLOCATE']='false'

import numpy as np
np.random.seed(0)
import jax
from jax import numpy as jnp
from functools import partial

@partial(jax.jit, static_argnames='size')
def func(r, q, cutoff=0.2, size=5000):
    if q.shape[0] != r.shape[0]+1:
        raise ValueError("The q.shape[0] should be equal to r.shape[0]+1")

    dis = jnp.abs(r[:, None] - r[None])
    maski, maskj = jnp.nonzero(jnp.where(dis<=cutoff, 1, 0), size=size, fill_value=-1)
    qi = q[maski]
    qj = q[maskj]
    return jnp.sum(qi*qj)

N = 100
r = jnp.array(np.random.random(N), jnp.float32)
q = jnp.array(np.random.random(N), jnp.float32)
pader = jnp.array([0.], jnp.float32)
q = jnp.append(q, pader)

print (func(r, q))
# 1035.7422

可以看到,函数用Jax的JIT成功编译,并且输出结果跟前面未编译时候是一致的。当然,这里还用到了一个小技巧,就是NonZero函数输出结果时,不到长度的输出结果会被自动Pad到给定的长度,这里Pad的值使用的是我们给出的
fill_value
。因为NonZero输出的也是索引,这样我们可以把Pad的这些索引设置为
-1
,然后在构建参数
\(q\)
的时候事先在末尾
append
一个0,这样就可以确保计算的输出结果直接就是正确的。

总结概要

在使用Jax的过程中,有时候会遇到函数输出是一个动态的Shape,这种情况下我们很难利用到Jax的即时编译的功能,不能使得性能最大化。这也是使用Tensor数据结构来计算的一个特点,有好有坏。本文介绍了Jax的另外一个函数NonZero,可以使得我们能够编译那些动态Shape输出的函数。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/nonzero.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html