
能力说明:
熟悉微服务常用开放框架,理解Spring、Spring Boot,以及Spring Cloud的概念和不同,对Spring Cloud Alibaba有较为全面的认知。对Istio具备基础运维能力,掌握基本组件的知识。
暂时未有相关云产品技术能力~
阿里云技能认证
详细说明最近参与了一些电商中台等复杂业务系统的设计和开发,结合对DDD和阿里业务中台的一些理解,有一些架构方面的思考和体会,在这里记录一下。 做技术方案,核心是下面几个问题: 做什么?- 产品需求 业务上怎么做?- 业务文档 技术上怎么做?- 技术方案 代码怎么实现?- 落地实现 明确了这几个问题,可以处理大部分日常需求开发,如果是比较复杂的业务系统,就需要拆解的更精细。 比如电商的商品管理、订单交易、促销活动营销中心等系统的开发和重构,业务相对复杂,开发人天在几个月以上,直接开发可能会老虎啃天,无从下手。 这时候可以通过一个流程化的模板来指导,如果抽象一个通用的流程,可以参考下面的套路: 业务拆解 > 复杂度来源 > 核心挑战点 领域驱动设计 > 业务过程分析 > 领域模型抽象 > 模型分解 分层组织 > 工程架构 > 模块化 > 组件化 考虑功能复用 > 可选路径 —( 业务身份,能力,扩展点,工作流程,编排) 方案产出 > 整体-模块-流程-细节 > 方案评审 > 最终方案 其中的功能复用环节,是包括阿里在内的大部分业务中台的解决思路,仅供参考。 一、业务拆解 1.1 复杂度来源 为什么要关注复杂度? 我比较认同系统设计中「软件复杂度」的观点,架构设计的目的是为了解决软件系统的复杂度带来的问题,所以在设计架构时,首先就要分析系统的复杂度。 只有正确分析出了系统的复杂性,后续的架构设计方案才不会偏离方向; 否则,如果对系统的复杂性判断错误,即使后续的架构设计方案再完美再先进,都是南辕北辙,做的越好,错的越多、越离谱。 举个例子,医院管理应用的医疗管理系统(HIS),复杂度在于业务逻辑复杂,系统之间调用不清晰, 如果你设计一个QPS几万的高性能架构,就是没有解决系统的核心问题。 正确的做法是将主要的复杂度问题列出来,然后根据业务、技术、团队等综合情况进行排序,优先解决当前面临的最主要的复杂度问题。 1.2 核心挑战点 射人先射马,擒贼先擒王。 确定了复杂度,也就确定了系统设计的难点,在进行系统设计时,可以把难点列出来,各个击破。 以优惠券为例,促销活动最大的复杂度来自营销形态的变化,营销最大的不变就是变,乱花渐欲迷人眼,各类促销方式千变万化。 每次促销活动都有不同的玩法和定义,促销系统的设计必须对促销模式有所抽象,任何活动或优惠手段都是基于最基本的促销模式而建立的。 电商促销活动和优惠券建设难点包括: 底层模型抽象:底层模型抽象可以通过DDD的方式,对领域模型和进行抽象。 促销引擎性能:性能问题如何解决?已经是老生常谈,工程领域有很多经典的解决方案,比如缓存,异步,最终一致性。 关联系统交互:理清和关联系统的交互 二、领域驱动设计 软件系统的目的反映在业务上,都是来解决一系列问题,例如考试系统完成考试,电商就是卖货, 同一个领域的系统都具有相同的核心业务,因为他们要解决的问题的本质是类似的,一个领域本质上可以理解为一个问题域 。 只要确定了系统所属的领域,那么这个系统的核心业务,即要解决的关键问题就基本确定了。 任何一个系统都会属于某个特定的领域,例如论坛系统,核心功能是确定的,比如用户发帖,回帖等基本功能。 广义的电商系统也是一个领域,做电商业务,必须要支持的商品,订单,交易,物流等功能。 多说一句,领域驱动设计不是银弹,也没必要妖魔化。作为指导思想,在实际操作中,要具体问题具体分析,综合采用其它各种设计方法。 2.1 领域模型设计 DDD里有领域专家的概念,领域专家要在这个领域深入研究,只有这样才会遇到非常多的该领域的问题,积累比较更加丰富的经验。 通常来说,一个领域有且只有一个核心问题,也就是核心子域。在核心子域、通用子域、支撑子域梳理的同时,会定义出子域中的限界上下文及其关系,用它来阐述子域之间的关系 。 以电商营销为例,优惠券、抽奖、套餐等,都是围绕这个促销这个业务范围来进行的,在促销域之外,还有相关的用户、商品、订单、风控、商家等。 三、架构分层 3.1 架构分层 下图是领域驱动设计中经典的分层架构: 用户界面/展示层: 请求应用层获取用户所需的展示数据; 发送命令给应用层执行用户的命令 应用层: 薄薄的一层,定义软件要完成的任务。 对外为展示层提供各种应用功能,对内调用领域层(领域对象或领域服务)完成各种业务逻辑。应用层不包含业务逻辑 领域层: 表达业务概念、业务状态信息及业务规则,是业务软件的核心 基础设施层: 为其他层提供通用的技术能力,提供了层间通信;为领域层提供持久化机制。 这是一个相对简单的分层架构,其实已经老生常谈,那么问题来了,我们在上面拆解的领域模型,如何映射到更加复杂的工程架构中? 3.2 工程架构 DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。 微服务追求业务层面的复用,设计出来的系统架构和业务一致,不过领域模型并不直接反映数据结构,需要明确这一点。 领域驱动设计最后落地到数据存储上,不需要直接参考领域模型,在最后的技术架构上可以自由选择合适的技术架构。 3.3 模块组织 Java项目一般是典型的Maven多模块项目,可以使用不同的Module,区分各个层次,进一步,通过Package来控制DDD中的限界上下文。 3.4 领域对象 对具体的领域对象封装时,典型的有充血和贫血模型,由于大部分程序员习惯在Service里封装业务逻辑的贫血模型,完全的充血模型开发效率相对较低, 我自己的体会是,技术服务业务第一,在开发时可以灵活的选择实现策略,模型对象封装一些简单的静态方法,大部分业务逻辑还是放在领域服务中实现。 3.5 代码模型 DDD 是一个指导思想,没有一个标准的代码模型。而且我觉得,团队成员水平不同,编码习惯也有区别,如果打着DDD的旗号,来给编码过程添加很多约束,那就有点舍本逐末了。 一般来说,可以通过一些脚手架工具,定制一个相对通用的代码模板,比如阿里巴巴的 https://start.aliyun.com/bootstrap.html 脚手架生成。 下面是一个简单的代码模型,可以作为参考: 四、考虑功能复用 4.1 编程DRY原则 大家都知道,编写整洁代码,有一个非常重要的原则就是DRY, Don't Repeat Yourself,避免产生重复代码,有经验的程序员都能够意识到这一条约束。 如果你使用Idea开发,Idea也会识别并且提示你重复的代码,建议你进行抽象。 DRY的好处更少的代码是好的,它节省了时间和精力,易于维护,并且减少了bug的几率。 除了在软件开发领域,在业务系统层面,也存在如何避免重复能力建设,考虑业务复用的问题。 4.2 业务层面的DRY 业务系统层面的DRY原则,其实可以总结为一个问题,就是复杂业务系统,如何实现具有共性的业务能力的复用,这个也是很多业务中台关注的问题。 一般的,大部分业务中台(特指业务中台,不包括数据中台等其他形态)对业务复用的方式, 都可以通过定义业务身份 ——> 梳理扩展点 ——> 枚举业务能力 ——> 根据不同业务身份编排工作流 ——> 实现业务能力复用,这样的流程来实现。 可以对比编程中的Pipeline模式,或者责任链模式,只不过每个链条上负责处理输入和输出的,是不同的业务功能。 业务中台是另一个话题,这部分是发散思考,具体可以参考阿里技术公众号对阿里巴巴 TMF (Trade Mudule Framework)框架的介绍: 如何实现32.5万笔/秒的交易峰值?阿里交易系统TMF2.0技术揭秘 五、可扩展性和过度设计如何平衡 好的架构设计一定是扩展简单的。 在设计时,要尽量封装可能的变化,在业务流程发生一些调整时,能够比较方便地修改系统程序模块或组件间的调用关系而实现新的需求,也就是我们常说的可扩展性。 但是可扩展性本身也是系统设计的复杂度来源之一,这就涉及到一个问题,如何平衡可扩展和过度设计。 5.1 区分确定性和变化 好的架构一定是扩展简单,运行平稳的。 系统架构最开始可以从一个通用的流程开始,case-by-case, 然后将「变化少」的部分沉淀下来为架构,将「变化多」的沉淀为扩展或者配置,梳理清楚,将这两者结合起来,最后完成系统架构设计。 5.2 用容量规划的方式来处理扩展程度 可以使用容量规划的思想,来处理可扩展性设计。 在做技术方案时,容量规划是一个特别重要的环节,要预估未来几年的增长量,进行数据库和缓存的容量规划。 我觉得这个方式也可以应用在扩展性设计上,对业务变化进行预期,考虑技术方案能够支持的业务发展时间。 六、方案评审 好的技术方案很难一蹴而就,大部分时候要经过反复的调整,就是需要关联的各方参与方案的评审和修改,最终确定最终技术方案。 我的建议是,团队最好输出一份技术方案的规范,可以供每个成员参考,从设计阶段,就统一团队成员的认识。 七、总结 最后再总结一下,关于复杂业务系统开发的一些体会: 熟悉业务,抽象产品需求,分析相关测试用例,了解各种用户角色和其使用的场景 自顶向下进行方案设计,对于比较复杂的业务系统,比较好的方式是先关注顶层模型,避免在一开始就陷入技术和业务细节中去 从整体设计,到模块局部规划,设计好部署架构、分层和分模块、API设计、数据库设计等 可以参考成熟的解决方案,比如将开源软件,改造,变成适合自己业务需求的架构 验证和优化架构设计方案,完整的架构设计方案,需要有多次的评审,充分收集各方面的反馈,反复修改后确定 合理进行扩展,考虑架构预期能满足多长时间的业务增长,比如未来一年的业务变化 扩展阅读: 一文教会你如何写复杂业务代码 如何实现32.5万笔/秒的交易峰值?阿里交易系统TMF2.0技术揭秘
并查集顾名思义就是有“合并集合”和“查找集合中的元素”两种操作的关于数据结构的一种算法。 算法 用集合中的某个元素来代表这个集合,该元素称为集合的代表元。一个集合内的所有元素组织成以代表元为根的树形结构。对于每一个元素 parent[x]指向x在树形结构上的父亲节点。如果x是根节点,则令parent[x] = x。对于查找操作,假设需要确定x所在的的集合,也就是确定集合的代表元。可以沿着parent[x]不断在树形结构中向上移动,直到到达根节点。 判断两个元素是否属于同一集合,只需要看他们的代表元是否相同即可。 路径压缩 为了加快查找速度,查找时将x到根节点路径上的所有点的parent设为根节点,该优化方法称为压缩路径。 使用该优化后,平均复杂度可视为Ackerman函数的反函数,实际应用中可粗略认为其是一个常数。 用途 1、维护无向图的连通性。支持判断两个点是否在同一连通块内。2、判断增加一条边是否会产生环:用在求解最小生成树的Kruskal算法里。 代码实现 LeetCode 547 朋友圈 班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。 给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果Mi = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。 使用并查集实现: public class Union { class UF { // 连通分量个数 private int count; // 存储一棵树 private int[] parent; // 记录树的“重量” private int[] size; public UF(int n) { this.count = n; parent = new int[n]; size = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; } } public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; // 小树接到大树下面,较平衡 if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; } public boolean connected(int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; } private int find(int x) { while (parent[x] != x) { // 进行路径压缩 parent[x] = parent[parent[x]]; x = parent[x]; } return x; } public int count() { return count; } } public int findCircleNum(int[][] M) { int n = M.length; UF uf = new UF(n); for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { if (M[i][j] == 1) uf.union(i, j); } } return uf.count(); } } 关于并查集的图文解说 为了解释并查集的原理,下面的内容来自网友博客。 话说江湖上散落着各式各样的大侠,有上千个之多。他们没有什么正当职业,整天背着剑在外面走来走去,碰到和自己不是一路人的,就免不了要打一架。但大侠们有一个优点就是讲义气,绝对不打自己的朋友。而且他们信奉“朋友的朋友就是我的朋友”,只要是能通过朋友关系串联起来的,不管拐了多少个弯,都认为是自己人。这样一来,江湖上就形成了一个一个的群落,通过两两之间的朋友关系串联起来。而不在同一个群落的人,无论如何都无法通过朋友关系连起来,于是就可以放心往死了打。但是两个原本互不相识的人,如何判断是否属于一个朋友圈呢? 我们可以在每个朋友圈内推举出一个比较有名望的人,作为该圈子的代表人物,这样,每个圈子就可以这样命名“齐达内朋友之队”“罗纳尔多朋友之队”……两人只要互相对一下自己的队长是不是同一个人,就可以确定敌友关系了。 但是还有问题啊,大侠们只知道自己直接的朋友是谁,很多人压根就不认识队长,要判断自己的队长是谁,只能漫无目的的通过朋友的朋友关系问下去:“你是不是队长?你是不是队长?”这样一来,队长面子上挂不住了,而且效率太低,还有可能陷入无限循环中。于是队长下令,重新组队。队内所有人实行分等级制度,形成树状结构,我队长就是根节点,下面分别是二级队员、三级队员。每个人只要记住自己的上级是谁就行了。遇到判断敌友的时候,只要一层层向上问,直到最高层,就可以在短时间内确定队长是谁了。由于我们关心的只是两个人之间是否连通,至于他们是如何连通的,以及每个圈子内部的结构是怎样的,甚至队长是谁,并不重要。所以我们可以放任队长随意重新组队,只要不搞错敌友关系就好了。于是,门派产生了。 下面我们来看并查集的实现。 int pre[1000]; 这个数组,记录了每个大侠的上级是谁。大侠们从1或者0开始编号(依据题意而定),pre[15]=3就表示15号大侠的上级是3号大侠。如果一个人的上级就是他自己,那说明他就是掌门人了,查找到此为止。也有孤家寡人自成一派的,比如欧阳锋,那么他的上级就是他自己。每个人都只认自己的上级。比如胡青牛同学只知道自己的上级是杨左使。张无忌是谁?不认识!要想知道自己的掌门是谁,只能一级级查上去。 find这个函数就是找掌门用的,意义再清楚不过了(路径压缩算法先不论,后面再说)。 int find(int x) //查找我(x)的掌门 { int r=x; //委托 r 去找掌门 while (pre[r ]!=r) //如果r的上级不是r自己(也就是说找到的大侠他不是掌门 = =) r=pre[r ] ; // r 就接着找他的上级,直到找到掌门为止。 return r ; //掌门驾到~~~ } 再来看看join函数,就是在两个点之间连一条线,这样一来,原先它们所在的两个板块的所有点就都可以互通了。这在图上很好办,画条线就行了。但我们现在是用并查集来描述武林中的状况的,一共只有一个pre[]数组,该如何实现呢? 还是举江湖的例子,假设现在武林中的形势如图所示。虚竹小和尚与周芷若MM是我非常喜欢的两个人物,他们的终极boss分别是玄慈方丈和灭绝师太,那明显就是两个阵营了。我不希望他们互相打架,就对他俩说:“你们两位拉拉勾,做好朋友吧。”他们看在我的面子上,同意了。这一同意可非同小可,整个少林和峨眉派的人就不能打架了。这么重大的变化,可如何实现呀,要改动多少地方?其实非常简单,我对玄慈方丈说:“大师,麻烦你把你的上级改为灭绝师太吧。这样一来,两派原先的所有人员的终极boss都是师太,那还打个球啊!反正我们关心的只是连通性,门派内部的结构不要紧的。”玄慈一听肯定火大了:“我靠,凭什么是我变成她手下呀,怎么不反过来?我抗议!”抗议无效,上天安排的,最大。反正谁加入谁效果是一样的,我就随手指定了一个。这段函数的意思很明白了吧? void join(int x,int y) //我想让虚竹和周芷若做朋友 { int fx=find(x),fy=find(y); //虚竹的老大是玄慈,芷若MM的老大是灭绝 if(fx!=fy) //玄慈和灭绝显然不是同一个人 pre[fx ]=fy; //方丈只好委委屈屈地当了师太的手下啦 } 路径压缩,就是调整掌门结构,都变成2级关系 再来看看路径压缩算法。建立门派的过程是用join函数两个人两个人地连接起来的,谁当谁的手下完全随机。最后的树状结构会变成什么胎唇样,我也完全无法预计,一字长蛇阵也有可能。这样查找的效率就会比较低下。最理想的情况就是所有人的直接上级都是掌门,一共就两级结构,只要找一次就找到掌门了。哪怕不能完全做到,也最好尽量接近。这样就产生了路径压缩算法。 设想这样一个场景:两个互不相识的大侠碰面了,想知道能不能揍。 于是赶紧打电话问自己的上级:“你是不是掌门?” 上级说:“我不是呀,我的上级是谁谁谁,你问问他看看。” 一路问下去,原来两人的最终boss都是东厂曹公公。 “哎呀呀,原来是记己人,西礼西礼,在下三营六组白面葫芦娃!” “幸会幸会,在下九营十八组仙子狗尾巴花!” 两人高高兴兴地手拉手喝酒去了。 “等等等等,两位同学请留步,还有事情没完成呢!”我叫住他俩。 “哦,对了,还要做路径压缩。”两人醒悟。 白面葫芦娃打电话给他的上级六组长:“组长啊,我查过了,其习偶们的掌门是曹公公。不如偶们一起及接拜在曹公公手下吧,省得级别太低,以后查找掌门麻环。” “唔,有道理。” 白面葫芦娃接着打电话给刚才拜访过的三营长……仙子狗尾巴花也做了同样的事情。 这样,查询中所有涉及到的人物都聚集在曹公公的直接领导下。每次查询都做了优化处理,所以整个门派树的层数都会维持在比较低的水平上。路径压缩的代码,看得懂很好,看不懂也没关系,直接抄上用就行了。总之它所实现的功能就是这么个意思。
为了学习SentinelResourceAspect,这篇文章里我用Aspectj实现一个AOP实例,一起来看下。 Sentinel 提供了 @SentinelResource 注解用于定义资源,支持 AspectJ 的扩展用于自动定义资源、处理 BlockException 等。 SentinelResourceAspect是Sentinel中的核心切面,Sentinel对限流,拦截等的支持都依赖 SentinelResourceAspect,本文回顾AOP相关知识,实现一个AspectJ实例,然后带你从源码角度,探究SentinelResourceAspect的实现。 1、回顾 Spring AOP 知识 AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。 利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。 常见使用场景 性能监控 在方法调用前后记录调用时间,方法执行太长或超时报警。 缓存代理 缓存某方法的返回值,下次执行该方法时,直接从缓存里获取。 软件破解 使用AOP修改软件的验证类的判断逻辑。 记录日志 在方法执行前后记录系统日志。 工作流系统 工作流系统需要将业务代码和流程引擎代码混合在一起执行,那么我们可以使用AOP将其分离,并动态挂接业务。 权限验证 方法执行前验证是否有权限执行当前方法,没有则抛出没有权限执行异常,由业务代码捕捉。 AOP的一些概念 Aspect Aspect : 声明类似于 Java 中的类声明,在 Aspect 中会包含着一些 Pointcut 以及相应的 Advice。 Joint point : 拦截点,如某个业务方法, 表示在程序中明确定义的点,典型的包括方法调用,对类成员的访问以及异常处理程序块的执行等等,它自身还可以嵌套其它 joint point。 Pointcut : 表示一组 joint point,这些 joint point 或是通过逻辑关系组合起来,或是通过通配、正则表达式等方式集中起来,即Joinpoint的表达式,表示拦截哪些方法。一个Pointcut对应多个Joinpoint Advice Advice : 定义了在 pointcut 里面定义的程序点具体要做的操作,即要切入的逻辑,它通过 before、after 和 around 来区别是在每个 joint point 之前、之后还是代替执行的代码。 Target : 被aspectj横切的对象。我们所说的joinPoint就是Target的某一行,如方法开始执行的地方、方法类调用某个其他方法的代码 在Spring 中,AOP有多种实现方式,AspectJ是其中一种,另外还有JDK 和 Cglig的动态代理等。 2、基于 AspectJ 和@annotation拦截方法实现AOP 在学习 SentinelResourceAspect 源码之前,我先动手实现一个 AspectJ 的AOP实例,完成这个实例以后,SentinelResourceAspect的原理只要看一眼源码就可以明白。 (1)编写Annotation类 @Target({ElementType.METHOD,ElementType.TYPE}) @Documented @Inherited public @interface WorkflowLogAnnotation { String field(); } (2)编写接口和实现类 编写接口类WorkflowService,编写实现类WorkflowServiceImpl,注意此处在方法上增加WorkflowLogAnnotation public class WorkflowServiceImpl implements WorkflowService { @Override @WorkflowLogAnnotation public void start(String bpmnXml) { System.out.println("启动流程"); } } (3)加入切面动作 在流程启动前和启动后做一些操作,增加通知,注意Pointcut表达式改为拦截器方式。 @Aspect public class WorkflowServiceAdviceAspect { public WorkflowServiceAdviceAspect(){ } @Pointcut("annotation(Spring.WorkflowLogAnnotation)") public void startPoint() @Before("startPoint()") public void before(Method arg0, Object[] arg1, Object arg2) throws Throwable { System.out.println("执行方法前"); } @AfterReturning("startPoint()") public void afterReturning(Object arg0, Method arg1, Object[] arg2, Object arg3) throws Throwable { System.out.println("执行方法后"); } } (4)增加配置 在Xml中增加配置,如果WorkflowServiceAdviceAspect和WorkflowServiceImpl类上增加了@Component,下面的bean声明可以用Spring的自动扫描来替代。 <aop:aspectj-autoproxy /> <!-- 定义通知内容,也就是切入点执行前后需要做的事情 --> <bean id="workflowServiceAdviceAspect" class="Spring.WorkflowServiceAdviceAspect"></bean> <!-- 定义被代理者 --> <bean id="workflowService" class="business.WorkflowServiceImpl"></bean> (5)完成验证 完成上面的操作,一个 AspectJ 实例就完成了,是不是很简单,下面看下 SentinelResourceAspect的源码。 3、SentinelResourceAspect源码 可以看到,SentinelResourceAspect切面和我们上面的实例实现方式是一样的。 public class SentinelResourceAspect extends AbstractSentinelAspectSupport { @Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)") public void sentinelResourceAnnotationPointcut() { } @Around("sentinelResourceAnnotationPointcut()") public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable { Method originMethod = resolveMethod(pjp); SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class); if (annotation == null) { // Should not go through here. throw new IllegalStateException("Wrong state for SentinelResource annotation"); } String resourceName = getResourceName(annotation.value(), originMethod); EntryType entryType = annotation.entryType(); Entry entry = null; try { entry = SphU.entry(resourceName, entryType, 1, pjp.getArgs()); Object result = pjp.proceed(); return result; } catch (BlockException ex) { return handleBlockException(pjp, annotation, ex); } catch (Throwable ex) { Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore(); // The ignore list will be checked first. if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) { throw ex; } if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) { traceException(ex, annotation); return handleFallback(pjp, annotation, ex); } // No fallback function can handle the exception, so throw it out. throw ex; } finally { if (entry != null) { entry.exit(1, pjp.getArgs()); } } } } (1)进入方法调用SphU.entry SentinelResourceAspect 使用aspect的around拦截,拦截标注有SentinelResource的注解,进入方法之前调用SphU.entry(resourceName, entryType),结束之后调用entry.exit(); entry = SphU.entry(resourceName, entryType, 1, pjp.getArgs()); Object result = pjp.proceed(); 这个使用方式和我们单机使用 Sentinel的方式是一样的。 (2)handleBlockException处理异常 异常的时候调用handleBlockException方法,会先判断是否是降级需要处理的异常,是的话,则调用fallback方法,否则调用block handler方法。 Method blockHandlerMethod = extractBlockHandlerMethod(pjp, annotation.blockHandler(), annotation.blockHandlerClass()); if (blockHandlerMethod != null) { Object[] originArgs = pjp.getArgs(); // Construct args. Object[] args = Arrays.copyOf(originArgs, originArgs.length + 1); args[args.length - 1] = ex; if (isStatic(blockHandlerMethod)) { return blockHandlerMethod.invoke(null, args); } return blockHandlerMethod.invoke(pjp.getTarget(), args); } // If no block handler is present, then go to fallback. return handleFallback(pjp, annotation, ex); 4、总结 Sentinel 使用了Aspectj来实现切面,可以更方便的应用Sentinel。
MySQL官方对索引的定义为:索引(Index)是帮助MySQL高效获取数据的数据结构。数据库查询是数据库的最主要功能之一,我们都希望查询数据的速度能尽可能的快,因此数据库系统的设计者会从查询算法的角度进行优化,这篇文章对索引做一个系统的梳理,希望对大家有帮助。 一、MySQL有哪些索引类型 索引的分类可以从多个角度进行,下面分别从数据结构,物理存储和业务逻辑三个维度进行划分。 1、从数据结构角度 (1)B+树索引(O(log(n))) 关于B+树索引,后面会深入解析 (2)hash索引 仅仅能满足"=","IN"和"<=>"查询,不能使用范围查询 其检索效率非常高,索引的检索可以一次定位,不像B-Tree 索引需要从根节点到枝节点,最后才能访问到页节点这样多次的IO访问,所以 Hash 索引的查询效率要远高于 B-Tree 索引 只有Memory存储引擎显示支持hash索引 (3)FULLTEXT索引 现在MyISAM和InnoDB引擎都支持了 (4)R-Tree索引 用于对GIS数据类型创建SPATIAL索引 2、从物理存储角度 (1)聚集索引(clustered index) 正文内容按照一个特定维度排序存储,这个特定的维度就是聚集索引; Innodb存储引擎中行记录就是按照聚集索引维度顺序存储的,Innodb的表也称为索引表;因为行记录只能按照一个维度进行排序,所以一张表只能有一个聚集索引。 (2)非聚集索引(non-clustered index) 索引是通过二叉树的数据结构来描述的,我们可以这么理解聚簇索引:索引的叶节点就是数据节点。而非聚簇索引的叶节点仍然是索引节点,只不过有一个指针指向对应的数据块。 非聚集索引索引项顺序存储,但索引项对应的内容却是随机存储的; 举个例子说明下: create table student ( `id` INT UNSIGNED AUTO_INCREMENT, `name` VARCHAR(255), PRIMARY KEY(`id`), KEY(`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 该表中主键id是该表的聚集索引、name为非聚集索引;表中的每行数据都是按照聚集索引id排序存储的;比如要查找name='Arla'和name='Arle'的两个同学,他们在name索引表中位置可能是相邻的,但是实际存储位置可能差的很远。name索引表节点按照name排序,检索的是每一行数据的主键。聚集索引表按照主键id排序,检索的是每一行数据的真实内容。 3、从逻辑角度 (1)主键索引 主键索引是一种特殊的唯一索引,不允许有空值 (2)普通索引或者单列索引 (3)多列索引(复合索引) 复合索引指多个字段上创建的索引,只有在查询条件中使用了创建索引时的第一个字段,索引才会被使用。使用复合索引时遵循最左前缀集合 (4)唯一索引或者非唯一索引 (5)空间索引 空间索引是对空间数据类型的字段建立的索引,MYSQL中的空间数据类型有4种,分别是GEOMETRY、POINT、LINESTRING、POLYGON。 MYSQL使用SPATIAL关键字进行扩展,使得能够用于创建正规索引类型的语法创建空间索引。创建空间索引的列,必须将其声明为NOT NULL,空间索引只能在存储引擎为MYISAM的表中创建. 二、索引创建方式 CREATE TABLE table_name[col_name data type] [unique|fulltext|spatial][index|key][index_name](col_name[length])[asc|desc] unique|fulltext|spatial为可选参数,分别表示唯一索引、全文索引和空间索引; index和key为同义词,两者作用相同,用来指定创建索引 col_name为需要创建索引的字段列,该列必须从数据表中该定义的多个列中选择; index_name指定索引的名称,为可选参数,如果不指定,MYSQL默认col_name为索引值;length为可选参数,表示索引的长度,只有字符串类型的字段才能指定索引长度; asc或desc指定升序或降序的索引值存储 1、创建表时建立索引 (1)创建普通索引 create table table_name( id int(11), name varchar(20), sex boolean, INDEX(id) ); 查看表结构 show create table table_name;可以使 EXPLAIN 语句查看索引是否被使用 explain select * from table_name where id = 1\G (2)创建唯一索引 create table index2( id int unique, name varchar(20), unique INDEX index_2(id asc) ); (3)创建全文索引 全文索引只能在char,varchar或者text 类型的字段上。而且,只有MyISAM 储存引擎支持全文索引。 create table idnex3( id int, info varchar(20), FULLTEXT INDEX index3_info(info) )ENGINE=MyISAM; (4)创建单列索引 create table index4( id int, subject varchar(255), index index4_st(subject(10)) ); 这里需要注意的,subject 的长度为255,但是index4_st索引只有10。这样做的目的还是为了提高查询速度。对于字符型的数据,可以不用查询全部信息,只查询其前面的若干字符信息。 (5)创建多列索引 create table index5( id int, name varchar(20), sex char(4), index index5_ns(name.sex) ); 这是我们可以看到,name 和sex字段上已经创建了index_ns索引。 2、在已经存在的表中创建索引 (1)创建普通索引 在example0() 表中的id 创建名为index7_id 的索引。 create index index7_id on example0(id); (2)创建唯一索引 create UNIQUE index index_name on table_name(name); (3)创建全文索引 create FULLTEXT index index_name on table_name(info); (4)创建单列索引 create INDEX index_name ON table_name(name(10)); (5)创建多列索引 create INDEX index_name ON table_name(name,sex); 3、用alter table 语句来创建索引 (1)创建普通索引 在name字段上创建名为indx_name 的索引 alter table table_name ADD INDEX index_name(name(20)); (2)创建唯一性索引 alter table table_name ADD UNIQUE INDEX index_name(id); (3)创建全文索引 alter table table_name ADD FULLTEXT INDEX index_name(info); (4)创建单列索引 alter table table_name ADD INDEX index_name(name(4)); (5)创建多列索引 alter tabel table_name ADD INDEX index_name(name.sex); 4、删除索引 DROP INDEX index_name ON table_name; 三、索引树是如何维护的 目前大部分数据库系统及文件系统都采用B-Tree或其变种B+Tree作为索引结构,那么索引树是如何维护的? 1、查找结构进化史 查找是数据结构和算法中一个非常重要的概念。 线性查找:一个个找;实现简单;太慢 二分查找:有序;简单;要求是有序的,插入特别慢 HASH查找:查询快;占用空间;不太适合存储大规模数据 二叉查找树:插入和查询很快(log(n));无法存大规模数据,复杂度退化 平衡树:解决 BST 退化问题,树是平衡的;节点非常多的时候,依然树高很高 多路查找树:一个父亲多个孩子节点(度);节点过多树高不会特别深 多路平衡查找树:B-Tree 2、B-Tree B-Tree是一种多路搜索树(并不是二叉的): 定义任意非叶子结点最多只有M个儿子;且M>2; 根结点的儿子数为[2, M]; 除根结点以外的非叶子结点的儿子数为[M/2, M]; 每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字) 非叶子结点的关键字个数=指向儿子的指针个数-1; 非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1]; 非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树; 所有叶子结点位于同一层; 每个k对应一个data。如:(M=3)相当于一个2–3树,2–3树是一个这样的一棵树, 它的每个节点要么有2个孩子和1个数据元素,要么有3个孩子和2个数据元素,叶子节点没有孩子,并且有1个或2个数据元素。 B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点;B-Tree上查找算法的伪代码如下: BTree_Search(node, key) { if(node == null) return null; foreach(node.key) { if(node.key[i] == key) return node.data[i]; if(node.key[i] > key) return BTree_Search(point[i]->node); } return BTree_Search(point[i+1]->node); } data = BTree_Search(root, my_key); (1)B-树的特性 关键字集合分布在整颗树中; 任何一个关键字出现且只出现在一个结点中; 搜索有可能在非叶子结点结束; 其搜索性能等价于在关键字全集内做一次二分查找; 自动层次控制; (2)B-树的自控制 B树中每一个内部节点会包含一定数量的键值。通常,键值的数量被选定在d和2d之间。在实际中,键值占用了节点中大部分的空间。因数2将保证节点可以被拆分或组合。如果一个内部节点有2d个键值,那么添加一个键值给此节点的过程,将会拆分2d键值为2个d键值的节点,并把此键值添加给父节点。每一个拆分的节点需要最小数目的键值。相似地,如果一个内部节点和他的邻居两者都有d个键值,那么将通过它与邻居的合并来删除一个键值。删除此键值将导致此节点拥有d-1个键值;与邻居的合并则加上d个键值,再加上从邻居节点的父节点移来的一个键值。结果为完全填充的2d个键值。 (3)B-树的构造过程 下面是往B树中依次插入 6 10 4 14 5 11 15 3 2 12 1 7 8 8 6 3 6 21 5 15 15 6 32 23 45 65 7 8 6 5 4 3、B+Tree B-Tree有许多变种,其中最常见的是B+Tree,MySQL就普遍使用B+Tree实现其索引结构。与B-Tree相比,B+Tree有以下不同点: 非叶子结点的子树指针与关键字个数相同; 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间); 为所有叶子结点增加一个链指针; 所有关键字都在叶子结点出现; 内节点不存储data,只存储key如:(M=3) B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找; (1)B+的特性 所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的; 不可能在非叶子结点命中; 非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层; 更适合文件索引系统; (2)B+树的构造过程 下面是往B+树中依次插入 6 10 4 14 5 11 15 3 2 12 1 7 8 8 6 3 6 21 5 15 15 6 32 23 45 65 7 8 6 5 4 3、索引的物理存储 一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。 这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。 假如每个盘块可以正好存放一个B树的结点(正好存放2个文件名)。那么一个BTNODE结点就代表一个盘块,而子树指针就是存放另外一个盘块的地址。 (1)模拟B+树查找过程 下面,咱们来模拟下查找文件29的过程: 根据根结点指针找到文件目录的根磁盘块1,将其中的信息导入内存。【磁盘IO操作 1次】 此时内存中有两个文件名17、35和三个存储其他磁盘页面地址的数据。根据算法我们发现:17<29<35,因此我们找到指针p2。 根据p2指针,我们定位到磁盘块3,并将其中的信息导入内存。【磁盘IO操作 2次】 此时内存中有两个文件名26,30和三个存储其他磁盘页面地址的数据。根据算法我们发现:26<29<30,因此我们找到指针p2。 根据p2指针,我们定位到磁盘块8,并将其中的信息导入内存。【磁盘IO操作 3次】 此时内存中有两个文件名28,29。根据算法我们查找到文件名29,并定位了该文件内存的磁盘地址。分析上面的过程,发现需要3次磁盘IO操作和3次内存查找操作。关于内存中的文件名查找,由于是一个有序表结构,可以利用折半查找提高效率。至于IO操作是影响整个B树查找效率的决定因素。 当然,如果我们使用平衡二叉树的磁盘存储结构来进行查找,磁盘4次,最多5次,而且文件越多,B树比平衡二叉树所用的磁盘IO操作次数将越少,效率也越高。 4、B+tree的优点 (1)B+-tree的磁盘读写代价更低 B+-tree的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。举个例子,假设磁盘中的一个盘块容纳16bytes,而一个关键字2bytes,一个关键字具体信息指针2bytes。一棵9阶B-tree(一个结点最多8个关键字)的内部结点需要2个盘快。而B+树内部结点只需要1个盘快。当需要把内部结点读入内存中的时候,B 树就比B+树多一次盘块查找时间(在磁盘中就是盘片旋转的时间)。 (2)B+-tree的查询效率更加稳定 由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。 四、索引创建有哪些原则 索引查询是数据库中重要的记录查询方法,要不要进入索引以及在那些字段上建立索引都要和实际数据库系统的查询要求结合来考虑,下面给出实际中的一些通用的原则: 在经常用作过滤器的字段上建立索引; 在SQL语句中经常进行GROUP BY、ORDER BY的字段上建立索引; 在不同值较少的字段上不必要建立索引,如性别字段; 对于经常存取的列避免建立索引; 用于联接的列(主健/外健)上建立索引; 在经常存取的多个列上建立复合索引,但要注意复合索引的建立顺序要按照使用的频度来确定; 缺省情况下建立的是非簇集索引,但在以下情况下最好考虑簇集索引,如:含有有限数目(不是很少)唯一的列;进行大范围的查询;充分的利用索引可以减少表扫描I/0的次数,有效的避免对整表的搜索。 经常用在WHERE子句中的数据列; 经常出现在关键字order by、group by、distinct后面的字段,建立索引。如果建立的是复合索引,索引的字段顺序要和这些关键字后面的字段顺序一致,否则索引不会被使用; 对于那些查询中很少涉及的列,重复值比较多的列不要建立索引; 对于定义为text、image和bit的数据类型的列不要建立索引; 对于经常存取的列避免建立索引; 限制表上的索引数目。对一个存在大量更新操作的表,所建索引的数目一般不要超过3个,最多不要超过5个。索引虽说提高了访问速度,但太多索引会影响数据的更新操作。 对复合索引,按照字段在查询条件中出现的频度建立索引。在复合索引中,记录首先按照第一个字段排序。对于在第一个字段上取值相同的记录,系统再按照第二个字段的取值排序,以此类推。因此只有复合索引的第一个字段出现在查询条件中,该索引才可能被使用,因此将应用频度高的字段,放置在复合索引的前面,会使系统最大可能地使用此索引,发挥索引的作用。 1、组合多个索引 一个单独的索引扫描只能用于这样的条件子句:使用被索引字段和索引操作符类中的操作符, 并且这些条件以AND连接。 假设在(a, b)上有一个索引, 那么类似WHERE a = 5 AND b = 6的条件可以使用索引,但是像WHERE a = 5 OR b = 6的条件就不能直接使用索引。 一个类似WHERE x =42 OR x = 47 OR x = 53 OR x = 99 这样的查询可以分解成四个在x上的独立扫描,每个扫描使用一个条件, 最后将这些扫描的结果OR 在一起,生成最终结果。 另外一个例子是,如果我们在x 和y上有独立的索引,一个类似WHERE x = 5 AND y = 6 这样的查询可以分解为几个使用独立索引的子句,然后把这几个结果AND 在一起,生成最终结果。 五、索引失效有哪几种情况 如果条件中有or,即使其中有条件带索引也不会使用(这也是为什么尽量少用or的原因) 对于多列索引,不是使用的第一部分(第一个),则不会使用索引 like查询是以%开头 如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则不使用索引 如果mysql估计使用全表扫描要比使用索引快,则不使用索引 1、联合索引失效的条件 联合索引又叫复合索引。两个或更多个列上的索引被称作复合索引。 对于复合索引:Mysql从左到右的使用索引中的字段,一个查询可以只使用索引中的一部份,但只能是最左侧部分。例如索引是key index (a,b,c)。 可以支持a | a,b| a,b,c 3种组合进行查找,但不支持 b,c进行查找 .当最左侧字段是常量引用时,索引就十分有效。 所以说创建复合索引时,应该仔细考虑列的顺序。对索引中的所有列执行搜索或仅对前几列执行搜索时,复合索引非常有用;仅对后面的任意列执行搜索时,复合索引则没有用处。 六、如何查看索引的使用情况 这里记录两种方式,分别是 1、使用Handler_read查看索引的使用情况 show status like ‘Handler_read%'; 大家可以注意: handler_read_key:这个值越高越好,越高表示使用索引查询到的次数 handler_read_rnd_next:这个值越高,说明查询低效 +-----------------------+--------------+ | Variable_name | Value | +-----------------------+--------------+ | Handler_read_first | 153 | | Handler_read_key | 364 | | Handler_read_next | 425 | | Handler_read_prev | 598 | | Handler_read_rnd | 605 | | Handler_read_rnd_next | 860571 | +-----------------------+--------------+ 6 rows in set (0.00 sec) ———————————————— 分析这几个值,我们可以查看当前索引的使用情况: Handler_read_first:索引中第一条被读的次数。如果较高,它表示服务器正执行大量全索引扫描;例如,SELECT col1 FROM foo,假定col1有索引(这个值越低越好)。 Handler_read_key:如果索引正在工作,这个值代表一个行被索引值读的次数,如果值越低,表示索引得到的性能改善不高,因为索引不经常使用(这个值越高越好)。 Handler_read_next :按照键顺序读下一行的请求数。如果你用范围约束或如果执行索引扫描来查询索引列,该值增加。 Handler_read_prev:按照键顺序读前一行的请求数。该读方法主要用于优化ORDER BY ... DESC。 Handler_read_rnd :根据固定位置读一行的请求数。如果你正执行大量查询并需要对结果进行排序该值较高。你可能使用了大量需要MySQL扫描整个表的查询或你的连接没有正确使用键。这个值较高,意味着运行效率低,应该建立索引来补救。 Handler_read_rnd_next:在数据文件中读下一行的请求数。如果你正进行大量的表扫描,该值较高。通常说明你的表索引不正确或写入的查询没有利用索引。 2、在sys库中查看没用的索引 查询 schema_unused_indexes库。 root@localhost [sys]>select * from schema_unused_indexes; +-------------------+-------------+------------+ | object_schema | object_name | index_name | +-------------------+-------------+------------+ | sysbench_testdata | sbtest1 | k_1 | | sysbench_testdata | sbtest10 | k_10 | | sysbench_testdata | sbtest3 | k_3 | | sysbench_testdata | sbtest4 | k_4 | | sysbench_testdata | sbtest5 | k_5 | | sysbench_testdata | sbtest6 | k_6 | | sysbench_testdata | sbtest7 | k_7 | | sysbench_testdata | sbtest8 | k_8 | | sysbench_testdata | sbtest9 | k_9 | +-------------------+-------------+------------+ 9 rows in set (0.00 sec) 七、EXPLAIN解释命令查看索引是否生效 explain显示了mysql如何使用索引来处理select语句以及连接表。可以帮助选择更好的索引和写出更优化的查询语句。 1、一个实际例子 新建一张表, CREATE TABLE IF NOT EXISTS `article` (`id` int(10) unsigned NOT NULL AUTO_INCREMENT, `author_id` int(10) unsigned NOT NULL, `category_id` int(10) unsigned NOT NULL, `views` int(10) unsigned NOT NULL, `comments` int(10) unsigned NOT NULL, `title` varbinary(255) NOT NULL, `content` text NOT NULL, PRIMARY KEY (`id`) ); 执行查询, EXPLAIN SELECT author_id FROM `article` WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1 响应数据如下, *************************** 1. row *************************** id: 1 select_type: SIMPLE table: article type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 3 Extra: Using where; Using filesort 1 row in set (0.00 sec) type 是 ALL,即最坏的情况。Extra 里还出现了 Using filesort,也是最坏的情况。 2、EXPLAIN列的解释: table:显示这一行的数据是关于哪张表的 type:这是重要的列,显示连接使用了何种类型。从最好到最差的连接类型为const、eq_reg、ref、range、indexhe和ALL possible_keys:显示可能应用在这张表中的索引。如果为空,没有可能的索引。可以为相关的域从WHERE语句中选择一个合适的语句 key: 实际使用的索引。如果为NULL,则没有使用索引。很少的情况下,MYSQL会选择优化不足的索引。这种情况下,可以在SELECT语句中使用USE INDEX(indexname)来强制使用一个索引或者用IGNORE INDEX(indexname)来强制MYSQL忽略索引 key_len:使用的索引的长度。在不损失精确性的情况下,长度越短越好 ref:显示索引的哪一列被使用了,如果可能的话,是一个常数 rows:MYSQL认为必须检查的用来返回请求数据的行数 Extra:关于MYSQL如何解析查询的额外信息。将在表4.3中讨论,但这里可以看到的坏的例子是Using temporary和Using filesort,意思MYSQL根本不能使用索引,结果是检索会很慢 3、type返回结果的解释 MySQL 在表里找到所需行的方式。包括(由左至右,由最差到最好):| All | index | range | ref | eq_ref | const,system | null | system 表只有一行:system表。这是const连接类型的特殊情况 const:表中的一个记录的最大值能够匹配这个查询(索引可以是主键或惟一索引)。因为只有一行,这个值实际就是常数,因为MYSQL先读这个值然后把它当做常数来对待 eq_ref:在连接中,MYSQL在查询时,从前面的表中,对每一个记录的联合都从表中读取一个记录,它在查询使用了索引为主键或惟一键的全部时使用 ref:这个连接类型只有在查询使用了不是惟一或主键的键或者是这些类型的部分(比如,利用最左边前缀)时发生。对于之前的表的每一个行联合,全部记录都将从表中读出。这个类型严重依赖于根据索引匹配的记录多少—越少越好 range:这个连接类型使用索引返回一个范围中的行,比如使用>或<查找东西时发生的情况 index: 这个连接类型对前面的表中的每一个记录联合进行完全扫描(比ALL更好,因为索引一般小于表数据) ALL:这个连接类型对于前面的每一个记录联合进行完全扫描,这一般比较糟糕,应该尽量避免 4、extra列返回的描述的意义 Distinct:一旦MYSQL找到了与行相联合匹配的行,就不再搜索了 Not exists: MYSQL优化了LEFT JOIN,一旦它找到了匹配LEFT JOIN标准的行,就不再搜索了 Range checked for each Record(index map:#):没有找到理想的索引,因此对于从前面表中来的每一个行组合,MYSQL检查使用哪个索引,并用它来从表中返回行。这是使用索引的最慢的连接之一 Using filesort: 看到这个的时候,查询就需要优化了。MYSQL需要进行额外的步骤来发现如何对返回的行排序。它根据连接类型以及存储排序键值和匹配条件的全部行的行指针来排序全部行 Using index: 列数据是从仅仅使用了索引中的信息而没有读取实际的行动的表返回的,这发生在对表的全部的请求列都是同一个索引的部分的时候 Using temporary 看到这个的时候,查询需要优化了。这里,MYSQL需要创建一个临时表来存储结果,这通常发生在对不同的列集进行ORDER BY上,而不是GROUP BY上 Using where 使用了WHERE从句来限制哪些行将与下一张表匹配或者是返回给用户。如果不想返回表中的全部行,并且连接类型ALL或index,这就会发生,或者是查询有问题不同连接类型的解释(按照效率高低的顺序排序) 参考文档 最官方的 mysql explain type 字段解读MySQL聚集索引和非聚集索引
这篇文章分为六个部分,不同特性的锁分类,并发锁的不同设计,Synchronized中的锁升级,ReentrantLock和ReadWriteLock的应用,帮助你梳理 Java 并发锁及相关的操作。 一、锁有哪些分类 一般我们提到的锁有以下这些: 乐观锁/悲观锁 公平锁/非公平锁 可重入锁 独享锁/共享锁 互斥锁/读写锁 分段锁 偏向锁/轻量级锁/重量级锁 自旋锁 上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面分别说明。 1、乐观锁 VS 悲观锁 乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度,在Java和数据库中都有此概念对应的实际应用。 (1)乐观锁 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。 乐观锁适用于多读的应用类型,乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。 CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。 简单来说,CAS算法有3个三个操作数: 需要读写的内存值 V。 进行比较的值 A。 要写入的新值 B。 当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则返回V。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而Synchronized是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。 (2)悲观锁 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。 传统的MySQL关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。 2、公平锁 VS 非公平锁 (1)公平锁 就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。 公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。 (2)非公平锁 上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。 非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。 (3)典型应用 java jdk并发包中的ReentrantLock可以指定构造函数的boolean类型来创建公平锁和非公平锁(默认),比如:公平锁可以使用new ReentrantLock(true)实现。 3、独享锁 VS 共享锁 (1)独享锁 是指该锁一次只能被一个线程所持有。 (2)共享锁 是指该锁可被多个线程所持有。 对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。 (3)AQS 抽象队列同步器(AbstractQueuedSynchronizer,简称AQS)是用来构建锁或者其他同步组件的基础框架,它使用一个整型的volatile变量(命名为state)来维护同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。 concurrent包的实现结构如上图所示,AQS、非阻塞数据结构和原子变量类等基础类都是基于volatile变量的读/写和CAS实现,而像Lock、同步器、阻塞队列、Executor和并发容器等高层类又是基于基础类实现。 4、互斥锁 VS 读写锁 相交进程之间的关系主要有两种,同步与互斥。所谓互斥,是指散布在不同进程之间的若干程序片断,当某个进程运行其中一个程序片段时,其它进程就不能运行它们之中的任一程序片段,只能等到该进程运行完这个程序片段后才可以运行。所谓同步,是指散布在不同进程之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。 显然,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。也就是说互斥是两个线程之间不可以同时运行,他们会相互排斥,必须等待一个线程运行完毕,另一个才能运行,而同步也是不能同时运行,但他是必须要安照某种次序来运行相应的线程(也是一种互斥)! 总结:互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。 同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。 (1)互斥锁 在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。 如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。 在这种方式下,只有一个线程能够访问被互斥锁保护的资源 (2)读写锁 这个时候读写锁就应运而生了,读写锁是一种通用技术,并不是Java特有的。 读写锁特点: 多个读者可以同时进行读 写者必须互斥(只允许一个写者写,也不能读者写者同时进行) 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者) 互斥锁特点: 一次只能一个线程拥有互斥锁,其他线程只有等待 (3)Linux的读写锁 Linux内核也支持读写锁。 互斥锁 pthread_mutex_init() pthread_mutex_lock() pthread_mutex_unlock() 读写锁 pthread_rwlock_init() pthread_rwlock_rdlock() pthread_rwlock_wrlock() pthread_rwlock_unlock() 条件变量 pthread_cond_init() pthread_cond_wait() pthread_cond_signal() 5、自旋锁 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。 在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。典型的自旋锁实现的例子,可以参考自旋锁的实现 它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。 但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。 (1)Java如何实现自旋锁? 下面是个简单的例子: public class SpinLock { private AtomicReference<Thread> cas = new AtomicReference<Thread>(); public void lock() { Thread current = Thread.currentThread(); // 利用CAS while (!cas.compareAndSet(null, current)) { // DO nothing } } public void unlock() { Thread current = Thread.currentThread(); cas.compareAndSet(current, null); } } lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。 (2)自旋锁存在的问题 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。 (3)自旋锁的优点 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能) 二、并发锁的不同设计方式 根据所锁的设计方式和应用,有分段锁,读写锁等。 1、分段锁技术,并发锁的一种设计方案 分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。 以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。 但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。 分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。 2、锁消除和锁膨胀(粗化) 锁消除,如无必要,不要使用锁。Java 虚拟机也可以根据逃逸分析判断出加锁的代码是否线程安全,如果确认线程安全虚拟机会进行锁消除提高效率。 锁粗化。如果一段代码需要使用多个锁,建议使用一把范围更大的锁来提高执行效率。Java 虚拟机也会进行优化,如果发现同一个对象锁有一系列的加锁解锁操作,虚拟机会进行锁粗化来降低锁的耗时。 3、轮询锁与定时锁 轮询锁是通过线程不断尝试获取锁来实现的,可以避免发生死锁,可以更好地处理错误场景。Java 中可以通过调用锁的 tryLock 方法来进行轮询。tryLock 方法还提供了一种支持定时的实现,可以通过参数指定获取锁的等待时间。如果可以立即获取锁那就立即返回,否则等待一段时间后返回。 4、读写锁 读写锁 ReadWriteLock 可以优雅地实现对资源的访问控制,具体实现为 ReentrantReadWriteLock。读写锁提供了读锁和写锁两把锁,在读数据时使用读锁,在写数据时使用写锁。 读写锁允许有多个读操作同时进行,但只允许有一个写操作执行。如果写锁没有加锁,则读锁不会阻塞,否则需要等待写入完成。 ReadWriteLock lock = new ReentrantReadWriteLock(); Lock readLock = lock.readLock(); Lock writeLock = lock.writeLock(); 三、synchronized中的锁 synchronized 代码块是由一对儿 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。 在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。 1、synchronized中锁的状态 锁的状态是通过对象监视器在对象头中的字段来表明的。四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时)。 无锁状态 偏向锁状态 轻量级锁状态 重量级锁状态 2、偏向锁、轻量级锁、重量级锁 这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。 3、synchronized的锁升级 所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。 当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。 如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。 四、看下ReentrantLock ReentrantLock,一个可重入的互斥锁,它具有与使用synchronized方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。 1、基本用法 public class LockTest { private Lock lock = new ReentrantLock(); public void testMethod() { lock.lock(); for (int i = 0; i < 5; i++) { System.out.println("ThreadName=" + Thread.currentThread().getName() + (" " + (i + 1))); } lock.unlock(); } } 2、Condition应用 synchronized与wait()和nitofy()/notifyAll()方法相结合可以实现等待/通知模型,ReentrantLock同样可以,但是需要借助Condition,且Condition有更好的灵活性,具体体现在: 一个Lock里面可以创建多个Condition实例,实现多路通知 notify()方法进行通知时,被通知的线程时Java虚拟机随机选择的,但是ReentrantLock结合Condition可以实现有选择性地通知,这是非常重要的 3、Condition类和Object类 Condition类的awiat方法和Object类的wait方法等效 Condition类的signal方法和Object类的notify方法等效 Condition类的signalAll方法和Object类的notifyAll方法等效 五、再看下ReadWriteLock 在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁,通常使用java提供的关键字synchronized(关于synchronized可以看这篇文章)或者concurrents包中实现了Lock接口的ReentrantLock。 它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性(出现脏读),而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方。 针对这种读多写少的情况,java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(读写锁)。读写所允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。 1、ReadWriteLock接口 ReadWriteLock,顾明思义,读写锁在读的时候,上读锁,在写的时候,上写锁,这样就很巧妙的解决synchronized的一个性能问题:读与读之间互斥。 ReadWriteLock也是一个接口,原型如下: public interface ReadWriteLock { Lock readLock(); Lock writeLock(); } 该接口只有两个方法,读锁和写锁。 也就是说,我们在写文件的时候,可以将读和写分开,分成2个锁来分配给线程,从而可以做到读和读互不影响,读和写互斥,写和写互斥,提高读写文件的效率。 2、ReentrantReadWriteLock应用 下面的实例参考《Java并发编程的艺术》,使用读写锁实现一个缓存。 import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class Cache { static Map<String,Object> map = new HashMap<String, Object>(); static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); static Lock readLock = readWriteLock.readLock(); static Lock writeLock = readWriteLock.writeLock(); public static final Object getByKey(String key){ readLock.lock(); try{ return map.get(key); }finally{ readLock.unlock(); } } public static final Object getMap(){ readLock.lock(); try{ return map; }finally{ readLock.unlock(); } } public static final Object put(String key,Object value){ writeLock.lock(); try{ return map.put(key, value); }finally{ writeLock.unlock(); } } public static final Object remove(String key){ writeLock.lock(); try{ return map.remove(key); }finally{ writeLock.unlock(); } } public static final void clear(){ writeLock.lock(); try{ map.clear(); }finally{ writeLock.unlock(); } } public static void main(String[] args) { List<Thread> threadList = new ArrayList<Thread>(); for(int i =0;i<6;i++){ Thread thread = new PutThread(); threadList.add(thread); } for(Thread thread : threadList){ thread.start(); } put("ji","ji"); System.out.println(getMap()); } private static class PutThread extends Thread{ public void run(){ put(Thread.currentThread().getName(),Thread.currentThread().getName()); } } } 3、读写锁的锁降级 读写锁支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级,关于锁降级下面的示例代码摘自ReentrantWriteReadLock源码中: void processCachedData() { rwl.readLock().lock(); if (!cacheValid) { // Must release read lock before acquiring write lock rwl.readLock().unlock(); rwl.writeLock().lock(); try { // Recheck state because another thread might have // acquired write lock and changed state before we did. if (!cacheValid) { data = ... cacheValid = true; } // Downgrade by acquiring read lock before releasing write lock rwl.readLock().lock(); } finally { rwl.writeLock().unlock(); // Unlock write, still hold read } } try { use(data); } finally { rwl.readLock().unlock(); } } }
2019年10月