开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

如何构建一个健壮性服务

发表于 2021-09-05

在大数据的时代、流量爆发的时代,怎么保证我们的服务安全、稳定是每个企业、每个开发者需要关注的问题。于是微服务、分布式、大数据等等一些高大尚的名词出现了…。然而无论多么复杂的系统或者框架,它们肯定都是由某些细小的东西组成的。

分布式

分布式的出现就是为了解决单体服务无法承受很高的压力,同时解决因为单体服务故障而导致整个服务不可用的情况。分布式服务肯定是由多个节点共同组成的,每个节点都可以分担一些压力,这样本来1台机器可以支撑1w qps的请求,通过10台机器,就可以达到10w qps的请求。而且通过把节点部署在不同机房,减少因为节点集中而造成集体瘫痪的现象。如果我们有只有一台机器,那么不管部署在哪,一挂整个服务就不可用,如果我们有10台机器,分别分布在城市A、城市B….,这样就算因为天气或者其他原因导致城市A的节点不可用,这时候我们只需要将本来接入城市A的流量给掐掉,把流量分摊给其余9个节点,整个服务还是可用的,实现了高可用了。既然有多个节点,那么我们如何将流量均摊或者按照我们的策略分给单个节点?

负载均衡

对于分布式的服务,一般常见的负载均衡算法有:

轮询法

将请求按顺序轮流地分配到各个节点上,比如第一个请求分给节点A,第二个请求分给节点B…,轮询法对于每个节点都非常公平,不会关心当前节点的状态(比如节点的连接数、负载等等)。所以一般使用轮询法的时候,每个节点的配置尽量保持一致(同样的CPU核数、同样的内存…)。如果存在某个节点的配置过低,那么对于同样的流量它的压力可能更大,这样在轮询的情况下,反而不是很公平,分配到低配置节点的流量也许会因为负载太大而承受不了。

随机法

相比轮询法的轮流分配,随机法可以通过一些随机的算法来选择其中一个节点来分配,比如当前有10个节点,你可以通过rand(1,10)来随机选择一个节点。由概率统计学可以得知,随机算法会随着调用次数的增多,而趋于平均,所以通过随机法的分配方式最终的效果和轮询法差不多。

IP哈希法

有时候我们希望某个客户端的请求固定打到某个节点上,比如客户端A的请求,每次我都希望它被分配到节点A上。这时通过轮询或者随机肯定是不行的,通过客户端的IP来分配到是不错,可以根据客户端的IP来hash得到一个数值,然后通过这个数值对节点的数量取模,取模后得到的数字结果就是这个请求要分配的节点序号。比如客户端的IP是192.168.10.1,然后通过hash函数得到的结果是:hashIndex(192.168.10.1)%10=5,那么5就表示当前应该分给序号是5的节点。

加权轮询法

现实情况中,如果出现某些个节点的配置不一样,通过权重来分配流量比较合理,比如节点A是2核CPU的,而其余节点都是4核CPU的,很明显节点A能承担的负载是相对较低的,这时候可以根据权重来分配流量,配置低的节点权重就低一些,然后根据权重顺序的将流量分配到各个节点上。比如正常轮询的时候,每个节点轮流分配,加权之后,除了节点A之外都是轮流分配,节点A可能是其余节点每分配两轮之后,再分配一次。

加权随机法

与加权轮询法一样,加权随机法也根据每个节点配置的权重来分配流量的。不同的是,它是按照权重随机请求后端服务器,而非顺序。比如可以根据核数来分配概率,假设节点A是2核CPU的,而其余9个节点都是4核CPU的,那么节点A每次被分配到的概率就是2/(4*9+2)。

最小连接数法

最小连接数法应该是比较灵活的负载均衡算法了,当每个节点的配置不相同的时候,除了要人为配置权重的算法之外,还可以根据节点的连接数来分配流量,这就是最小连接数法。每次根据节点当前的连接情况,动态地选取其中连接数最少的一个节点来分配流量,尽可能地提高每个节点的利用效率,更加合理地分配流量。

服务注册与发现

ip + port

当我们有了多个集群,也设计好了负载均衡算法,接下来要解决的就是一个请求如何找到对应的节点,比如A请求应该是分给A节点的,那么A请求如何找到A节点?最简单的方式就是ip+端口的方式:把ip和端口告诉调用方,调用方根据lb选择对应的ip节点,这样就找到了服务,这种方式比较常见的就是配置nginx的upstream,把nginx作为我们server的代理层,由它去帮我们发现服务。但是这种方式的缺点就是需要人工干预,当我们需要动态添加一个或者删除一个节点的时候,都需要修改nginx的配置,如果线上某个节点出现故障,人工去改配置是不是显得有些缓慢。

注册和发现

也许你会觉得ip和port的方式还行,毕竟在节点不多的情况下,加加减减配置也是很快的。然而在大型微服务系统中,我们会把服务拆的更细,我们会根据业务分类把整个应用拆成一个一个微服务,每个微服务做高可用,所以每个微服务也是由多个节点组成的。在这么多节点的情况下,通过ip+port的方式来管理,那得多耗费人力和不安全。于是服务注册和发现就出现了,通过服务注册和发现可以自动管理节点,不需要人为干预,极大的提高了运维的效率。

服务注册:把自己的ip和端口告诉一个管理者,同时可以设置一个标识name。

1
2
shell复制代码#伪代码 注册一个鉴权节点
discover->register('192.168.0.1',8000,'auth')

服务发现:通过注册的name去找到对应的节点。

1
2
shell复制代码#伪代码 发现一个节点
discover->find('auth')

服务注册与发现通过心跳来检查节点存活状态,如果节点心跳失败,那么就会自动摘除,当然我们的discover也得是集群,高可用。目前一般使用consul、etcd、zookeeper等来实现。

缓存

随着信息的发展,数据量越来越大,用户越来越多,单单靠数据库提供读写已经不能满足响应快的需求,因为IO是相对耗时的,因此我们一般会在db的前面拦上一道缓存,优先读取缓存,缓存失效后我们再去数据库中读取,读取到数据再回写到缓存中,这样形成一个闭环。通过缓存,我们可以保证绝大部分的请求走缓存,不仅达到响应速度的提升,还会起到保护db的一个作用。既然用了缓存,那就避免不了数据性一致的问题。当我们尝试更新一条数据,我们是先更新缓存,还是更新数据库?

先更新缓存,再更新数据库

  • 假设先更新缓存成功,然后更新数据库失败,那么就会导致数据不一致,而且在缓存失效后去数据库读取的数据也是老的。
  • 假设现在有两个请求A、B同时进行操作
    1. A更新缓存
    2. B更新缓存
    3. B更新数据库
    4. A更新数据库
      由于网路问题A延迟了,那么可以发现B更新数据库的数据被A覆盖了。

先更新数据库,再更新缓存

  • 假设先更新数据库成功,然后更新缓存失败,那么也会导致数据不一致,但是相比先更新缓存,再更新数据库的模式,在缓存失效后,可以读到正确的数据。
  • 假设现在有两个请求A、B同时进行操作
    1. A更新数据库
    2. B更新数据库
    3. B更新缓存
    4. A更新缓存
      由于网路问题A延迟了,那么可以发现B更新的缓存数据被A覆盖了。

撇开双写不一致的问题不说,更新缓存这个动作不推荐使用,因为有时候缓存里面的数据是个比较复杂的数据,它是个综合体,需要读几张表的数据做个聚合,并且如果你用的json string,那么每次还要反序列化,更新后再序列化,整个开销还是非常大的。而且如果在某一时刻,你更新了10次,但是一次读也没有,那么白白浪费了多次更新缓存这个操作。

先删除缓存,再更新数据

当A请求来删除缓存,B请求读缓存:

  1. A删除缓存
  2. B请求缓存,发现缓存不存在
  3. B请求数据库,把读到的旧值回写到缓存
  4. A将新值更新到数据库

解决这个问题方式一般用延迟删除,在A更新完数据库后,再执行一次删除。

先更新数据库,再删除缓存

同理如果更新数据库成功了,但是删除缓存失败了,那么也会导致数据不一致,或者你的缓存是主从架构,主删除成功了,但是还没来得及同步到从,这时候读取的是从服务器,那么也会造成短暂的数据不一致问题。

其实无论哪种方法都不能保证数据库缓存双写一致的问题,更何况在分布式系统中。只有选择一种风险更小的,或者更适合自己业务场景的方式。剩下就是接监控和报警,在一些对数据一致性要求比较高的场景中,通过监控和报警来及时通知异常数据,然后对异常数据进行修补操作是个不错的选择。

接口限流

为什么接口需要限流,首先每个接口干的事不一样,有的接口也许只返回一些静态的配置,有些接口需要进行复杂的运算,还有些接口需要大量的IO…。不限流的话,对于一些比较耗时的接口来说,突发的流量可能把我们的机器打挂或者背后的DB打挂。
限流怎么估算?一般通过压测来预估自己接口能承受的qps,这样超过预估的qps,我们拒绝处理。好的限流算法也是非常重要的,常见的限流算法如下:

计数限流

来一个就累加一个,在单位时间内当总数到达设定的界限后,就拒绝服务。
这种方案的缺点就是突发流量支持不太好,比如1s限制100个,前100ms就把用完了,剩下的900ms内都是无法服务的。

1
2
lua复制代码count++
count--

固定窗口限流

相比计数限流,固定窗口多了个窗口的概念。固定窗口限流的主要思想就是将某个时间段当成一个窗口,在这个窗口内有一个计数器,这个计数器用来统计在这个时间窗口内请求的次数,当请求的次数超过阈值,那么就会限流。当开启下一个窗口的时候,会进行重新计数。但是固定窗口有个缺点:假设我的限流的是100qps,我的窗口是以s为单位的,第一个窗口的后0.5s把100qps消耗完了,然后第二个窗口的前0.5s把100qps也消耗完了,那么两个加起来1s就200qps,显然不符合我们的限流规则。

滑动窗口限流

为了解决固定窗口带来的问题,我们可以将窗口设置成不固定的,每次请求到来的时候,往前推1s,那么在这1s的窗口内,如果没达到限流上限那么就提供服务,否则拒绝服务。但是滑动窗口也有个问题,就是没法解决流量突发的问题,比如前1ms,把限流用完了,剩下的999ms都是无法服务的。

漏桶限流

漏桶算法的思想是:外部速率是变的(请求有密集有稀疏),而桶的容量是固定的,并且总是以恒定的速率出桶,当处理不过来了(桶满了),多余的请求就会被限流(桶溢出)。漏桶法适合以恒定速率处理的场景。

令牌桶

令牌桶的思想是以恒定的速率像向里放入令牌,当桶里的令牌满了,那么就不会放入,说明此时的请求速度跟不上令牌放入速度。这样在服务闲暇时,桶里会有足够的令牌,在突发流量的时候,因为桶里已经有足够的令牌了,也可以一定程度上满足突发流量。令牌桶算法支持出桶的速率是任意的,每次都需要从桶里获取令牌,如果获得了令牌表示可以处理任务,没拿到令牌就是限流了。


放入令牌的动作也不需要单独起个线程去处理,可以在每次获取令牌的时候利用时差和速率顺带算出应该放入多少令牌进去。

1
2
3
bash复制代码速率:speed = 1/n #例如 1s 10个, 那么速率就是每1/10秒放入1个
时差:difftime:=now-lasttime
放入令牌:difftime/speed #这里注意算出的token别超出桶的容量

服务熔断

为什么需要熔断机制?在微服务中,由于服务拆分的很细,经常会出现服务A->服务B->服务C,如果C服务因为某些耗时操作,导致慢查询,接口的RT线性增长,这时会导致B服务的所有请求都超时,随着超时越来越多,tcp连接被打满,服务B也出现问题,服务B出现问题之后,会导致A的超时也逐渐增加,同样也出现A服务不可用的情况。可以发现因为服务C的问题导致相关联的服务都出现不可用的情况,引起雪崩。我们应该在服务C出现连续不可用的时候,切断服务B与C的通信,这就是熔断。当然我们也不能一直熔断,如果服务C好了,我们还还得继续提供服务,那么这就涉及到熔断算法了。

熔断策略

  1. 统计一段时间内的请求总数
  2. 统计这段时间内请求的失败率
  3. 如果1和2都满足,触发熔断
  4. 熔断一段时间后半开启,尝试放入一点量
  5. 半开启后如果还是出现很多失败,再次熔断,否则熔断器关闭。

当请求总数达到我们设置的数量且失败率也达到我们设置的标准后,就触发熔断。这时候熔断器会告诉我们服务不可用了,这样下次过来的请求,会直接返回。当过了一段时间之后(时间可配),假设依赖的服务已经恢复了,这时候熔断器会尝试放入一部分流量进去试探,如果此时还有很多错误,说明依赖的服务还是没恢复,那么会再次触发熔断,并且等待下次半开启的时间。如果半开启的流量都ok,那么就会关闭熔断,服务恢复。

服务降级

在服务治理中,降级也是比较重要的一个手段。当业务流量出现峰值的时候,导致某些不重要的服务出现故障,而这些故障服务可能造成连锁反应,尽而影响主体业务,这时候一般可以停掉一些不重要的服务(比如运营位、评论…)。

手段

  • 整个服务停掉
  • 相关的接口直接返回空
  • 接口依赖DB资源切掉,转成从本地内存读取兜底数据

当然降级一般可以熔断配合一起使用。

1
2
3
kotlin复制代码if break { //熔断
return localCache //降级走本地缓存
}

守护进程 + 平滑重启

如果我们服务在运行期间,突然出现异常退出,这时候假设没有一个自动恢复的功能,那岂不是很尴尬。

守护方式

  1. 如果服务上k8s,那么k8s会在pod退出时,自动拉起。
  2. 通过第三方的supervisor,管理我们的进程
  3. 通过系统systemd来管理我们的进程

平滑原理

需要注意的是在服务重启的过程中,要注意平滑。不能因为重启造成正在处理的请求出现504的情况,一般会通过主进程fork子进程的方式,主进程负责处理老的请求,主进程处理完成之后,自动退出。新的请求全部交给子进程处理,主进程退出后,子进程也就是新的主进程了。

分布式链路追踪

在分布式系统中,由于服务的拆分,一个网关接口背后可能是许许多多的微服务接口层层嵌套而成的,这样无疑增加了问题的排查难度。比如A->B->C->D,
A因为错误找B,B说是C的错误导致的,C说是D错误导致的。如果有一种方式能将分布在各个地方的节点串起来,最终通过界面展示出各个节点的耗时、ip、错误等信息,那就可以极大的提升排查问题的效率。

分布式链路追踪(trace)的出现解决了这个问题。

链路追踪一般包括3个核心的步骤:数据采集、数据存储和数据展示。由于数据采集需要侵入代码,那么必然会造成各种不同的写法,于是OpenTracing出现了,OpenTracing是一套分布式追踪协议,和语言无关,具有统一的接口规范,方便接入不同的分布式追踪系统。OpenTracing语义规范中定义的数据模型有Trace、Span以及Reference。

Span

Span表示一个独立的工作单元,它可以是一次rpc调用,一次函数调用,甚至是你认为属于span模块的调用。
通过span圈定我们关注的模块,span一般包含:

  • span的name,比如依赖方的服务名称
  • 开始时间戳
  • 结束时间戳

Trace

Trace表示一条完整的追踪链路,例如:一个请求的整个生命过程。一个 Trace是由一个或者多个Span组成的有向无环图(DAG)。

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码        [Span A]  ←←←(the root span)
|
+------+------+
| |
[Span B] [Span C] ←←←(Span C is a `ChildOf` Span A)
| |
[Span D] +---+-------+
| |
[Span E] [Span F] >>> [Span G] >>> [Span H]
↑
↑
↑
(Span G `FollowsFrom` Span F)

按照时间轴的方式展示如下:

1
2
3
4
5
6
7
css复制代码––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time

[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E·······] [Span F··] [Span G··] [Span H··]

Reference

一个Span可以与一个或者多个Span存在因果关系,这种关系称为Reference。OpenTracing目前定义了两种关系:ChildOf(父子)关系和FollowsFrom(跟随)关系。

  • ChildOf:父Span的执行依赖子Span的执行结果,比如对于一次RPC调用,父Span同步等待子Span的返回结果。这时候子Span就是ChildOf父Span。
  • FollowsFrom:父Span的执行不依赖子Span的执行结果,比如一些异步流程。

每个请求的链路都有一个唯一的traceID,通过traceID和spanID,可以把一条链路上的所有span关联起来,整个请求链路一目了然。常见的分布式链路追踪有 Uber的Jaeger、Twitter的Zipin、阿里的鹰眼…

日志

通过日志帮我们排查问题也是个常用手段,根据不同的场景,我们将日志级别分为以下几种:

  • DEBUG:调试等级的日志,调试程序时经常使用的类型。
  • TRACE:跟踪等级的日志,指一些包含程序运行详细过程的信息。
  • INFO: 信息等级的日志,打印一些自己认为需要的info信息。
  • WARN: 警告等级的日志,可能是潜在的错误,需要进一步判断问题的严重性,一般不影响程序正常运行。
  • ERROR:错误等级的日志,指某个地方发生了错误,影响正常的功能,需要确认修复的
  • FATAL:致命等级的日志,程序发生崩溃,异常退出等严重性错误。

监控

服务的健壮性离不开监控,通过监控我们可以看到服务的qps情况、http错误情况、耗时情况、cpu负载情况、以及我们自己想要监控的业务数据情况…。监控系统的三要素:收集指标、存储数据、页面展示。
这其中被广泛使用的的莫过于Prometheus+Grafana。普罗米修斯监控上报的指标有4种metrics类型:

Counter:

计数器是比较简单的指标类型,计数器的值只能增加或者重置为0,比如统计接口的请求量、http错误的次数等都可以用counter。

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码httpReqs := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "How many HTTP requests processed, partitioned by status code and HTTP method.",
},
[]string{"code", "method"},
)
prometheus.MustRegister(httpReqs)
httpReqs.WithLabelValues("404", "POST").Add(42)


m := httpReqs.WithLabelValues("200", "GET")
m.Inc()

Gauges

Gauges可以用于处理随时间增加会动态变化的指标,比如像内存使用率、CPU负载等等。

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码opsQueued := prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "our_company",
Subsystem: "blob_storage",
Name: "ops_queued",
Help: "Number of blob storage operations waiting to be processed.",
})
prometheus.MustRegister(opsQueued)

// 10 operations queued by the goroutine managing incoming requests.
opsQueued.Add(10)
// A worker goroutine has picked up a waiting operation.
opsQueued.Dec()

Histogram

直方图,一个histogram会生成三个指标,分别是_count(数量)、_sum(累计和)、_bucket(桶)。

  • _bucket:对每个采样点进行统计打到各个桶(bucket)中
  • _sum:对每个采样点值累计和(sum)
  • _count:对采样点的次数累计和(count)

比如现在有100份蛋糕,蛋糕的质量都是小100g的,我们希望把这100份蛋糕按质量分到对应的盒子里,现在有5个盒子分别可以盛放0-20g、20-40g、40-60g、60-80g、80-100g的蛋糕。那么_bucket就是这5个盒子,假设20g的盒子里面装了5g、10g、15g 3个蛋糕,那么_count就是3,_sum就是5+10+15=30g。

1
2
3
4
5
6
7
8
9
go复制代码CakeHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "cakeHistogram",
Help: "cake histogram",
Buckets: prometheus.LinearBuckets(20,40,60,80,100),
})
cases:=[]int{1,2,3,4,...}
for i:=0;i<len(cases);i++{
CakeHistogram.Observe(cases[i])
}

Summary

summary和histogram类似也会产生三个指标,分别是_count、_sum和quantile,_count和_sum与histogram的概念相同,quantile的含义是分位数,summary可以在定义时指定分位数,如5分位、9分位、99分位、999分位,999分位的概念就是比这个数小的数占99.9%。如果你有N个样本值,首先要从小到大排序,然后取出排在φ *N的值。summary可以通过百分比的概率来反应整个指标的情况。一般会对φ加一个波动范围,比如0.9:0.1,那么最终φ在0.89-0.91之间。

1
2
3
4
5
6
7
8
9
go复制代码GradeSummary = prometheus.NewSummary(prometheus.SummaryOpts{
Name: "man_grade",
Help: "man grade",
Objectives: map[float64]float64{0.5:0.01,0.9:0.001,0.99:0.01,0.999:0.01},
})
var salary = [10]float64{90,87,88,84,99,100,91}
for i:=0;i<len(salary);i++{
GradeSummary.Observe(salary[i])
}

指标上报的前提首先要在你的机器上安装Exporter组件,然后prometheus server会主动过来拉采集器的数据,或者采集器主动把数据推到push gateway中,prometheus server再对从push gateway进行数据的拉取。指标一般都会临时存在机器的内存里,这也是为什么一般发版的时候,会出现曲线抖动的情况。

告警

告警是我们第一时间了解问题的手段,一次业务的错误可以告警,一段时间内qps的抖动可以告警…,常见的告警的手段有:

  1. 企业微信
  2. 邮件
  3. 短信
  4. 电话

CI/CD

在没有CI/CD的时候,上线代码的任务是非常繁琐的,需要多人合作。


你需要自己去编译,然后跑单元测试,如果中途发现错误,还得修改代码,修改代码后,又得手动去编译、测试…,这一系列重复的动作,无不降低从开发到部署的效率。

CI

持续集成(Continuous Integration):,开发人员能够频繁地将其代码集成到公共代码仓库的分支中。CI可以在源代码变更后自动检测、拉取、构建和进行单元测试。CI的目标是快速确保开发人员新提交的变更是好的,并且适合在代码库中进一步使用。

CD

持续交付(Continuous Delivery):持续交付在持续集成的基础上,将集成后的代码部署到预发环境中。

持续部署(Continuous Deployment):持续部署则是在持续交付的基础上,把部署到生产环境的过程自动化。

通过CI/CD我们可以解放重复性劳动、更快地修复问题、更早的交付成果,实现了开发运维一体化,大大提升了交付效率。

配置中心

为了减少我们项目中的硬编码,针对一些可能经常变更的配置信息,一套成熟的配置中心能给我们的项目带来不少的好处:不用因为一个小配置,而重新修改代码(比如一个商品的价格,今天是9.9元,明天做活动,希望改成6.9元)。根据场景,把静态的常量改成读取动态的配置,可以提升交付的效率。

实现

一般是在项目发布的时候,去远程的配置中心把对应的配置文件拉取到本地,程序根据配置文件把对应的配置映射到自己的变量中,相应的代码读取对应的变量。

特点

  • 可视化配置后台,方便操作
  • 版本记录,会记录每一次配置修改的记录,出了问题,方便回溯
  • 回滚,如果新改的配置有问题,可以回退到之前的配置。

故障演练

故障演练类似于军事演习,虽然线上稳定,但是如果出现问题,如何排查和解决?目的是锻炼程序员的问题排查能力和快速定位问题的能力。那么如何搭建一套演练环境?直接用测试环境肯定不行,首先不能影响正常测试,其次测试环境和真实环境还是有些差别的。预发和生产更不行,它们的内部连接的都是真实的线上资源,不能影响线上。

环境搭建

于是需要搭建这样一套接近真实环境的演练环境,这个演练环境的资源(DB、缓存…)需要和生产隔离,并且把线上的DB等资源copy新的DB中。接下来是流量,流量要接近真实流量,最好是真实流量,可以使用类似GoReplay这样的http流量回放神器,在lb层把流量进行复制,然后把复制的流量在演练环境进行回放达到真实流量回放的效果。

演练什么

  • DB切断
  • 缓存切断
  • 某个服务切断
  • ….

模拟一些资源或者其他的故障,当故障出现时,第一时间应该是报警,然后去查看监控和日志来定位是哪个服务的哪个环节出现了。

压测

通过压测来摸清线上的处理极限来达到提前预防的效果,压测环境也要接近真实环境,压测环境理论可以使用演练环境,如果条件充足,也可以单独搭建一个压测环境。

压测对象

  • 可以是一个接口
  • 可以是一块业务
  • …

压测评估

  • RT:观察响应时间
  • ERROR:观察错误信息
  • CPU:观察cpu负载
  • MEM:观察内存负载
  • DB:观察数据库负载
  • 连接数:观察网络连接数
  • IO:观察磁盘IO
  • QPS:观察qps曲线

最终通过压测评估出支撑的qps,极限的瓶颈在哪,未来如果流量增长,优化点在哪,如果是cpu密集型的,应该加机器资源,如果是IO密集型的,可能要加DB相关的资源了。

编码健壮

以上介绍的都是服务级别的健壮,下面我们来介绍下程序层面的健壮。如何优雅的编写一套代码,对于一个程序员来说也是非常重要的。

推荐用 return 代替 else

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go复制代码// bad case
if a {
do_a()
} else if b {
do_b()
}
// good case
if a {
do_a()
return
}
if b {
do_b()
return
}

重试

当我们在进行rpc调用的时候,对于一些非常重要的业务场景,可以给予几次重试的机会。

1
2
3
4
5
go复制代码for i:=0;i<2;i++{
if _,err := rpc.getUserinfo();err==nil{
break
}
}

连接池

通过连接池来复用连接,避免通过三次握手来频繁的建立新的连接。

1
2
go复制代码redisPool := newRedisPoo()
conn := redisPool.Get()

超时控制

任何一个rpc请求,都应该给予一定的超时时间,避免造成雪崩现象。

1
go复制代码conn, err := net.DialTimeout(netw, addr, time.Second*2)

避免资源重复调用

在一个业务中,经常会出现func A()->func B() -> func C(),假设每个func都需要获取用户的信息,千万不要每个func都去获取一遍(除非对实时性要求非常高的场景),通过参数传递的方式来减少不必要的查询。

1
2
3
4
5
6
7
8
9
go复制代码func A() {
user := DB.getUser()
B(user)
}
func B(user *User) {
C(user)
}
func C(user *User) {
}

函数体代码不要过多

当一个函数的代码行数非常多的时候,会不利于阅读。

1
2
3
csharp复制代码func doSomething() {
//此书省略1000行
}

函数命名要符合实际意义

例如获取最近登陆的用户。

1
2
3
4
go复制代码//bad case
func getLoginUser()
//good case
func getRecentLoginUser()

捕捉异常

不要因为一些异常而导致你的程序退出。

1
2
3
4
5
go复制代码defer func (
if err:=recover;err!=nil{
log.Error("something panic (%+v)",err)
}
){}

欢迎关注同名公众号,领取计算机网络、数据结构、redis、mysql、java、go、python等电子书

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

docker安装MySQL主从及新增节点

发表于 2021-09-05

1.下载镜像,这里使用MySQL8.0.18

1
复制代码docker pull mysql:8.0.18

2.主库

创建my.cnf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ini复制代码user=mysql
character-set-server=utf8
default_authentication_plugin=mysql_native_password
secure_file_priv=/var/lib/mysql
#expire_logs_days=7
sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
max_connections=1000

## 设置server_id,同一局域网中需要唯一
server_id=101
## 指定不需要同步的数据库名称
binlog-ignore-db=mysql
## 开启二进制日志功能
log-bin=mall-mysql-bin
## 设置二进制日志使用内存大小(事务)
binlog_cache_size=1M
## 设置使用的二进制日志格式(mixed,statement,row)
binlog_format=mixed
## 二进制日志过期清理时间。默认值为0,表示不自动清理。
expire_logs_days=7
## 跳过主从复制中遇到的所有错误或指定类型的错误,避免slave端复制中断。
## 如:1062错误是指一些主键重复,1032错误是因为主从数据库数据不一致
slave_skip_errors=1062

[client]
default-character-set=utf8

[mysql]
default-character-set=utf8

创建容器

1
2
3
4
5
6
7
bash复制代码sudo docker run -d --privileged=true \
-v /community/mysql/master/conf:/etc/mysql \
-v /community/mysql/master/logs:/var/log/mysql \
-v /community/mysql/master/data:/var/lib/mysql \
-v /community/mysql/master/my.cnf:/etc/mysql/my.cnf \
-p 3306:3306 --name=mysql -e MYSQL_ROOT_PASSWORD=123456 \
mysql:8.0.18

参数说明

参数 说明
-d 后台运行
-v 挂载文件或文件夹
-p 端口映射
-e 环境变量设置
–privileged=true 拥有root权限
–name 容器名称

创建数据同步用户

  • 进入容器中的MySQL
1
2
bash复制代码# 进入容器内部,并连接mysql
docker exec -it mysql mysql -uroot -p

  • 创建账号
1
2
sql复制代码CREATE USER 'slave'@'%' IDENTIFIED BY '123456';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON . TO 'slave'@'%';

3.从库

创建从库容器

1
2
3
4
5
6
7
bash复制代码sudo docker run -d --privileged=true \
-v /community/mysql/slave1/conf:/etc/mysql \
-v /community/mysql/slave1/logs:/var/log/mysql \
-v /community/mysql/slave1/data:/var/lib/mysql \
-v /community/mysql/slave1/my.cnf:/etc/mysql/my.cnf \
-p 3305:3306 --name=mysqlslave -e MYSQL_ROOT_PASSWORD=123456 \
mysql:8.0.18
  1. 主从同步

查看主库状态

  • 进入主库容器中的MySQL
1
2
bash复制代码# 进入容器内部,并连接mysql
docker exec -it mysql mysql -uroot -p
  • 查看主数据库状态
1
2
3
4
5
6
lua复制代码mysql> show master status;
+-----------------------+------------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+-----------------------+------------+--------------+------------------+-------------------+
| mall-mysql-bin.000003 | 1044876395 | | mysql | |
+-----------------------+------------+--------------+------------------+-------------------+

配置从库

  • 进入从库中的MySQL
1
2
bash复制代码# 进入容器内部,并连接mysql
docker exec -it mysqlslave mysql -uroot -p

  • 在从数据库中配置主从复制
1
2
3
4
ini复制代码change master to master_host='10.0.0.165',\
master_user='slave', master_password='123456',\
master_port=3306, master_log_file='mall-mysql-bin.000003', \
master_log_pos=1044876395, master_connect_retry=30;
  • 参数说明
参数 说明
master_host 主库IP
master_user 同步的用户
master_password 同步的密码
master_port 同步的端口
master_log_file show master status;查出来的File
master_log_pos show master status;查出来的Position
master_connect_retry 连接失败重试的时间间隔,单位为秒。
  • 查看主从同步状态
1
ini复制代码show slave status \G;
  • 开启主从同步
1
ini复制代码start slave;
  • 当Slave_IO_Running和Slave_SQL_Running都为YES时就是成功

5.测试

  • 在主库中创建数据库
  • 在从库中查看

6.增加新节点

停止一个从库的主从复制

1
2
3
4
5
6
7
8
ini复制代码# 进入容器内部,并连接mysql
docker exec -it mysqlslave mysql -uroot -p

# 停止主从同步
stop slave;

# 查看从库状态
show slave status \G;

备份当前从库的所有数据

1
css复制代码mysqldump -uroot -p --all-databases > sqlfile.sql
  • ps: 之前创建容器的时候,映射有文件夹,这条命令可以在有映射文件夹的地方执行,这样能直接在外面拿到sqlfile.sql ,也可以使用docker cp命令将sqlfile.sql文件拷贝出来

将导出的数据,导入新库里面

1
bash复制代码source /var/log/mysql/sqlfile.sql;

设置主库同步

1
2
3
4
ini复制代码change master to master_host='10.0.0.165', \
master_user='slave', master_password='123456',\
master_port=3306, master_log_file='mall-mysql-bin.000004', \
master_log_pos=37642095, master_connect_retry=30;
  • 参数说明
参数 说明
master_log_file 对应从库状态查询出来的Relay_Master_Log_file
master_log_pos 对应从库状态查询出来的Exec_Master_Log_Pos

开启同步

1
ini复制代码start slave;
  • 查看状态
  • show slave status \G;

开启第一步关闭的从库的主从复制

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

自己工作中一直接触不到高并发、分布式怎么办?

发表于 2021-09-04

背景

面试总会遇到一些关系高并发、分布式的问题,可是自己工作中不接触,自学又不深入,这可怎么办?

分布式架构的知识太庞大了,小匠也是管中窥豹,结合自己的经验阐述一下自己的想法。

结合我自己的一些面试经历,从分布式系统的构建体系说一下可能遇到的问题,涉及的技术和解决方案,这便是分布式系统的重点,也是面试的重点。

现象

我们从业务场景入手,用户越来越多,单个系统的内存、磁盘、CPU无法满足业务的需求的时候,需要把单机变成多机来解决问题,那么就需要引入分布式横向的扩充机器以增加吞吐量。针对不同的业务的需要的硬件、带宽、重要性不同,我们又一次做细粒度的拆分,把每一个模块根据业务的上下文进行拆分,这样就有了微服务。

那么同时也引发了一些问题,一个请求需要协同多个服务来解决,势必会出现数据不一致的问题?那么我们就需要在 CAP 中间做取舍,同时需要在幂等、分布式事务、分布式锁、重试、补偿等方便着重考虑每个服务之间的依赖和数据传输。最后针对不同的业务可以做到熔断、降级等处理。

擦,对于一个工作本就没有机会接触这么多名词的人,如何应对这么“卷”的互联网面试?

何如?

首先推荐两本书,它们并不能解决你遇到的面试问题,而是提供一个通用的思路和解决方案。

《大型网站系统与JAVA中间件实践》

《大型网站技术架构:核心原理与案例分析》

还有一个小册子,这个我快速看了一下还不错,可以快速的了解一些关键概念。

《分布式原理介绍》

那么有人继续追问,截止到现在你其实说了一堆废话,我还是一头雾水,怎么解决自己没有分布式项目的经历,怎么觉得自己面试过程中提及分布式问题的困难的?

答案只有一个就是学以致用,因为你啃书再透彻,自己的练习 demo 写的再完备,对上面罗列的技术和知识点依然是一知半解。

下面说一下我的亲身经历,想办法自己推动现有项目做技术变革,实在不行也可以做“伪变革”。

当时我的项目使用的并不是分布式架构,我接到需求一个,大概评估了一下需要 3 周完成,于是我用自己业余时间调研了一下接入 dubbo 需要多少时间,差不多需要 5 周,于是我就直接和主管说,我想使用 dubbo 来做这个需求,大概需要 5 周时间,但是可以解决我们项目的耦合问题,增加复用度,百利无一害。不过考虑到工期问题,我可能需要每天加加班,一次来保证进度问题,这个您放心。果不其然主管也答应了,那么接下来就是我发挥的时候了,学的东西用到真实的项目里面才能真正的体会其中的奥妙。

这就是我学 dubbo 的经历,还有另一个类似的经历,我加入公司的时候还不是前后端分离的项目,开发成本太高了,当时火的还是 angular,于是我主动请缨,唇枪舌战最终使用spring boot + angular 做了一次重构,虽然当时也加了很多班,但是成长很多,现在想想也挺感谢当时的领导的。

经历这两件事以后我对这些技术的理解又不一样了,如果你和我一样,一样没有大厂的经验傍身,也没有足够的环境让你接触新技术,同样可以一样和我做类似的尝试……

假如?

当然即便是这样你接触的分布式架构也是冰山一角,但是足够入门,后面就需要不断的完善自己的架构了,比如考虑缓存的接入,分布式锁的接入,分布式事务的接入,消息队列的接入,再后面考虑熔断、降级等组件的加入。这样以来假以时日你就渐入佳境了。

那么问题又来了,假如你也和我一样唇枪舌战最后失败了,没有采纳你的意见怎么办?这里我也有一个经历分享给大家,这也算是一个面试技巧吧。

当时我在学习 Redis 的 zset,基本了解了里面的数据结构、原理和经典的使用案例,于是我把现有的一块逻辑生搬硬套到了我们的项目里面,前前后后思考了一下看是否有问题,然后在自己本机搭建 Redis 使用 zset 解决,并完美的解决了所有问题。记得当时这个需求是实现一个每周最热话题,要实时更新的那种。说到这里你是不是又学到了?这就是我上面说的“伪变革”。

最后

最后无论你选择那种思路,都不要放弃自己那份求知的热情,时刻保持自己的竞争力。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

良好的编码习惯之方法只干一件事

发表于 2021-09-04
  1. 背景

最近接手了个项目,在熟悉代码的时候,发现了好多可以优化的代码,这些代码都违反了一个规范,叫 方法只做一件事

感觉挺有记录意义的,所以来写一下这些问题代码,以及我觉得这些代码的问题点,还有就是优化

  1. 示例一

2.1 问题代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
go复制代码// 通过用户id判断有没有权限
func (userAuth *UserAuth) CheckPermission(uid int64, needAdminPermission bool) (hasPermission bool) {
user, err := userAuth.GetUserInfoById(uid)

   // 用户存在即有权限
   if err != nil {
hasPermission = true
}

   // 需要管理员权限
if needAdminPermission {
if user.IsAdmin() {
hasPermission = true
} else {
hasPermission = false
}
}

return
}

2.2 问题点

这个CheckPermission方法做了两件事:

  • 判断用户是否有一般的权限
  • 判断用户是否有管理员的权限

它通过needAdminPermission这个布尔值来决定需要做哪件事,这样会带来一些问题

  • 可读性:最大的坏处是可读性变差。实际的代码并没有那么简单,它做了多件事,相应地,逻辑会变复杂,代码行数会增加,这些不利于问题的排查、后人接手。更坏的情况下,做了多件事情的方法甚至都不能做到见名知意
  • 灵活、可扩展性:想想,万一需求变了,需要加多一个角色权限,这个方法好扩展嘛
  • 可测试性:一般来说,入参越少、功能越少的方法,可测试性越高,写单测越简单。如果让我写这个函数的单测,我会这样按照入参来决定用例有哪些,那么这个函数的用例就有六条,不存在的用户+true/false,普通用户+true/false,管理员+true/false

2.3 优化

将CheckPermission拆分成两个方法,一个用来校验普通用户的,一个用来校验管理员的

1
2
3
4
5
6
7
8
9
go复制代码// 通过用户id判断有没有权限
func (userAuth *UserAuth) CheckUserPermission(uid int64) (hasPermission bool) {
user, err := userAuth.GetUserInfoById(uid)
   // 用户存在即有权限
   if err != nil {
hasPermission = true
}
return
}
1
2
3
4
5
6
7
8
9
go复制代码// 通过用户id判断有没有权限
func (userAuth *UserAuth) CheckAdminPermission(uid int64) (hasPermission bool) {
user, err := userAuth.GetUserInfoById(uid)
   // 判断是否为管理员
   if err != nil && user.isAdmin{
hasPermission = true
}
return
}

好处:

  • 即使我需要扩展多一个角色,也只需要多一个CheckXXXPermission的方法即可,对原来的方法以及单测并无影响
  • 简单易懂,见名知意
  • 降低变更带来的风险
  • 写单测也简单了。每个CheckXXXPermission方法我只需要传不存在的用户、普通用户和管理员即可,虽然用例数没变,但是组合条件是少了,单测的复杂度一下子就下来了
  1. 示例二

这个例子的也是一个方法干了多件事,但是它的问题会比较掩蔽,出现问题时找问题会比较麻烦

3.1 问题代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码// 校验手机短信验证码
func (biz *SmsBiz) CheckMoileSmsCode(phone, smsCde string) (err error){

// 是否需要检测图形验证码
// 判断该手机号获取验证码的次数,超过指定次数则返回需要图形验证码的错误
// 未超过次数,则该手机号获取验证码次数 + 1
if err := biz.CaptchaCodeRequire(phone); err == errorutils.ErrorNeedCaptchaCode() {
return
}

// 从缓存中获取短信验证码,然后匹配
cacheSmsCode := biz.getSmsCodeFormCache(phone)
if smsCode != cacheSmsCode {
return errorutils.ErrorCodeSmsCodeNotMatch()
}

return nil
}

3.2 问题点

这个方法有副作用。什么是副作用呢?简单来讲就是方法除了完成了自己的工作之外,还对系统或者被调用者产生额外的影响

这个CheckMoileSmsCode方法看上去只是校验手机的验证码是否正确,但是它还有个副作用,就是会额外记录这个手机号获取验证码的次数

实际遇到的问题:登录校验除了一大串检验逻辑,还会调用到这个CheckMoileSmsCode方法,所以当时多次登录之后,就报了一个需要验证图形码的错误。

3.3 优化

这个 是否需要检测图形验证码CaptchaCodeRequire()的方法跟校验手机短信验证码CheckMoileSmsCode()从抽象层级来说是属于同一层级的,不太应该在CheckMoileSmsCode()中调用,应该移走。

好处:减少副作用带来的隐性危害

  1. 例子三

4.1 问题代码

1
2
3
4
5
6
7
8
9
10
java复制代码// 对应用的名称和地址进行校验
public void checkAppNameAndUrl(String name, String url) {
   
   // 判断应用名是否为空
   StringUtils.isBlank(name);
   
   // 判断应用地址是否为空
   StringUtils.isBlank(url);
   
}

4.2 问题点

不利于代码复用

在新建应用的时候,需要对应用的名称和地址进行校验,可是这个方法干了多件事,就是把名称和链接地址的校验捆绑了在一起

假设需求 更新应用时只能更新名称的时候,那么这个方法就不适用了

4.3 优化

4.3.1 优化一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码// 对应用的参数进行校验
public void checkAppParam(AppModel appModel) {
   
   // 判断应用名是否已存在
   checkAppName(appModel.getName());
   
   // 判断应用地址是否已存在
   checkAppUrl(appModel.getUrl());
   
}

// 检验应用名称
public void checkAppName(String name) {
   StringUtils.isBlank(name);
}

// 检验应用地址
public void checkAppUrl(String url) {
    StringUtils.isBlank(url);
}

见名知意,doXXX1AndXXX2的方法一看就知道不对劲了,最好把它拆成doXXX1()和doXXX2()

利于代码复用,假如我只需要校验名称时,那么直接使用checkAppName方法即可

4.3.2 优化二

还有一种充血模式,就是把这些参数校验的工作职责也划分到AppModel这个类里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码public class AppModel {

   private String name;
   
   private String url;
   
   // getter、setter
   
   // 对应用的参数进行校验
public void checkParam() {
   
  // 判断应用名是否已存在
  checkName(this.getName());
   
  // 判断应用地址是否已存在
  checkUrl(this.getUrl()));
   
}

// 检验应用名称
public void checkName() {
  StringUtils.isBlank(this.getName());
}

// 检验应用地址
public void checkUrl() {
  StringUtils.isBlank(this.getUrl());
}
}

我比较喜欢这种充血模式

  • 这样才是面向对象的写法,而不像贫血模式那样只有getter、setter
  • 对外屏蔽了校验细节,外面只需要调用一下即可校验,而不需要理解你对参数是怎么校验的。(迪米特法则)
  • 减少业务层的逻辑

当然,这也有不好的地方,比如,检测参数这个职责应不应该划分到AppModel里呢?其实这些都是比较模糊的,不同人有不同看法。

  1. 思考

CAS操作,在Java,JUC下面的atomic包里,有着大量的doXXXAndXXX()方法,例如

1
2
3
java复制代码public final boolean compareAndSet(int expect, int update) {
   return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

那么这里的方法算不算干了多件事情?应不应该拆分成两个方法

5.1 个人理解

判断方法是不是干了多件事,除了像示例二那样看抽象层级之外,还可以通过示例三那样看看能不能再拆出一个方法

那这里能拆成再拆出这两个方法嘛?

嗯,我觉得并不能,因为这里是要原子地执行compare和set操作,所以这里并不能拆成compare方法和set方法

其实拆也不是不行,再调用compare和set的时候加上锁不就行了。但是这样就本末倒置了,加锁的代价一般来说都会比CAS大,所以这里把compare和set合在一个方法里也挺合理的

  1. 总结

  • 职责不单一可能会带来的问题
+ 可读性变差
+ 可扩展性变差
+ 可测试性变差
+ 可能会带来额外的副作用
+ 不利于方法复用
  • 如何判断方法职责不单一
+ 看名字
+ 看抽象层级
+ 看能不能再拆出一个方法

代码都是不断地打磨出来的,不同人有不同的理解和意见,具体场景具体分析嘛,假如大佬们有什么不错的优化想法也可以给我说说。写完收工

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文带你落地DDD 一前言 二DDD是什么 三落地分享

发表于 2021-09-04

一.前言

hello,everyone,好久不见。最近几周部门有个大版本发布,一直没有抽出时间来写博。由于版本不断迭代,功能越做越复杂,系统的维护与功能迭代越来越困难。前段领导找我说,能不能在架构上动手做做文章,将架构迁移到DDD。哈哈哈哈,当时我听到这个话的时候瞬间来了精神。说实话,从去年开始从大厂的一些朋友那里接触到DDD,自己平时也会时不时的阅读相关的文章与开源项目,但是一直没有机会在实际的工作中实施。正好借着这次机会可以开始实践一下。

image.png

本文由于本文的重点为MVC三层架构如何迁移DDD,因此将先对DDD做一个简要的概念介绍(细化的领域概念不做过多展开),然后对于MVC三层架构迁移至DDD作出迁移方案建议。如有不对之处,欢迎指出,共同进步。

本文尤其感谢一下lilpilot在DDD落地方案上给出的宝贵建议。

image.png

DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

我的第一本掘金小册《深入浅出DDD》已经在掘金上线,欢迎大家试读~

DDD的微信群我也已经建好了,由于文章内不能放二维码,大家可以加我微信baiyan_lou,备注DDD交流,我拉你进群,欢迎交流共同进步。

二.DDD是什么

2.1.DDD简介

相信了解过DDD的同学都听过网上那种官方的介绍:

  • Domain Drive Design(领域驱动设计)
  • 六边形架构模型
  • 领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具
  • ….

说的都多多少少抽象点了,听君一席话,如听一席话,哈哈哈

在我看来常规在MVC三层架构中,我们进行功能开发的之前,拿到需求,解读需求。往往最先做的一步就是先设计表结构,在逐层设计上层dao,service,controller。对于产品或者用户的需求都做了一层自我理解的转化。

众所周知,人才是系统最大的bug。

image.png

image-20210904135645004.png

用户需求在被提出之后经过这么多层的转化后,特别是研发需求在数据库结构这一层转化后,将业务以主观臆断行为进行了转化。一旦业务边界划分模糊,考虑不全。大量的逻辑补充堆积到了代码层实现,变得越来越难维护,到处是if/else,传说中***一样代码。

image-20210904140321557.png

DDD所要做的就是

  • 消除信息不对称
  • 常规MVC三层架构中自底向上的设计方式做一个反转,以业务为主导,自顶向下的进行业务领域划分
  • 将大的业务需求进行拆分,分而治之

说到这里大家可能还是有点模糊DDD与常见的mvc架构的区别。这里以电商订单场景为例。假如我们现在要做一个电商订单下单的需求。涉及到用户选定商品,下订单,支付订单,对用户下单时的订单发货。

MVC架构里面,我们常见的做法是在分析好业务需求之后,就开始设计表结构了,订单表,支付表,商品表等等。然后编写业务逻辑。这是第一个版本的需求,功能迭代饿了,订单支付后我可以取消,下单的商品我们退换货,是不是又需要进行加表,紧跟着对于的实现逻辑也进行修改。功能不断迭代,代码就不断的层层往上叠。

DDD架构里面,我们先进行划分业务边界。这里面核心是订单。那么订单就是这个业务领域里面的聚合逻辑体现。支付,商品信息,地址等等都是围绕着订单而且。订单本身的属性决定之后,类似于地址只是一个属性的体现。当你将订单的领域模型构建好之后,后续的逻辑边界与仓储设计也就随之而来了。

2.2.为什么要用DDD

  • 面向对象设计,数据行为绑定,告别贫血模型
  • 降低复杂度,分而治之
  • 优先考虑领域模型,而不是切割数据和行为
  • 准确传达业务规则,业务优先
  • 代码即设计
  • 它通过边界划分将复杂业务领域简单化,帮我们设计出清晰的领域和应用边界,可以很容易地实现业务和技术统一的架构演进
  • 领域知识共享,提升协助效率
  • 增加可维护性和可读性,延长软件生命周期
  • 中台化的基石

2.3.DDD术语介绍

战略设计:限界上下文、通用语言,子域

战术设计:聚合、实体、值对象、资源库、领域服务、领域事件、模块

1595145053316-e3f10592-4b88-479e-b9b7-5f1ba43cadcb.jpeg

2.3.1.限界上下文与通用语言

限界上下文是一个显式的语义和语境上的边界,领域模型便存在于边界之内。边界内,通用语言中的所有术语和词组都有特定的含义。

通用语言就是能够简单、清晰、准确描述业务涵义和规则的语言。

把限界上下文拆解开看。限界就是领域的边界,而上下文则是语义环境。 ​ 通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流。

域是问题空间,限界上下文是解决空间

2.3.2.上下文组织和集成模式

防腐层(Anticorruption Layer):简称ACL,在集成两个上下文,如果两边都状态良好,可以引入防腐层来作为两边的翻译,并且可以隔离两边的领域模型。

image-20210904143337032.png

2.3.3.实体

DDD中要求实体是唯一的且可持续变化的。意思是说在实体的生命周期内,无论其如何变化,其仍旧是同一个实体。唯一性由唯一的身份标识来决定的。可变性也正反映了实体本身的状态和行为。

实体 = 唯一身份标识 + 可变性【状态 + 行为】

2.3.4.值对象

当你只关心某个对象的属性时,该对象便可作为一个值对象。 我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。

值对象=将一个值用对象的方式进行表述,来表达一个具体的固定不变的概念。

2.3.5.聚合

聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。

我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。

2.3.6.聚合根

聚合的根实体,最具代表性的实体

2.3.7.领域服务

当一些逻辑不属于某个实体时,可以把这些逻辑单独拿出来放到领域服务中 理想的情况是没有领域服务,如果领域服务使用不恰当慢慢又演化回了以前逻辑都在service层的局面。

可以使用领域服务的情况:

  • 执行一个显著的业务操作
  • 对领域对象进行转换
  • 以多个领域对象作为输入参数进行计算,结果产生一个值对象

2.3.8.应用服务

应用服务是用来表达用例和用户故事的主要手段。

应用层通过应用服务接口来暴露系统的全部功能。 在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。

应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。

应用层作为展现层与领域层的桥梁。展现层使用VO(视图模型)进行界面展示,与应用层通过DTO(数据传输对象)进行数据交互,从而达到展现层与DO(领域对象)解耦的目的。

2.3.9.工厂

职责是创建完整的聚合

  • 工厂方法
  • 工厂类

领域模型中的工厂

  • 将创建复杂对象和聚合的职责分配给一个单独的对象,它并不承担领域模型中的职责,但是领域设计的一部份
  • 对于聚合来说,我们应该一次性的创建整个聚合,并且确保它的不变条件得到满足
  • 工厂只承担创建模型的工作,不具有其它领域行为
  • 一个含有工厂方法的聚合根的主要职责是完成它的聚合行为
  • 在聚合上使用工厂方法能更好的表达通用语言,这是使用构造函数所不能表达的

聚合根中的工厂方法

  • 聚合根中的工厂方法表现出了领域概念
  • 工厂方法可以提供守卫措施

领域服务中的工厂

  • 在集成限界上下文时,领域服务作为工厂
  • 领域服务的接口放在领域模型内,实现放在基础设施层

2.3.10.资源库【仓储】

是聚合的管理,仓储介于领域模型和数据模型之间,主要用于聚合的持久化和检索。它隔离了领域模型和数据模型,以便我们关注于领域模型而不需要考虑如何进行持久化。

我们将暂时不使用的领域对象从内存中持久化存储到磁盘中。当日后需要再次使用这个领域对象时,根据 key 值到数据库查找到这条记录,然后将其恢复成领域对象,应用程序就可以继续使用它了,这就是领域对象持久化存储的设计思想

2.3.11.事件模型

领域事件是一个领域模型中极其重要的部分,用来表示领域中发生的事件。忽略不相关的领域活动,同时明确领域专家要跟踪或希望被通知的事情,或与其他模型对象中的状态更改相关联

领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。

比如下订单后,给用户增长积分与赠送优惠券的需求。如果使用瀑布流的方式写代码。一个个逻辑调用,那么不同用户,赠送的东西不同,逻辑就会变得又臭又长。这里的比较好的方式是,用户下订单成功后,发布领域事件,积分聚合与优惠券聚合监听订单发布的领域事件进行处理。

2.4.DDD架构总览

2.4.1.架构图

严格分层架构:某层只能与直接位于的下层发生耦合。

松散分层架构:允许上层与任意下层发生耦合

依赖倒置原则

高层模块不应该依赖于底层模块,两者都应该依赖于抽象

抽象不应该依赖于实现细节,实现细节应该依赖于接口

简单的说就是面向接口编程。

按照DIP的原则,领域层就可以不再依赖于基础设施层,基础设施层通过注入持久化的实现就完成了对领域层的解耦,采用依赖注入原则的新分层架构模型就变成如下所示:

image-20210904145125083.png

从上往下

第一层为用户交互层,web请求,rpc请求,mq消息等外部输入均被视为外部输入的请求,可能修改到内部的业务数据。

第二层为业务应用层,与MVC中的service不同的不是,service中存储着大量业务逻辑。但在应用服务的实现中(以功能点为维度),它负责编排、转发、校验等。

第三层为领域层,聚合根是里面最高话事人。核心逻辑均在聚合根中体现【充血模型】,如果当前聚合根不能处理当前逻辑,需要其他聚合根的配合时,则在聚合根的外部包一层领域服务去实现逻辑。当然,理想的情况是不存在领域服务的。

第四层为基础设施层,为其他层提供技术实现支持

相信这里大家还看见了应用服务层直接调用仓储层的一条线,这条线是什么意思呢?

领域模型的建立是为了控制对于数据的增删改的业务边界,至于数据查询,不同的报表,不同的页面需要展示的数据聚合不具备强业务领域,因此常见的会使用CQRS方式进行查询逻辑的处理。

2.4.2.六边形架构(端口与适配器)

对于每一种外界类型,都有一个适配器与之对应。外界接口通过应用层api与内部进行交互。

对于右侧的端口与适配器,我们可以把资源库看成持久化的适配器。

image-20210904150651866.png

2.4.3.命令和查询职责分离–CQRS

  • 一个对象的一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据,声明为void。
  • 一个对象的一个方法如果返回了数据,该方法便是一个查询(Query),不应该通过直接或者间接的手段修改对象状态。
  • 聚合只有Command方法,没有Query方法。
  • 资源库只有add/save/fromId方法。
  • 领域模型一分为二,命令模型(写模型)和查询模型(读模型)。
  • 客户端和查询处理器 客户端:web浏览器、桌面应用等 查询处理器:一个只知道如何向数据库执行基本查询的简单组件,查询处理器不复杂,可以返回DTO或其它序列化的结果集,根据系统状态自定
  • 查询模型:一种非规范化的数据模型,并不反映领域行为,只用于数据显示
  • 客户端和命令处理器 聚合就是命令模型 命令模型拥有设计良好的契约和行为,将命令匹配到相应的契约是很直接的事情
  • 事件订阅器更新查询模型
  • 处理具有最终一致性的查询模型

2.4.4.事件驱动架构

落地指导与实践:DDD落地之事件驱动模型

  • 事件驱动架构可以融入六边型架构,融合的比较好,也可以融入传统分层架构
  • 管道和过滤器
  • 长时处理过程
1. 主动拉取状态检查:定时器和完成事件之间存在竞态条件可能造成失败
2. 被动检查,收到事件后检查状态记录是否超时。问题:如果因为某种原因,一直收不到事件就一直不过期
  • 事件源
1. 对于聚合的每次命令操作,都至少一个领域事 件发布出去,表示操作的执行结果
2. 每一个领域事件都将被保存到事件存储中
3. 从资源库获取聚合时,将根据发生在聚合上的 事件来重建聚合,事件的重放顺序与其产生顺序相同
4. 聚合快照:将聚合的某一事件发生时的状态快 照序列化存储下来。以减少重放事件时的耗时

三.落地分享

3.1.事件风暴

EventStorming则是一套Workshop(可以理解成一个类似于头脑风暴的工作坊)方法。DDD出现要比EventStorming早了10多年,而EventStorming的设计虽然参考了DDD的部分内容,但是并不是只为了DDD而设计的,是一套独立的通过协作基于事件还原系统全貌,从而快速分析复杂业务领域,完成领域建模的方法。

image-20210904152542121.png

针对老系统内的业务逻辑,根据以上方式进行业务逻辑聚合的划分

例如电商场景下购车流程进行事件风暴

image-20210904152737731.png

3.2.场景识别

事件风暴结束明确业务聚合后,进行场景识别与层级划分

image-20210904153035722.png

3.3.包模块划分

图片2.png

3.4.迁移说明

3.4.1.仓储层

在我们日常的代码中,使用Repository模式是一个很简单,但是又能得到很多收益的事情。最大的收益就是可以彻底和底层实现解耦,让上层业务可以快速自发展。

以目前逆向模型举例,现有

  • OrderDO
  • OrderDAO

可以通过以下几个步骤逐渐的实现Repository模式:

  1. 生成Order实体类,初期字段可以和OrderDO保持一致
  2. 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成
  3. 写单元测试,确保Order和OrderDO之间的转化100%正确
  4. 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性
  5. 将原有代码里使用了OrderDO的地方改为Order
  6. 将原有代码里使用了OrderDAO的地方都改为用OrderRepository
  7. 通过单测确保业务逻辑的一致性。

有一点要注意,目前我们用mybatis,dao操作都是含有业务含义的,正常的repository不应该有这种方法,目前repository中的含有业务含义的方法只是兼容方案,最终态都要干掉的。

极端DDD推崇者要求在repository中只存在save与byId两个聚合方法。这个当然需要根据实际业务场景来决定,但是还是建议仅保存这两个方法,其他业务需求查询聚合的方法单独开一个queryRepository实现不同数据的查询聚合与页面数据展示。保证数据的增删改入口唯一

3.4.2. 隔离三方依赖-adapter

思想和repository是一致的,以调用payApi为例:

  1. 在domain新建adapter包
  2. 新建PayAdapter接口
  3. 在infrastructure中定义adapter的实现,转换内部模型和外部模型,调用pay接口,返回内部模型dto
  4. 将原先业务中调用rpc的地方改成adapter
  5. 单测对比rpc和adapter,保证正确性

3.4.3. 抽离技术组件

同样是符合六边形架构的思想,把mqProducer,JsonUtil等技术组件,在domain定义接口,在infrastructure写实现,替换步骤和adapter类似。

3.4.4. 业务流程模块化-application

如果是用能力链的项目,能力链的service就可以是application。如果原先service中的业务逻辑混杂,甚至连参数组装都是在service中体现的。那么需要把逻辑归到聚合根中,当前聚合根无法完全包裹的,防止在领域模型中体现。在应用服务层中为能力链的体现。

3.4.5. CQRS参数显式化

能力链项目,定义command,query包,通过能力链来体现Command,Query,包括继承CommandService、QueryService,Po继承CommandPo,QueryPo

非能力链项目,在application定义command,query包,参数和类名要体现CQRS。

3.4.6. 战略设计-domain

重新设计聚合和实体,可能和现有模型有差异,如果模型差距不大,直接将能力点内的逻辑,迁移到实体中,

将原来调用repository的含业务含义的方法,换成save,同时删除含业务含义的方法,这个时候可以考虑用jpa替换mybatis,这里就看各个子域的选择了,如果用jpa的话 dao层可以干掉。至此,原biz里的大多数类已迁移完成。

四.迁移过程中可能存在的疑问

迁移过程中一定会存在或多或少不清楚的地方,这里我分享一下我在迁移的过程中遇到的问题。

image.png

1.领域服务与应用服务的实际应用场景区别

应用服务:可以理解为是各种方法的编排,不会处理任务业务逻辑,比如订单数修改,导致价格变动,这个逻辑体现在聚合根中,应用服务只负责调用。

领域服务:聚合根本身无法完全处理这个逻辑,例如支付这个步骤,订单聚合不可能支付,所以在订单聚合上架一层领域服务,在领域服务中实现支付逻辑。应用服务调用领域服务。

2.聚合根定义的业务边界是什么?

不以表结构数据进行业务逻辑的划分,一个业务体为一块业务。比如一个订单涉及商品,收货地址,发货地址,个人信息等等。以实体与值对象的方式在聚合内进行定义。

3.一个command修改一个聚合时,会关联修改到别的关联表,这个关联表算不算聚合

关联表不算聚合,算值对象

4.应用服务层如果调用rpc是否必须使用adapter

是的,必须使用,屏蔽外部依赖对于当前业务逻辑的影响。设想一下,你现在需要调用rpc接口,返回的字段有100,你要取其中50个字段。隔了一段时间,调用方改了接口逻辑的返回,数据被包含在实体内。而你调用这个接口的地方特别多,改动就很大了。但是如果有了适配器这一层,你只要定义本身业务需要的数据结构,剩下的业务不需要考虑,完全新人适配器可以将你想要的数据从rpc中加载到。

5.聚合根内部逻辑无法单独处理时,放到领域服务内的话,是否可以调用其他聚合根的领域服务或者应用服务,加入业务强绑定形式,聚合根内部如果需要调用service服务或者仓储时如何做。

可以这么做,但是逻辑要保证尽量内聚。

6.事件通知模式,比如是强绑定形式的,是否还是此种方式,还是与本聚合根无关的逻辑均走事件通知

强依赖形式的走逻辑编排,比如订单依赖支付结果进行聚合修改则走应用服务编排。订单支付后发送优惠券,积分等弱耦合方式走事件通知模式。

7.聚合根,PO,DTO,VO的限界

po是数据库表结构的一一对应。

dto是数据载体,贫血模型,仅对数据进行装载。

vo为dto结构不符合前端展示要求时的包装。

聚合根为一个或者多个po的聚合数据,当然不仅仅是po的组合,还有可能是值对象数据,充血模型,内聚核心业务逻辑处理。

8.查询逻辑单独开设一个repository,还是可以在聚合根的仓储中,划分的依据是什么

单独开设一个仓储。聚合根的仓储应该查询结果与save的参数均为聚合根,但是业务查询可能多样,展示给前端的数据也不一定都是聚合根的字段组成,并且查询不会对数据库造成不可逆的后果,因此单独开设查询逻辑处理,走CQRS模式。

9.返回的结果数据为多个接口组成,是否在应用服务层直接组合

不可以,需要定义一个assember类,单独对外部依赖的各种数据进行处理。

10.save方法做完delete,insert,update所有方法吗?

delete方法单独处理,可以增加一个delete方法,insert与update方法理论上是需要保持统一方法的。

11.查询逻辑如果涉及到修改聚合根怎么处理

简单查询逻辑直接走仓储,复杂逻辑走应用服务,在应用服务中进行聚合根数据修改。

12.逻辑处理的service放置在何处

如果为此种逻辑仅为某个聚合使用,则放置在对应的领域服务中,如果逻辑处理会被多个聚合使用,则将其单独定义一个service,作为一个工具类。

五.总结

本文对DDD做了一个不算深入的概念,架构的介绍。后对现在仍旧还是被最多使用的MVC三层架构迁移至DDD方案做了一个介绍,最后对可能碰到的一些细节疑问点做了问答。

当然不是所有的业务服务都合适做DDD架构,DDD合适产品化,可持续迭代,业务逻辑足够复杂的业务系统,中小规模的系统与团队还是不建议使用的,毕竟相比较与MVC架构,成本很大。

demo演示:DDD-demo

关于MVC分层的微服务架构博主在之前的文章中也给出过一些设计规范,感兴趣的大家可以去看看:

1.看完这篇,你就是架构师

2.求求你,别写祖传代码了

image.png

六.更多DDD学习资料

博客资料:

ThoughtWork DDD系列

张逸 DDD系列

欧创新 DDD系列

代码示例:

阿里COLA

github.com/citerus/ddd…

github.com/YaoLin1/ddd…

github.com/ddd-by-exam…

github.com/Sayi/ddd-ca…

七.特别鸣谢

lilpilot

八.联系我

文中如有不正确之处,欢迎指正,写文不易,点个赞吧,么么哒~

钉钉:louyanfeng25

微信:baiyan_lou

公众号:柏炎大叔

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

聊聊spring事务失效的12种场景,太坑了

发表于 2021-09-04

前言

对于从事java开发工作的同学来说,spring的事务肯定再熟悉不过了。

在某些业务场景下,如果一个请求中,需要同时写入多张表的数据。为了保证操作的原子性(要么同时成功,要么同时失败),避免数据不一致的情况,我们一般都会用到spring事务。

确实,spring事务用起来贼爽,就用一个简单的注解:@Transactional,就能轻松搞定事务。我猜大部分小伙伴也是这样用的,而且一直用一直爽。

但如果你使用不当,它也会坑你于无形。

今天我们就一起聊聊,事务失效的一些场景,说不定你已经中招了。不信,让我们一起看看。

最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。

BAT大佬写的刷题笔记,让我offer拿到手软

一 事务不生效

1.访问权限问题

众所周知,java的访问权限主要有四种:private、default、protected、public,它们的权限从左到右,依次变大。

但如果我们在开发过程中,把有某些事务方法,定义了错误的访问权限,就会导致事务功能出问题,例如:

1
2
3
4
5
6
7
8
9
java复制代码@Service
public class UserService {

@Transactional
private void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}

我们可以看到add方法的访问权限被定义成了private,这样会导致事务失效,spring要求被代理方法必须是public的。

说白了,在AbstractFallbackTransactionAttributeSource类的computeTransactionAttribute方法中有个判断,如果目标方法不是public,则TransactionAttribute返回null,即不支持事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
// Don't allow no-public methods as required.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}

// The method may be on an interface, but we need attributes from the target class.
// If the target class is null, the method will be unchanged.
Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);

// First try is the method in the target class.
TransactionAttribute txAttr = findTransactionAttribute(specificMethod);
if (txAttr != null) {
return txAttr;
}

// Second try is the transaction attribute on the target class.
txAttr = findTransactionAttribute(specificMethod.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}

if (specificMethod != method) {
// Fallback is to look at the original method.
txAttr = findTransactionAttribute(method);
if (txAttr != null) {
return txAttr;
}
// Last fallback is the class of the original method.
txAttr = findTransactionAttribute(method.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}
}
return null;
}

也就是说,如果我们自定义的事务方法(即目标方法),它的访问权限不是public,而是private、default或protected的话,spring则不会提供事务功能。

2. 方法用final修饰

有时候,某个方法不想被子类重新,这时可以将该方法定义成final的。普通方法这样定义是没问题的,但如果将事务方法定义成final,例如:

1
2
3
4
5
6
7
8
9
java复制代码@Service
public class UserService {

@Transactional
public final void add(UserModel userModel){
saveData(userModel);
updateData(userModel);
}
}

我们可以看到add方法被定义成了final的,这样会导致事务失效。

为什么?

如果你看过spring事务的源码,可能会知道spring事务底层使用了aop,也就是通过jdk动态代理或者cglib,帮我们生成了代理类,在代理类中实现的事务功能。

但如果某个方法用final修饰了,那么在它的代理类中,就无法重写该方法,而添加事务功能。

注意:如果某个方法是static的,同样无法通过动态代理,变成事务方法。

3.方法内部调用

有时候我们需要在某个Service类的某个方法中,调用另外一个事务方法,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Service
public class UserService {

@Autowired
private UserMapper userMapper;

//@Transactional
public void add(UserModel userModel) {
userMapper.insertUser(userModel);
updateStatus(userModel);
}

@Transactional
public void updateStatus(UserModel userModel) {
doSameThing();
}
}

我们看到在事务方法add中,直接调用事务方法updateStatus。从前面介绍的内容可以知道,updateStatus方法拥有事务的能力是因为spring aop生成代理了对象,但是这种方法直接调用了this对象的方法,所以updateStatus方法不会生成事务。

由此可见,在同一个类中的方法直接内部调用,会导致事务失效。

那么问题来了,如果有些场景,确实想在同一个类的某个方法中,调用它自己的另外一个方法,该怎么办呢?

3.1 新加一个Service方法

这个方法非常简单,只需要新加一个Service方法,把@Transactional注解加到新Service方法上,把需要事务执行的代码移到新方法中。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码@Servcie
public class ServiceA {
@Autowired
prvate ServiceB serviceB;

public void save(User user) {
queryData1();
queryData2();
serviceB.doSave(user);
}
}

@Servcie
public class ServiceB {

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}

}

3.2 在该Service类中注入自己

如果不想再新加一个Service类,在该Service类中注入自己也是一种选择。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Servcie
public class ServiceA {
@Autowired
prvate ServiceA serviceA;

public void save(User user) {
queryData1();
queryData2();
serviceA.doSave(user);
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}

可能有些人可能会有这样的疑问:这种做法会不会出现循环依赖问题?

答案:不会。

其实spring ioc内部的三级缓存保证了它,不会出现循环依赖问题。但有些坑,如果你想进一步了解循环依赖问题,可以看看我之前文章《spring:我是如何解决循环依赖的?》。

3.3 通过AopContent类

在该Service类中使用AopContext.currentProxy()获取代理对象

上面的方法2确实可以解决问题,但是代码看起来并不直观,还可以通过在该Service类中使用AOPProxy获取代理对象,实现相同的功能。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Servcie
public class ServiceA {

public void save(User user) {
queryData1();
queryData2();
((ServiceA)AopContext.currentProxy()).doSave(user);
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}

最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。

我以往的技术群里技术氛围非常不错,大佬很多。

image.png

加微信:su_san_java,备注:加群,即可加入该群。

4.未被spring管理

在我们平时开发过程中,有个细节很容易被忽略。即使用spring事务的前提是:对象要被spring管理,需要创建bean实例。

通常情况下,我们通过@Controller、@Service、@Component、@Repository等注解,可以自动实现bean实例化和依赖注入的功能。

当然创建bean实例的方法还有很多,有兴趣的小伙伴可以看看我之前写的另一篇文章《@Autowired的这些骚操作,你都知道吗?》

如果有一天,你匆匆忙忙的开发了一个Service类,但忘了加@Service注解,比如:

1
2
3
4
5
6
7
8
9
java复制代码//@Service
public class UserService {

@Transactional
public void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}

从上面的例子,我们可以看到UserService类没有加@Service注解,那么该类不会交给spring管理,所以它的add方法也不会生成事务。

5.多线程调用

在实际项目开发中,多线程的使用场景还是挺多的。如果spring事务用在多线程场景中,会有问题吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码@Slf4j
@Service
public class UserService {

@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
new Thread(() -> {
roleService.doOtherThing();
}).start();
}
}

@Service
public class RoleService {

@Transactional
public void doOtherThing() {
System.out.println("保存role表数据");
}
}

从上面的例子中,我们可以看到事务方法add中,调用了事务方法doOtherThing,但是事务方法doOtherThing是在另外一个线程中调用的。

这样会导致两个方法不在同一个线程中,获取到的数据库连接不一样,从而是两个不同的事务。如果想doOtherThing方法中抛了异常,add方法也回滚是不可能的。

如果看过spring事务源码的朋友,可能会知道spring的事务是通过数据库连接来实现的。当前线程中保存了一个map,key是数据源,value是数据库连接。

1
2
3
java复制代码private static final ThreadLocal<Map<Object, Object>> resources =

new NamedThreadLocal<>("Transactional resources");

我们说的同一个事务,其实是指同一个数据库连接,只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程,拿到的数据库连接肯定是不一样的,所以是不同的事务。

6.表不支持事务

周所周知,在mysql5之前,默认的数据库引擎是myisam。

它的好处就不用多说了:索引文件和数据文件是分开存储的,对于查多写少的单表操作,性能比innodb更好。

有些老项目中,可能还在用它。

在创建表的时候,只需要把ENGINE参数设置成MyISAM即可:

1
2
3
4
5
6
7
8
java复制代码CREATE TABLE `category` (
`id` bigint NOT NULL AUTO_INCREMENT,
`one_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`two_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`three_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`four_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

myisam好用,但有个很致命的问题是:不支持事务。

如果只是单表操作还好,不会出现太大的问题。但如果需要跨多张表操作,由于其不支持事务,数据极有可能会出现不完整的情况。

此外,myisam还不支持行锁和外键。

所以在实际业务场景中,myisam使用的并不多。在mysql5以后,myisam已经逐渐退出了历史的舞台,取而代之的是innodb。

有时候我们在开发的过程中,发现某张表的事务一直都没有生效,那不一定是spring事务的锅,最好确认一下你使用的那张表,是否支持事务。

7.未开启事务

有时候,事务没有生效的根本原因是没有开启事务。

你看到这句话可能会觉得好笑。

开启事务不是一个项目中,最最最基本的功能吗?

为什么还会没有开启事务?

没错,如果项目已经搭建好了,事务功能肯定是有的。

但如果你是在搭建项目demo的时候,只有一张表,而这张表的事务没有生效。那么会是什么原因造成的呢?

当然原因有很多,但没有开启事务,这个原因极其容易被忽略。

如果你使用的是springboot项目,那么你很幸运。因为springboot通过DataSourceTransactionManagerAutoConfiguration类,已经默默的帮你开启了事务。

你所要做的事情很简单,只需要配置spring.datasource相关参数即可。

但如果你使用的还是传统的spring项目,则需要在applicationContext.xml文件中,手动配置事务相关参数。如果忘了配置,事务肯定是不会生效的。

具体配置如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码   
<!-- 配置事务管理器 -->
<bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="transactionManager">
<property name="dataSource" ref="dataSource"></property>
</bean>
<tx:advice id="advice" transaction-manager="transactionManager">
<tx:attributes>
<tx:method name="*" propagation="REQUIRED"/>
</tx:attributes>
</tx:advice>
<!-- 用切点把事务切进去 -->
<aop:config>
<aop:pointcut expression="execution(* com.susan.*.*(..))" id="pointcut"/>
<aop:advisor advice-ref="advice" pointcut-ref="pointcut"/>
</aop:config>

默默的说一句,如果在pointcut标签中的切入点匹配规则,配错了的话,有些类的事务也不会生效。

二 事务不回滚

1.错误的传播特性

其实,我们在使用@Transactional注解时,是可以指定propagation参数的。

该参数的作用是指定事务的传播特性,spring目前支持7种传播特性:

  • REQUIRED 如果当前上下文中存在事务,那么加入该事务,如果不存在事务,创建一个事务,这是默认的传播属性值。
  • SUPPORTS 如果当前上下文存在事务,则支持事务加入事务,如果不存在事务,则使用非事务的方式执行。
  • MANDATORY 如果当前上下文中存在事务,否则抛出异常。
  • REQUIRES_NEW 每次都会新建一个事务,并且同时将上下文中的事务挂起,执行当前新建事务完成以后,上下文事务恢复再执行。
  • NOT_SUPPORTED 如果当前上下文中存在事务,则挂起当前事务,然后新的方法在没有事务的环境中执行。
  • NEVER 如果当前上下文中存在事务,则抛出异常,否则在无事务环境上执行代码。
  • NESTED 如果当前上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务。

如果我们在手动设置propagation参数的时候,把传播特性设置错了,比如:

1
2
3
4
5
6
7
8
9
java复制代码@Service
public class UserService {

@Transactional(propagation = Propagation.NEVER)
public void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}

我们可以看到add方法的事务传播特性定义成了Propagation.NEVER,这种类型的传播特性不支持事务,如果有事务则会抛异常。

目前只有这三种传播特性才会创建新事务:REQUIRED,REQUIRES_NEW,NESTED。

2.自己吞了异常

事务不会回滚,最常见的问题是:开发者在代码中手动try…catch了异常。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Slf4j
@Service
public class UserService {

@Transactional
public void add(UserModel userModel) {
try {
saveData(userModel);
updateData(userModel);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}

这种情况下spring事务当然不会回滚,因为开发者自己捕获了异常,又没有手动抛出,换句话说就是把异常吞掉了。

如果想要spring事务能够正常回滚,必须抛出它能够处理的异常。如果没有抛异常,则spring认为程序是正常的。

3.手动抛了别的异常

即使开发者没有手动捕获异常,但如果抛的异常不正确,spring事务也不会回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Slf4j
@Service
public class UserService {

@Transactional
public void add(UserModel userModel) throws Exception {
try {
saveData(userModel);
updateData(userModel);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new Exception(e);
}
}
}

上面的这种情况,开发人员自己捕获了异常,又手动抛出了异常:Exception,事务同样不会回滚。

因为spring事务,默认情况下只会回滚RuntimeException(运行时异常)和Error(错误),对于普通的Exception(非运行时异常),它不会回滚。

4.自定义了回滚异常

在使用@Transactional注解声明事务时,有时我们想自定义回滚的异常,spring也是支持的。可以通过设置rollbackFor参数,来完成这个功能。

但如果这个参数的值设置错了,就会引出一些莫名其妙的问题,例如:

1
2
3
4
5
6
7
8
9
10
java复制代码@Slf4j
@Service
public class UserService {

@Transactional(rollbackFor = BusinessException.class)
public void add(UserModel userModel) throws Exception {
saveData(userModel);
updateData(userModel);
}
}

如果在执行上面这段代码,保存和更新数据时,程序报错了,抛了SqlException、DuplicateKeyException等异常。而BusinessException是我们自定义的异常,报错的异常不属于BusinessException,所以事务也不会回滚。

即使rollbackFor有默认值,但阿里巴巴开发者规范中,还是要求开发者重新指定该参数。

这是为什么呢?

因为如果使用默认值,一旦程序抛出了Exception,事务不会回滚,这会出现很大的bug。所以,建议一般情况下,将该参数设置成:Exception或Throwable。

5.嵌套事务回滚多了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public class UserService {

@Autowired
private UserMapper userMapper;

@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
roleService.doOtherThing();
}
}

@Service
public class RoleService {

@Transactional(propagation = Propagation.NESTED)
public void doOtherThing() {
System.out.println("保存role表数据");
}
}

这种情况使用了嵌套的内部事务,原本是希望调用roleService.doOtherThing方法时,如果出现了异常,只回滚doOtherThing方法里的内容,不回滚 userMapper.insertUser里的内容,即回滚保存点。。但事实是,insertUser也回滚了。

why?

因为doOtherThing方法出现了异常,没有手动捕获,会继续往上抛,到外层add方法的代理方法中捕获了异常。所以,这种情况是直接回滚了整个事务,不只回滚单个保存点。

怎么样才能只回滚保存点呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Slf4j
@Service
public class UserService {

@Autowired
private UserMapper userMapper;

@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {

userMapper.insertUser(userModel);
try {
roleService.doOtherThing();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}

可以将内部嵌套事务放在try/catch中,并且不继续往上抛异常。这样就能保证,如果内部嵌套事务中出现异常,只回滚内部事务,而不影响外部事务。
最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。

我以往的技术群里技术氛围非常不错,大佬很多。

image.png

加微信:su_san_java,备注:加群,即可加入该群。

三 其他

1 大事务问题

在使用spring事务时,有个让人非常头疼的问题,就是大事务问题。

通常情况下,我们会在方法上@Transactional注解,填加事务功能,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码@Service
public class UserService {

@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {
query1();
query2();
query3();
roleService.save(userModel);
update(userModel);
}
}


@Service
public class RoleService {

@Autowired
private RoleService roleService;

@Transactional
public void save(UserModel userModel) throws Exception {
query4();
query5();
query6();
saveData(userModel);
}
}

但@Transactional注解,如果被加到方法上,有个缺点就是整个方法都包含在事务当中了。

上面的这个例子中,在UserService类中,其实只有这两行才需要事务:

1
2
java复制代码roleService.save(userModel);
update(userModel);

在RoleService类中,只有这一行需要事务:

1
java复制代码saveData(userModel);

现在的这种写法,会导致所有的query方法也被包含在同一个事务当中。

如果query方法非常多,调用层级很深,而且有部分查询方法比较耗时的话,会造成整个事务非常耗时,而从造成大事务问题。

关于大事务问题的危害,可以阅读一下我的另一篇文章《让人头痛的大事务问题到底要如何解决?》,上面有详细的讲解。

最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。

BAT大佬写的刷题笔记,让我offer拿到手软“)

2.编程式事务

上面聊的这些内容都是基于@Transactional注解的,主要说的是它的事务问题,我们把这种事务叫做:声明式事务。

其实,spring还提供了另外一种创建事务的方式,即通过手动编写代码实现的事务,我们把这种事务叫做:编程式事务。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码
@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
queryData1();
queryData2();
transactionTemplate.execute((status) => {
addData1();
updateData2();
return Boolean.TRUE;
})
}

在spring中为了支持编程式事务,专门提供了一个类:TransactionTemplate,在它的execute方法中,就实现了事务的功能。

相较于@Transactional注解声明式事务,我更建议大家使用,基于TransactionTemplate的编程式事务。主要原因如下:

  1. 避免由于spring aop问题,导致事务失效的问题。
  2. 能够更小粒度的控制事务的范围,更直观。

建议在项目中少使用@Transactional注解开启事务。但并不是说一定不能用它,如果项目中有些业务逻辑比较简单,而且不经常变动,使用@Transactional注解开启事务开启事务也无妨,因为它更简单,开发效率更高,但是千万要小心事务失效的问题。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【刷穿 LeetCode】剑指 Offer 10- I 斐

发表于 2021-09-04

题目描述

这是 LeetCode 上的 剑指 Offer 10- I. 斐波那契数列 ,难度为 简单。

Tag : 「动态规划」、「线性 DP」、「记忆化搜索」、「打表」、「矩阵快速幂」

写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))。斐波那契数列的定义如下:

  • F(0) = 0, F(1) = 1
  • F(N) = F(N - 1) + F(N - 2), 其中 N > 1.

斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。

答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。

示例 1:

1
2
3
ini复制代码输入:n = 2

输出:1

示例 2:

1
2
3
ini复制代码输入:n = 5

输出:5

提示:

  • 0 <= n <= 100

递推实现动态规划

既然转移方程都给出了,直接根据转移方程从头到尾递递推一遍即可。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Java复制代码class Solution {
int mod = (int)1e9+7;
public int fib(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
c %= mod;
a = b;
b = c;
}
return b;
}
}
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(1)O(1)O(1)

递归实现动态规划

能以「递推」形式实现动态规划,自然也能以「递归」的形式实现。

为防止重复计算,我们需要加入「记忆化搜索」功能,同时利用某个值 xxx 在不同的样例之间可能会作为“中间结果”被重复计算,并且计算结果 fib(x)fib(x)fib(x) 固定,我们可以使用 static 修饰缓存器,以实现计算过的结果在所有测试样例中共享。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
Java复制代码class Solution {
static int mod = (int)1e9+7;
static int N = 110;
static int[] cache = new int[N];
public int fib(int n) {
if (n <= 1) return n;
if (cache[n] != 0) return cache[n];
cache[n] = fib(n - 1) + fib(n - 2);
cache[n] %= mod;
return cache[n];
}
}
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(1)O(1)O(1)

打表

经过「解法二」,我们进一步发现,可以利用数据范围只有 100100100 进行打表预处理,然后直接返回。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java复制代码class Solution {
static int mod = (int)1e9+7;
static int N = 110;
static int[] cache = new int[N];
static {
cache[1] = 1;
for (int i = 2; i < N; i++) {
cache[i] = cache[i - 1] + cache[i - 2];
cache[i] %= mod;
}
}
public int fib(int n) {
return cache[n];
}
}
  • 时间复杂度:将打表逻辑放到本地执行,复杂度为 O(1)O(1)O(1);否则为 O(C)O(C)O(C),CCC 为常量,固定为 110110110
  • 空间复杂度:O(C)O(C)O(C)

矩阵快速幂

对于数列递推问题,可以使用矩阵快速幂进行加速,最完整的介绍在 这里 讲过。

对于本题,某个 f(n)f(n)f(n) 依赖于 f(n−1)f(n - 1)f(n−1) 和 f(n−2)f(n - 2)f(n−2),将其依赖的状态存成列向量:

[f(n−1)f(n−2)]\begin{bmatrix}
f(n - 1)\
f(n - 2)
\end{bmatrix}[f(n−1)f(n−2)​]
目标值 f(n)f(n)f(n) 所在矩阵为:

[f(n)f(n−1)]\begin{bmatrix}
f(n)\
f(n - 1)
\end{bmatrix}[f(n)f(n−1)​]
根据矩阵乘法,不难发现:

[f(n)f(n−1)]=[1110]∗[f(n−1)f(n−2)]\begin{bmatrix}
f(n)\
f(n - 1)
\end{bmatrix}

=

\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}

*

\begin{bmatrix}
f(n - 1)\
f(n - 2)
\end{bmatrix}[f(n)f(n−1)​]=[11​10​]∗[f(n−1)f(n−2)​]
我们令:

mat=[1110]mat =
\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}mat=[11​10​]
起始时,我们只有 [f(1)f(0)]\begin{bmatrix}
f(1)\
f(0)
\end{bmatrix}[f(1)f(0)​],根据递推式得:

[f(n)f(n−1)]=mat∗mat∗…∗mat∗[f(1)f(0)]\begin{bmatrix}
f(n)\
f(n - 1)
\end{bmatrix}

=

mat * mat * … * mat *

\begin{bmatrix}
f(1)\
f(0)
\end{bmatrix}[f(n)f(n−1)​]=mat∗mat∗…∗mat∗[f(1)f(0)​]
再根据矩阵乘法具有「结合律」,最终可得:

[f(n)f(n−1)]=matn−1∗[f(1)f(0)]\begin{bmatrix}
f(n)\
f(n - 1)
\end{bmatrix}

=

mat^{n - 1}

*

\begin{bmatrix}
f(1)\
f(0)
\end{bmatrix}[f(n)f(n−1)​]=matn−1∗[f(1)f(0)​]
计算 matn−1mat^{n - 1}matn−1 可以套用「快速幂」进行求解。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Java复制代码class Solution {
int mod = (int)1e9+7;
long[][] mul(long[][] a, long[][] b) {
int r = a.length, c = b[0].length, z = b.length;
long[][] ans = new long[r][c];
for (int i = 0; i < r; i++) {
for (int j = 0; j < c; j++) {
for (int k = 0; k < z; k++) {
ans[i][j] += a[i][k] * b[k][j];
ans[i][j] %= mod;
}
}
}
return ans;
}
public int fib(int n) {
if (n <= 1) return n;
long[][] mat = new long[][]{
{1, 1},
{1, 0}
};
long[][] ans = new long[][]{
{1},
{0}
};
int x = n - 1;
while (x != 0) {
if ((x & 1) != 0) ans = mul(mat, ans);
mat = mul(mat, mat);
x >>= 1;
}
return (int)(ans[0][0] % mod);
}
}
  • 时间复杂度:O(log⁡n)O(\log{n})O(logn)
  • 空间复杂度:O(1)O(1)O(1)

其他「打表」内容

题目 题解 难度 推荐指数
401. 二进制手表 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
1137. 第 N 个泰波那契数 LeetCode 题解链接 简单 🤩🤩🤩🤩
1646. 获取生成数组中的最大值 LeetCode 题解链接 简单 🤩🤩🤩🤩
面试题 10.02. 变位词组 LeetCode 题解链接 中等 🤩🤩🤩🤩

注:以上目录整理来自 wiki,任何形式的转载引用请保留出处。

其他「矩阵快速幂」内容

题目 题解 难度 推荐指数
552. 学生出勤记录 II LeetCode 题解链接 困难 🤩🤩🤩🤩
1137. 第 N 个泰波那契数 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩

注:以上目录整理来自 wiki,任何形式的转载引用请保留出处。

最后

这是我们「刷穿 LeetCode」系列文章的第 剑指 Offer 10- I 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。

在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。

为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour… 。

在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【手把手】MySQL教程0:上👍详细介绍Linux/Cent

发表于 2021-09-03

Linux下安装MySQL,环境是centos7,Ubuntu下的安装类似,只不是包管理命令是apt-get而已。

内容的介绍分为上下两篇:最新版本MySQL8.0的详细安装配置(上),以及5.5和10.x版本的MariaDB安装介绍(下),分两部分介绍。

MySQL 8 目前最新稳定版为 8.0.26 ,官方表示 MySQL 8 比 MySQL 5.7 快 2 倍,包含大量的改进和更快的性能!

删除系统默认或以前安装的MySQL/MariaDB

首先将系统默认的MySQL,或者以前安装的其他版本的MySQL删除。

下面的方法也是正确卸载/删除MySQL的方法。

rpm删除MySQL

最简便的方式,使用 rpm -q 查询已经安装的mysql相关的软件,使用 rpm -e 卸载软件包。

  1. (1) 使用shell命令循环查询所有 mysql 名称相关的软件,并依次卸载:
1
sh复制代码[root@VM_0_15_centos ~]# for i in $(rpm -qa|grep mysql);do rpm -e $i --nodeps;done

rpm -qa:-q查询,-a查询所有的软件包。

rpm -e 卸载包,-e是--erase的缩写(erase——擦除,清除);

--nodeps表示忽略依赖关系,通常结合--force一起使用,强制卸载。

  1. (2) 删除MariaDB。

CentOS 7.0以后,系统自带的数据库默认MariaDB。而MariaDB会和MySQL冲突。同样需要删除。

1
sh复制代码[root@VM_0_15_centos ~]# for i in $(rpm -qa|grep MariaDB);do rpm -e $i --nodeps;done
  1. 删除MySQL相关的目录(mysql软件目录和配置目录)【可选,不删除新的安装将使用旧的配置和数据库】

比起删除,更推荐重命名MySQL的软件和数据目录,见下一小节介绍。

1
sh复制代码[root@VM_0_15_centos ~]# rm -rf /var/lib/mysql && rm -rf /etc/my.cnf

非root用户执行时,需要使用 sudo 命令提升权限,如下:

1
2
3
4
5
6
> sh复制代码for i in $(rpm -qa|grep mysql);do sudo rpm -e $i --nodeps;done
> 或 for i in $(rpm -qa|grep MariaDB);do sudo rpm -e $i --nodeps;done
>
> sudo rm -rf /var/lib/mysql && sudo rm -rf /etc/my.cnf
>
>

使用 yum 包管理命令删除

使用yum remove等命令移除软件包就很方便。

  1. 使用 yum remove 删除:

为了删除所有的MySQL包及依赖,仍需要使用循环:

1
2
3
sh复制代码$ for i in $(rpm -qa|grep mysql);do sudo yum remove -y $i;done

或 for i in $(rpm -qa|grep MariaDB);do sudo yum remove -y $i;done
  1. 重命名mysql相关目录。

推荐重命名 /var/lib/mysql软件数据目录 和 /etc/my.cnf 配置目录文件,作为一个备份。

1
2
sh复制代码$ sudo mv /var/lib/mysql /var/lib/mysql_old_backup
$ sudo mv /etc/my.cnf /etc/my.cnf_old_backup

yum 安装 Mysql8.0

安装过程总体分为三步:

  1. 配置MySQL的yum仓库地址
  2. 安装
  3. 初始化配置

两种方式配置yum仓库地址

对于 MySQL8.0 的 yum 仓库地址的配置,下面提供两种方式:

  • 一种是MySQL官方提供的yum repo rpm包。
  • 另一种是配置国内镜像源(以 清华 镜像为例)

使用官方提供的配置yum仓库的rpm包

官方提供的 MySQL 配置yum仓库安装包的下载,如下:

等到官方的下载地址:repo.mysql.com//mysql80-co…

  • 使用 yum localinstall 安装【推荐】
1
sh复制代码$ sudo yum localinstall https://repo.mysql.com//mysql80-community-release-el7-3.noarch.rpm

由于是直接指定rpm包地址,此处也可以使用yum install。

  • 或先下载rpm包,然后使用 rpm -i 安装

如下,使用wget先下载。

1
2
3
4
5
6
7
8
9
10
11
sh复制代码$ wget -c https://repo.mysql.com//mysql80-community-release-el7-3.noarch.rpm
--2021-09-02 14:46:23-- https://repo.mysql.com//mysql80-community-release-el7-3.noarch.rpm
Resolving repo.mysql.com (repo.mysql.com)... 23.72.33.24
Connecting to repo.mysql.com (repo.mysql.com)|23.72.33.24|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 26024 (25K) [application/x-redhat-package-manager]
Saving to: ‘mysql80-community-release-el7-3.noarch.rpm’

100%[======================================>] 26,024 116KB/s in 0.2s

2021-09-02 14:46:24 (116 KB/s) - ‘mysql80-community-release-el7-3.noarch.rpm’ saved [26024/26024]

使用rpm -i安装(也可使用yum localinstall)

1
2
3
4
5
sh复制代码$ sudo rpm -ivh mysql80-community-release-el7-3.noarch.rpm
warning: mysql80-community-release-el7-3.noarch.rpm: Header V3 DSA/SHA1 Signature, key ID 5072e1f5: NOKEY
Preparing... ################################# [100%]
Updating / installing...
1:mysql80-community-release-el7-3 ################################# [100%]
  • 查看安装
1
2
sh复制代码$ rpm -q mysql80-community-release
mysql80-community-release-el7-3.noarch
  • 查看安装的 mysql-community 仓库源

使用清华镜像配置MySQL8.0仓库源

进入清华镜像的MySQL仓库地址:mirrors.tuna.tsinghua.edu.cn/mysql/yum/m…

可以找到对应的 mysql80-community-release-el7-3.noarch.rpm 包。

如下所示:

原本想演示 aliyun 的镜像源。但是 aliyun 现在的镜像源查找,真实是…一言难尽。

还是清华镜像的组织结构清晰,方便查找。比如上面截图,进入 mysql/yum/mysql80-community-el7/ 路径后,查找release的rpm包,可以看到,只有三个 release 包。找到mysql80。

下图可以看出,进入清华镜像源地址后,可以很方便的依照要安装的软件名,一级级找到自己想要的包。

执行安装仓库源的包:

1
sh复制代码$ sudo yum install https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql80-community-el7/mysql80-community-release-el7-3.noarch.rpm

安装MySQL

安装mysql-community-server

安装好仓库源后,就可以安装了。

1
sh复制代码$ sudo yum install -y mysql-community-server

启动mysqld

1
sh复制代码$ sudo systemctl start mysqld

开机运行mysqld

1
sh复制代码$ sudo systemctl enable mysqld

查看mysqld状态

1
sh复制代码$ sudo systemctl status mysqld

配置MySQL

查看MySQL的临时密码

MySQL默认安装时,会为 root 用户生成一个临时密码,这个密码只能使用一次,在初次登陆后,必须重置密码。

临时密码位于log目录下的 /var/log/mysqld.log 文件中,使用关键字 ‘temporary password’ 查找即可。

查看临时密码:

1
2
sh复制代码$ sudo grep 'temporary password' /var/log/mysqld.log
2021-09-03T09:11:28.993923Z 6 [Note] [MY-010454] [Server] A temporary password is generated for root@localhost: jus?>Y6kkd1d

mysql -p<临时密码>登陆

如下,mysql -p使用临时密码登陆。

注意 sudo 权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sh复制代码$ sudo mysql -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 9
Server version: 8.0.26

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> select version();
ERROR 1820 (HY000): You must reset your password using ALTER USER statement before executing this statement.

临时密码登陆后必须重置密码,否则无法执行任何语句。

通常使用mysql -uroot -p登陆,即指定用户名,默认不指定为root用户。

修改密码

修改 root 用户密码的语句为:ALTER USER 'root'@'localhost' IDENTIFIED BY 'xxxx';

密码策略验证

如下,由于安全策略的调整,默认无法为用户指定简单密码:

1
2
3
sql复制代码mysql> ALTER USER 'root'@'localhost' IDENTIFIED BY '123456';
ERROR 1819 (HY000): Your password does not satisfy the current policy requirements
mysql>

严重不推荐使用简单密码!

修改 validate_password_policy 配置

validate_password是MySQL的一个插件(在MySQL5.6引入,后续随着版本自动安装)。

validate_password_policy则是用于判断修改的密码是否符合当前的策略。

  • validate_password_policy的取值
Policy Tests Performed
0 or LOW Length
1 or MEDIUM Length; numeric, lowercase/uppercase, and special characters
2 or STRONG Length; numeric, lowercase/uppercase, and special characters; dictionary file

修改密码策略为低级别,且长度要求最低为1(实际为4,最小值为4)。

1
2
3
4
5
sql复制代码mysql> set global validate_password.policy=0;
Query OK, 0 rows affected (0.00 sec)

mysql> set global validate_password.length=1;
Query OK, 0 rows affected (0.00 sec)

完成密码修改

记得使用 flush 保存权限。

1
2
3
4
5
sql复制代码mysql> ALTER USER 'root'@'localhost' IDENTIFIED BY '123456';
Query OK, 0 rows affected (0.03 sec)

mysql> flush privileges;
Query OK, 0 rows affected (0.01 sec)

还可以直接使用 SET PASSWORD = '123456'; 设置密码:

1
2
3
4
> sql复制代码mysql> SET PASSWORD = '123456';
> Query OK, 0 rows affected (0.02 sec)
>
>

5.7.6版本以前的MySQL,可以使用 SET PASSWORD = PASSWORD('123456'); 设置登陆用户的密码。

PASSWORD() 在MySQL8中不支持。

退出并使用新密码重新登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sh复制代码mysql> exit
Bye
[root_test@VM_0_15_centos ~]$ sudo mysql -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 10
Server version: 8.0.26 MySQL Community Server - GPL

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> select version();
+-----------+
| version() |
+-----------+
| 8.0.26 |
+-----------+
1 row in set (0.00 sec)

MySQL中几个信息查看设置

查看端口

1
2
3
4
5
6
7
sql复制代码mysql> show variables like 'port';  -- 或 show global variables like 'port';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| port | 3306 |
+---------------+-------+
1 row in set (0.01 sec)

查看用户的授权信息

1
2
3
4
5
6
7
8
9
10
sql复制代码mysql> select host,user,authentication_string from mysql.user;
+-----------+------------------+------------------------------------------------------------------------+
| host | user | authentication_string |
+-----------+------------------+------------------------------------------------------------------------+
| localhost | mysql.infoschema | $A$005$THISISACOMBINATIONOFINVALIDSALTANDPASSWORDTHATMUSTNEVERBRBEUSED |
| localhost | mysql.session | $A$005$THISISACOMBINATIONOFINVALIDSALTANDPASSWORDTHATMUSTNEVERBRBEUSED |
| localhost | mysql.sys | $A$005$THISISACOMBINATIONOFINVALIDSALTANDPASSWORDTHATMUSTNEVERBRBEUSED |
| localhost | root | $A$005$ l[QL*} _GW4qyR6aL6uR3/CYdIvRHPZA60sgS.Iwo5Sn9qzoiF.QBNQ8 |
+-----------+------------------+------------------------------------------------------------------------+
4 rows in set (0.00 sec)

注:MySQL5.6及以下的版本,查看用户的登录信息,可以使用 select host,user,password from mysql.user;。

在5.7之后,password 字段改成了 authentication_string 。

validate_password 参数查看和设置

通过 show variables like 'validate_password%'; 查看密码验证的几个相关参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sql复制代码
mysql> set global validate_password.policy=0;
Query OK, 0 rows affected (0.00 sec)

mysql> set global validate_password.length=1;
Query OK, 0 rows affected (0.00 sec)

mysql> show variables like 'validate_password%';
+--------------------------------------+-------+
| Variable_name | Value |
+--------------------------------------+-------+
| validate_password.check_user_name | ON |
| validate_password.dictionary_file | |
| validate_password.length | 4 |
| validate_password.mixed_case_count | 1 |
| validate_password.number_count | 1 |
| validate_password.policy | LOW |
| validate_password.special_char_count | 1 |
+--------------------------------------+-------+
7 rows in set (0.00 sec)

如上,可以看到,设置 validate_password.length 为1,其值也会是4(最低长度)。

与 validate_password 相关的参数,都可以使用 set global validate_password.xxx设置。

其中,validate_password.number_count —— 密码中数字的最小个数;validate_password.mixed_case_count —— 大小写的最小个数;validate_password.special_char_count —— 特殊字符的最小个数;dictionary_file 表示字典文件

作为全局变量,又可以使用 @@validate_password.length 查看。

1
2
3
4
5
6
7
8
9
> sql复制代码mysql> select @@validate_password.length;
> +----------------------------+
> | @@validate_password.length |
> +----------------------------+
> | 4 |
> +----------------------------+
> 1 row in set (0.00 sec)
>
>

MySQL8.0中不支持 PASSWORD 函数

MySQL8.0中已经不支持 PASSWORD 函数,因此无法使用 password(‘xxx’) 设置密码。否则会报语法错误(ERROR 1064)。

MySQL5.7.5以后不再支持 PASSWORD 函数。

1
2
3
4
sql复制代码mysql> select password('123456');
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '('123456')' at line 1
mysql> SET PASSWORD = PASSWORD('123456');
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'PASSWORD('123456')' at line 1

关于修改密码的坑【注意】

密码的设置,实际是更新的 user 表中的 authentication_string 字段或 password 字段(MySQL5.7.6以前)。

但是,千万不要使用 UPDATE 语句直接赋值 authentication_string 更新用户的密码:update user set authentication_string = 'xxx' where user='root';,并执行 flush privileges; 刷新权限,这,将导致无法登陆MySQL。

因为,authentication_string 字段存储的是 sha2 加密后的字符串,直接赋值,将导致登录时验证,无法和加密后的密码对应。

但是,

可以使用 SET PASSWORD = 'xxx'; 极其方便的更改当前用户密码。

也可以使用 ALTER USER 'root' IDENTIFIED BY 'xxx'; 快速的修改指定用户的密码。

SET PASSWORD 中使用 FOR 关键字也可以指定用户:

1
sql复制代码SET PASSWORD FOR 'root'@'localhost' = 'xxx';

推荐及首选修改密码的语句是:ALTER USER ‘user_name’ IDENTIFIED BY ‘xxx’;

MySQL5.7.5及以前的版本,即 user 表中有 password 字段的版本,支持 password() 函数。则可以直接执行下面的 UPDATE 语句,更新密码:

1
2
3
> sql复制代码update user set password = password('123456') where user='root';
>
>

附:安装指定版本的MySQL

查看可用的MySQL软件包

1
sh复制代码yum repolist all | grep mysql

可以看到,默认安装的版本为 mysql80-community。

注:上面安装的 mysql80-community-release-el7-3.noarch.rpm 仓库源包,里面包含5.5、5.6、5.7版本的mysql-community-server。

安装指定版本的MySQL

如果想要安装 mysql57-community 版本,有两个办法:

一是修改 /etc/yum.repos.d/mysql-community.repo 配置文件,修改 enabled 参数【不推荐】。

二是,使用 yum-config-manager 命令配置。

  1. 使用 yum-config-manager --disable mysql80-community 取消 mysql80-community 的默认安装
  2. 使用 yum-config-manager --enable mysql57-community 设置 mysql57-community 成为yum的默认安装版本。

再次查看默认安装的版本:

此时,就可以安装 mysql57 版本的MySQL:

1
sh复制代码yum install -y mysql-community-server

远古时代(MySQL小于5.5版本),MySQL的安装包名为 mysql mysql-server。

如:yum install mysql mysql-server 或 yum remove mysql mysql-server

疑问:

如下,同样是使用 yum repolist all | grep mysql 查看mysql可用的包。

不知为何,!mysql80-community多了感叹号。暂时不知其含义。可正常安装。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

爱奇艺本地实时 Cache 方案

发表于 2021-09-03

高并发系统离不开 Cache,通过采用更多的本地 Cache 来提升系统吞吐量和稳定性是必然的,**这其中的最大难点就是解决分布式本地 Cache 数据的实时性和一致性问题,**否则本地 Cache 就无法更普遍应用于频繁变更的数据上。

没有最完美的方案,只有更合适的方案。本文将详细讲解爱奇艺 TV 后台分布式实时本地 Cache 实践方案,给大家解决高并发问题提供一个参考。

一、背景

目前互联网系统大多是读多写少的系统,面对读多写少的系统,大家一定会拆分为读系统、写系统,提高系统稳定性和吞吐量,今天我们所讲的 Cache 主要是面向读的系统。

爱奇艺拥有大量版权内容的 Metadata,在不同的业务场景都需要将这些 Metadata 和相应的关联数据作为基础数据返回,为了提高业务迭代速度,降低系统耦合,**我们将这些基础数据的组装环节拆分为独立的服务,该服务提供包括通用和个性化逻辑的封装,所有用到基础数据的地方调用此微服务。**每条数据大约几 KB 到十几 KB 不等,且数量不断增长。该服务作为基础服务对各子业务服务。

如何解决该服务对集中式 Cache 带来的高并发问题(高峰期支撑百万级 QPS):**比如集中式 Cache 内网带宽损耗、Cache 网络故障超时场景等问题。**以下提供众多解决方案中的一个解决方案。

二、思路 &方案

首先对比一下本地 Cache 与集中 Cache 的优缺点:

1.本地 Cache

其优点有:

(1)热点缓存,每扩容一个实例就相当于扩容了一个热点数据库;

(2)命中率高;

(3)过期策略;

(4)业务逻辑速度快,机器损耗低;

(5)抗风险能力强。

其缺点有:

(1)一般是被动缓存,实时性差;

(2)存储量有限,配备 2GB~4GB,足矣满足当前大部分场景的热点数据。

2.集中 Cache

其优点有:

(1)方便实时更新 Cache;

(2)缓存一致性强。

其缺点如下:

(1)集群过大,依赖过重;

(2)并发量高时,IO 过重;

(3)易受应用机器到缓存机器之间的网络抖动影响;

(4)热点 Key 访问量过大时,容易将带宽跑满,需要多 Cache 集群来解决。

对比如上优缺点,**大部分人都会采用本地热点 Cache,**本地存储 4GB 一般都能满足常见的业务热点数据,但是本地 Cache 实时性差,如何解决实时性?如下为解决方案:

3.解决方案

**通过统一的消息机制来触发本地 Cache 的实时更新,同时提供消息过滤机制,由业务方自行处理逻辑,这样可以实现本地 Cache 个性化更新方案。**方案如下:

4.方案说明

**(1)管理后台:**管理使用该本地 Cache 的所有应用实例及缓存策略;

(2)数据变更:数据变更源头;

**(3)消息总线:**消息的集散中心;

**(4)业务 Filter:**业务方可以自行处理部分消息;

**(5)监控统计:**采用爱奇艺统一的日志收集系统,可以用来统计分析热点数据,为其它热点方案提供数据支持,监控采用统一监控,监控各个实例的 Cache 命中等指标

三、扩展

当单个集群本地 Cache 命中率低于可接受临界值(比如 70%)时,内存受限无法扩大本地 Cache 存储,可拆分成轻逻辑数据分片集群,即可提高命中率。

四、效果总结

(1)有效降低了集群雪崩的风险;

(2)解决了高并发读的问题;

(3)减少热点数据的网络穿透,降低集中式 Cache 的负担。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

深入解析 Dubbo 30 服务端暴露全流程 背景 什么是

发表于 2021-09-03

简介: 随着云原生时代的到来,Dubbo 3.0 的一个很重要的目标就是全面拥抱云原生。正因如此,Dubbo 3.0 为了能够更好的适配云原生,将原来的接口级服务发现机制演进为应用级服务发现机制。

作者介绍

熊聘,Github账号pinxiong,Apache Dubbo贡献者,关注RPC、Service Mesh和云原生等领域。现任职于携程国际事业部研发团队,负责市场营销、云原生等相关工作。

背景

随着云原生时代的到来,Dubbo 3.0 的一个很重要的目标就是全面拥抱云原生。正因如此,Dubbo 3.0 为了能够更好的适配云原生,将原来的接口级服务发现机制演进为应用级服务发现机制。

基于应用级服务发现机制,Dubbo 3.0 能大幅降低框架带来的额外资源消耗,大幅提升资源利用率,主要体现在:

  • 单机常驻内存下降 75%
  • 能支持的集群实例规模以百万计的集群
  • 注册中心总体数据量下降超 90%

目前关于 Dubbo 服务端暴露流程的技术文章很多,但是都是基于 Dubbo 接口级服务发现机制来解读的。在 Dubbo 3.0 的应用级服务发现机制下,服务端暴露流程与之前有很大的变化,本文希望可以通过 对Dubbo 3.0 源码理解来解析服务端暴露全流程。

什么是应用级服务发现

简单来说,以前 Dubbo 是将接口的信息全部注册到注册中心,而一个应用实例一般会存在多个接口,这样一来注册的数据量就要大很多,而且有冗余。应用级服务发现的机制是同一个应用实例仅在注册中心注册一条数据,这种机制主要解决以下几个问题:

  • 对齐主流微服务模型,如:Spring Cloud
  • 支持 Kubernetes native service,Kubernetes 中维护调度的服务都是基于应用实例级,不支持接口级
  • 减少注册中心数据存储能力,降低了地址变更推送的压力

假设应用 dubbo-application 部署了 3 个实例(instance1, instance2, instance3),并且对外提供了 3 个接口(sayHello, echo, getVersion)分别设置了不同的超时时间。在接口级和应用级服务发现机制下,注册到注册中心的数据是截然不同的。如下图所示:

  • 接口级服务发现机制下注册中心中的数据

“sayHello”: [
{“application”:”dubbo-application”,”name”:”instance1”, “ip”:”127.0.0.1”, “metadata”:{“timeout”:1000}},
{“application”:”dubbo-application”,”name”:”instance2”, “ip”:”127.0.0.2”, “metadata”:{“timeout”:2000}},
{“application”:”dubbo-application”,”name”:”instance3”, “ip”:”127.0.0.3”, “metadata”:{“timeout”:3000}},
],
“echo”: [
{“application”:”dubbo-application”,”name”:”instance1”, “ip”:”127.0.0.1”, “metadata”:{“timeout”:1000}},
{“application”:”dubbo-application”,”name”:”instance2”, “ip”:”127.0.0.2”, “metadata”:{“timeout”:2000}},
{“application”:”dubbo-application”,”name”:”instance3”, “ip”:”127.0.0.3”, “metadata”:{“timeout”:3000}},
],
“getVersion”: [
{“application”:”dubbo-application”,”name”:”instance1”, “ip”:”127.0.0.1”, “metadata”:{“timeout”:1000}},
{“application”:”dubbo-application”,”name”:”instance2”, “ip”:”127.0.0.2”, “metadata”:{“timeout”:2000}},
{“application”:”dubbo-application”,”name”:”instance3”, “ip”:”127.0.0.3”, “metadata”:{“timeout”:3000}}
]

  • 应用级服务发现机制下注册中心中的数据

“dubbo-application”: [
{“name”:”instance1”, “ip”:”127.0.0.1”, “metadata”:{“timeout”:1000}},
{“name”:”instance2”, “ip”:”127.0.0.2”, “metadata”:{“timeout”:2000}},
{“name”:”instance3”, “ip”:”127.0.0.3”, “metadata”:{“timeout”:3000}}
]

通过对比我们可以发现,采用应用级服务发现机制确实使注册中心中的数据量减少了很多,那些原有的接口级的数据存储在元数据中心中。

服务端暴露全流程

引入应用级服务发现机制以后,Dubbo 3.0 服务端暴露全流程和之前有很大的区别。暴露服务端全流程的核心代码在 DubboBootstrap#doStart 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码private void doStart() {
// 1. 暴露Dubbo服务
exportServices();
// If register consumer instance or has exported services
if (isRegisterConsumerInstance() || hasExportedServices()) {
// 2. 暴露元数据服务
exportMetadataService();
// 3. 定时更新和上报元数据
registerServiceInstance();
....
}
......
}

假设以 Zookeeper 作为注册中,对外暴露 Triple 协议的服务为例,服务端暴露全流程时序图如下:

我们可以看到,整个的暴露流程还是挺复杂的,一共可以分为四个部分:

  • 暴露 injvm 协议的服务
  • 注册 service-discovery-registry 协议
  • 暴露 Triple 协议的服务并注册 registry 协议
  • 暴露 MetadataService 服务

下面会分别从这四个部分对服务暴露全流程进行详细讲解。

1、暴露 injvm 协议的服务

injvm 协议的服务是暴露在本地的,主要原因是在一个应用上往往既有 Service(暴露服务)又有 Reference(服务引用)的情况存在,并且 Reference 引用的服务就是在该应用上暴露的 Service。为了支持这种使用场景,Dubbo 提供了 injvm 协议,将 Service 暴露在本地,Reference 就可以不需要走网络直接在本地调用 Service。

整体时序图

由于这部分内容在之前的接口级服务发现机制中是类似的,所以相关的核心代码就不在这里展开讨论了。

2、注册 service-discovery-registry 协议

注册 service-discovery-registry 协议的核心目的是为了注册与服务相关的元数据,默认情况下元数据通过 InMemoryWritableMetadataService 将数据存储在本地内存和本地文件。

整体时序图

核心代码在 ServiceConfig#exportRemote 中,具体如下:

  • 注册 service-discovery-registry 协议的入口

private URL exportRemote(URL url, List registryURLs) {
if (CollectionUtils.isNotEmpty(registryURLs)) {
// 如果是多个注册中心,通过循环对每个注册中心进行注册
for (URL registryURL : registryURLs) {
// 判断是否是service-discovery-registry协议
// 将service-name-mapping参数的值设置为true
if (SERVICE_REGISTRY_PROTOCOL.equals(registryURL.getProtocol())) {
url = url.addParameterIfAbsent(SERVICE_NAME_MAPPING_KEY, “true”);
}
……
// 注册service-discovery-registry协议复用服务暴露流程
doExportUrl(registryURL.putAttribute(EXPORT_KEY, url), true);
}
……
return url;
}

  • invoker 中包装 Metadata

核心代码在 ServiceConfig#doExportUrl 中,具体如下:

1
2
3
4
5
6
7
8
9
10
ini复制代码private void doExportUrl(URL url, boolean withMetaData) {
Invoker<?> invoker = PROXY_FACTORY.getInvoker(ref, (Class) interfaceClass, url);
// 此时的withMetaData的值为true
// 将invoker包装成DelegateProviderMetaDataInvoker
if (withMetaData) {
invoker = new DelegateProviderMetaDataInvoker(invoker, this);
}
Exporter<?> exporter = PROTOCOL.export(invoker);
exporters.add(exporter);
}
  • 通过 RegistryProtocol 将 Invoker 转化成 Exporter

核心代码在 ProtocolListenerWrapper#export 中,具体如下:

1
2
3
4
5
6
7
swift复制代码public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {
// 此时的protocol为RegistryProtocol类型
if (UrlUtils.isRegistry(invoker.getUrl())) {
return protocol.export(invoker);
}
......
}
  • RegistryProtocol 将 Invoker 转化成 Exporter 的核心流程

核心代码在 RegistryProtocol#export 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
scss复制代码public <T> Exporter<T> export(final Invoker<T> originInvoker) throws RpcException {
URL registryUrl = getRegistryUrl(originInvoker);
URL providerUrl = getProviderUrl(originInvoker);
......
// 再次暴露Triple协议的服务
final ExporterChangeableWrapper<T> exporter = doLocalExport(originInvoker, providerUrl);

// registryUrl中包含service-discovery-registry协议
// 通过该协议创建ServiceDiscoveryRegistry对象
// 然后组合RegistryServiceListener监听器,
// 最后包装成ListenerRegistryWrapper对象
final Registry registry = getRegistry(registryUrl);
final URL registeredProviderUrl = getUrlToRegistry(providerUrl, registryUrl);

boolean register = providerUrl.getParameter(REGISTER_KEY, true);
if (register) {
// 注册service-discovery-registry协议
// 触发RegistryServiceListener的onRegister事件
register(registry, registeredProviderUrl);
}
......
// 触发RegistryServiceListener的onRegister事件
notifyExport(exporter);
return new DestroyableExporter<>(exporter);
}
  • 暴露 Triple 协议的服务

核心代码在 RegistryProtocol#doLocalExport 中,具体如下:

1
2
3
4
5
6
7
8
9
swift复制代码private <T> ExporterChangeableWrapper<T> doLocalExport(final Invoker<T> originInvoker, URL providerUrl) {
String key = getCacheKey(originInvoker);
// 此时的protocol为Triple协议的代理类
// 和暴露injvm协议的PROTOCOL相同
return (ExporterChangeableWrapper<T>) bounds.computeIfAbsent(key, s -> {
Invoker<?> invokerDelegate = new InvokerDelegate<>(originInvoker, providerUrl);
return new ExporterChangeableWrapper<>((Exporter<T>) protocol.export(invokerDelegate), originInvoker);
});
}
  • 注册service-discovery-registry协议

核心代码在 ServiceDiscoveryRegistry#register和ServiceDiscoveryRegistry#doRegister 中,具体如下:

1、ServiceDiscoveryRegistry#register

1
2
3
4
5
6
7
8
arduino复制代码public final void register(URL url) {
// 只有服务端(Provider)才需要注册
if (!shouldRegister(url)) {
return;
}
// 注册service-discovery-registry协议
doRegister(url);
}

2、ServiceDiscoveryRegistry#doRegister

1
2
3
4
5
6
7
8
9
10
11
12
13
less复制代码public void doRegister(URL url) {
url = addRegistryClusterKey(url);
// 注册元数据
if (writableMetadataService.exportURL(url)) {
if (logger.isInfoEnabled()) {
logger.info(format("The URL[%s] registered successfully.", url.toString()));
}
} else {
if (logger.isWarnEnabled()) {
logger.warn(format("The URL[%s] has been registered.", url.toString()));
}
}
}
  • 注册元数据

核心代码在 InMemoryWritableMetadataService#exportURL 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scss复制代码public boolean exportURL(URL url) {
// 如果是MetadataService,则不注册元数据
if (MetadataService.class.getName().equals(url.getServiceInterface())) {
this.metadataServiceURL = url;
return true;
}

updateLock.readLock().lock();
try {
String[] clusters = getRegistryCluster(url).split(",");
for (String cluster : clusters) {
MetadataInfo metadataInfo = metadataInfos.computeIfAbsent(cluster, k -> new MetadataInfo(ApplicationModel.getName()));
// 将Triple协议的服务中接口相关的数据生成ServiceInfo
// 将ServiceInfo注册到MetadataInfo中
metadataInfo.addService(new ServiceInfo(url));
}
metadataSemaphore.release();
return addURL(exportedServiceURLs, url);
} finally {
updateLock.readLock().unlock();
}
}
  • 发布 onRegister 事件

核心代码在 ListenerRegistryWrapper#register 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码public void register(URL url) {
try {
// registry为ServiceDiscoveryRegistry对象
// 此时已经调用完ServiceDiscoveryRegistry#registry方法
registry.register(url);
} finally {
if (CollectionUtils.isNotEmpty(listeners) && !UrlUtils.isConsumer(url)) {
RuntimeException exception = null;
for (RegistryServiceListener listener : listeners) {
if (listener != null) {
try {
// 注册完service-discovery-registry协议后发布onRegister事件
listener.onRegister(url, registry);
} catch (RuntimeException t) {
logger.error(t.getMessage(), t);
exception = t;
}
}
}
if (exception != null) {
throw exception;
}
}
}
}
  • 发布服务注册事件

核心代码在 RegistryProtocol#notifyExport 中,具体如下:

1
2
3
4
5
6
7
8
9
10
scss复制代码private <T> void notifyExport(ExporterChangeableWrapper<T> exporter) {
List<RegistryProtocolListener> listeners = ExtensionLoader.getExtensionLoader(RegistryProtocolListener.class)
.getActivateExtension(exporter.getOriginInvoker().getUrl(), "registry.protocol.listener");
if (CollectionUtils.isNotEmpty(listeners)) {
for (RegistryProtocolListener listener : listeners) {
// 发布RegistryProtocolListener的onExport事件
listener.onExport(this, exporter);
}
}
}

我们可以看出注册 service-discovery-registry 协议的核心目的是为了将服务的接口相关的信息存储在内存中。从兼容性和平滑迁移两方面来考虑,社区在实现的时候采取复用 ServiceConfig 的暴露流程的方式。

3、暴露Triple协议服务并注册registry协议

暴露 Triple 协议的服务并注册 registry 协议是 Dubbo 3.0 服务暴露的核心流程,一共分为两部分:

  • 暴露 Triple 协议的服务
  • 注册 registry 协议

由于暴露 Triple 协议服务的流程和暴露 Injvm 协议服务的流程是一致的,所以不再赘述。注册 registry 协议的过程仅仅注册了应用实例相关的信息,也就是之前提到的应用级服务发现机制。

整体时序图

  • 通过 InterfaceCompatibleRegistryProtocol 将 Invoker 转化成 Exporter

核心代码在 ProtocolListenerWrapper#export 中,具体如下:

1
2
3
4
5
6
7
8
swift复制代码public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {
// 此时的protocol为InterfaceCompatibleRegistryProtocol类型(继承了RegistryProtocol)
// 注意:在注册service-discovery-registry协议的时候protocol为RegistryProtocol类型
if (UrlUtils.isRegistry(invoker.getUrl())) {
return protocol.export(invoker);
}
......
}
  • RegistryProtocol 将 Invoker 转化成 Exporter 的核心流程

核心代码在 RegistryProtocol#export 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
scss复制代码public <T> Exporter<T> export(final Invoker<T> originInvoker) throws RpcException {
URL registryUrl = getRegistryUrl(originInvoker);
URL providerUrl = getProviderUrl(originInvoker);
......
// 再次暴露Triple协议的服务
final ExporterChangeableWrapper<T> exporter = doLocalExport(originInvoker, providerUrl);

// registryUrl中包含registry协议
// 通过该协议创建ZookeeperRegistry对象
// 然后组合RegistryServiceListener监听器,
// 最后包装成ListenerRegistryWrapper对象
// 注意:
// 1. service-discovery-registry协议对应的是ServiceDiscoveryRegistry
// 2. registry协议对应的是ZookeeperRegistry
final Registry registry = getRegistry(registryUrl);
final URL registeredProviderUrl = getUrlToRegistry(providerUrl, registryUrl);

boolean register = providerUrl.getParameter(REGISTER_KEY, true);
if (register) {
// 注册registry协议
// 触发RegistryServiceListener的onRegister事件
register(registry, registeredProviderUrl);
}
......
// 发布RegistryProtocolListener的onExport事件
notifyExport(exporter);
return new DestroyableExporter<>(exporter);
}
  • 注册 registry 协议

核心代码在 FailbackRegistry#register 和 ServiceDiscoveryRegistry#doRegister 中(ZookeeperRegistry 继承 FailbackRegistry)中,具体如下:

1、FailbackRegistry#register

1
2
3
4
5
6
7
8
9
10
11
scss复制代码public void register(URL url) {
if (!acceptable(url)) {
......
try {
// 注册registry协议
doRegister(url);
} catch (Exception e) {
......
}
}
}

2、ServiceDiscoveryRegistry#doRegister

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ruby复制代码public void doRegister(URL url) {
try {
// 在zookeeper上注册Provider
// 目录:/dubbo/xxxService/providers/***
// 数据:dubbo://192.168.31.167:20800/xxxService?anyhost=true&
// application=application-name&async=false&deprecated=false&dubbo=2.0.2&
// dynamic=true&file.cache=false&generic=false&interface=xxxService&
// metadata-type=remote&methods=hello&pid=82470&release=&
// service-name-mapping=true&side=provider&timestamp=1629588251493
zkClient.create(toUrlPath(url), url.getParameter(DYNAMIC_KEY, true));
} catch (Throwable e) {
throw new RpcException("Failed to register " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
}
}
  • 订阅地址变更

核心代码在 FailbackRegistry#subscribe 和 ZookeeperRegistry#doSubscribe 中,具体如下:

1、FailbackRegistry#subscribe

1
2
3
4
5
6
7
8
typescript复制代码public void subscribe(URL url, NotifyListener listener) {
......
try {
// 调用ZookeeperRegistry#doSubscribe
doSubscribe(url, listener);
} catch (Exception e) {
......
}

2、ZookeeperRegistry#doSubscribe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
arduino复制代码public void doSubscribe(final URL url, final NotifyListener listener) {
try {
if (ANY_VALUE.equals(url.getServiceInterface())) {
......
} else {
......
for (String path : toCategoriesPath(url)) {
ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
ChildListener zkListener = listeners.computeIfAbsent(listener, k -> new RegistryChildListenerImpl(url, path, k, latch));
if (zkListener instanceof RegistryChildListenerImpl) {
((RegistryChildListenerImpl) zkListener).setLatch(latch);
}
// 创建临时节点用来存储configurators数据
// 目录:/dubbo/xxxService/configurators
// 数据:应用的配置信息,可以在dubbo-admin中进行修改,默认为空
zkClient.create(path, false);
// 添加监听器,用来监听configurators中的变化
List<String> children = zkClient.addChildListener(path, zkListener);
if (children != null) {
urls.addAll(toUrlsWithEmpty(url, path, children));
}
}
......
}
} catch (Throwable e) {
......
}
}
  • 建立暴露的 Triple 协议服务与 Metadata 之间的联系

核心代码在 ServiceConfig#exportUrl、MetadataUtils#publishServiceDefinition、InMemoryWritableMetadataService#publishServiceDefinition、RemoteMetadataServiceImpl#publishServiceDefinition 和 MetadataReport#storeProviderMetadata 中,具体如下:

1、ServiceConfig#exportUrl

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码private void exportUrl(URL url, List<URL> registryURLs) {
......
if (!SCOPE_NONE.equalsIgnoreCase(scope)) {
......
if (!SCOPE_LOCAL.equalsIgnoreCase(scope)) {
url = exportRemote(url, registryURLs);
// 发布事件,更新服务接口相关的数据
MetadataUtils.publishServiceDefinition(url);
}
}
......
}

2、MetadataUtils#publishServiceDefinition

1
2
3
4
5
6
7
8
scss复制代码public static void publishServiceDefinition(URL url) {
// 将服务接口相关的数据存在到InMemoryWritableMetadataService中
WritableMetadataService.getDefaultExtension().publishServiceDefinition(url);
// 将服务接口相关的数据存在到远端的元数据中心
if (REMOTE_METADATA_STORAGE_TYPE.equalsIgnoreCase(url.getParameter(METADATA_KEY))) {
getRemoteMetadataService().publishServiceDefinition(url);
}
}

3、InMemoryWritableMetadataService#publishServiceDefinition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
java复制代码public void publishServiceDefinition(URL url) {
try {
String interfaceName = url.getServiceInterface();
if (StringUtils.isNotEmpty(interfaceName)
&& !ProtocolUtils.isGeneric(url.getParameter(GENERIC_KEY))) {
Class interfaceClass = Class.forName(interfaceName);
ServiceDefinition serviceDefinition = ServiceDefinitionBuilder.build(interfaceClass);
Gson gson = new Gson();
String data = gson.toJson(serviceDefinition);
// 存储服务接口相关数据
// 数据格式:
// {
// "canonicalName": "xxxService",
// "codeSource": "file:/Users/xxxx",
// "methods": [{
// "name": "hello",
// "parameterTypes": ["java.lang.String"],
// "returnType": "java.lang.String",
// "annotations": []
// }],
// "types": [{
// "type": "java.lang.String"
// }],
// "annotations": []
// }
serviceDefinitions.put(url.getServiceKey(), data);
return;
} else if (CONSUMER_SIDE.equalsIgnoreCase(url.getParameter(SIDE_KEY))) {
......
}
......
} catch (Throwable e) {
......
}
}

4、RemoteMetadataServiceImpl#publishServiceDefinition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
scss复制代码public void publishServiceDefinition(URL url) {
checkRemoteConfigured();
String side = url.getSide();
if (PROVIDER_SIDE.equalsIgnoreCase(side)) {
// 发布服务端(Provider)的服务接口信息到元数据中心
publishProvider(url);
} else {
......
}
}

RemoteMetadataServiceImpl#publishProvider

private void publishProvider(URL providerUrl) throws RpcException {
......
try {
String interfaceName = providerUrl.getServiceInterface();
if (StringUtils.isNotEmpty(interfaceName)) {
......
for (Map.Entry<String, MetadataReport> entry : getMetadataReports().entrySet()) {
// 获取MetadataReport服务,该服务用来访问元数据中心
MetadataReport metadataReport = entry.getValue();
// 将服务接口信息存储到元数据中心
metadataReport.storeProviderMetadata(new MetadataIdentifier(providerUrl.getServiceInterface(),
providerUrl.getVersion(), providerUrl.getGroup(),
PROVIDER_SIDE, providerUrl.getApplication()), fullServiceDefinition);
}
return;
}
......
} catch (ClassNotFoundException e) {
......
}
}

5、AbstractMetadataReport#storeProviderMetadata

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
perl复制代码public void storeProviderMetadata(MetadataIdentifier providerMetadataIdentifier, ServiceDefinition serviceDefinition){
if (syncReport) {
storeProviderMetadataTask(providerMetadataIdentifier, serviceDefinition);
} else {
// 异步存储到元数据中心
reportCacheExecutor.execute(() -> storeProviderMetadataTask(providerMetadataIdentifier, serviceDefinition));
}
}

private void storeProviderMetadataTask(MetadataIdentifier providerMetadataIdentifier, ServiceDefinition serviceDefinition) {
try {
......
allMetadataReports.put(providerMetadataIdentifier, serviceDefinition);
failedReports.remove(providerMetadataIdentifier);
Gson gson = new Gson();
// data的数据格式:
// {
// "parameters": {
// "side": "provider",
// "interface": "xxxService",
// "metadata-type": "remote",
// "service-name-mapping": "true",
// },
// "canonicalName": "xxxService",
// "codeSource": "file:/Users/xxxx",
// "methods": [{
// "name": "hello",
// "parameterTypes": ["java.lang.String"],
// "returnType": "java.lang.String",
// "annotations": []
// }],
// "types": [{
// "type": "java.lang.String"
// }],
// "annotations": []
// }
String data = gson.toJson(serviceDefinition);
// 存储到元数据中心,实例中的元数据中心是ZookeeperMetadataReport
// 目录:元数据中心Metadata-report的/dubbo/metadata/xxxService/provider/${application-name}节点下
doStoreProviderMetadata(providerMetadataIdentifier, data);
// 存储到本地文件
// 路径:xxxService:::provider:${application-name}
saveProperties(providerMetadataIdentifier, data, true, !syncReport);
} catch (Exception e) {
......
}
}
  • 建立 Triple 协议服务与 MetadataReport 服务之间的关系

核心代码在 ServiceConfig#exported、MetadataServiceNameMapping#map 和 ZookeeperMetadataReport#registerServiceAppMapping 中,具体如下:

1、ServiceConfig#exported

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码protected void exported() {
exported = true;
List<URL> exportedURLs = this.getExportedUrls();
exportedURLs.forEach(url -> {
// 判断URL中是否标记有service-name-mapping的字段
// 标记有该字段的服务是需要将暴露的服务与元数据中心关联起来
// Consumer可以通过元数据中心的消息变更感知到Provider端元数据的变更
if (url.getParameters().containsKey(SERVICE_NAME_MAPPING_KEY)) {
ServiceNameMapping serviceNameMapping = ServiceNameMapping.getDefaultExtension();
// 建立关系
serviceNameMapping.map(url);
}
});
onExported();
}

2、MetadataServiceNameMapping#map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ini复制代码public void map(URL url) {
execute(() -> {
String registryCluster = getRegistryCluster(url);
// 获取MetadataReport,也就是元数据中心的访问路径
MetadataReport metadataReport = MetadataReportInstance.getMetadataReport(registryCluster);
......
int currentRetryTimes = 1;
boolean success;
String newConfigContent = getName();
do {
// 获取元数据中心中存储的应用的版本信息
ConfigItem configItem = metadataReport.getConfigItem(serviceInterface, DEFAULT_MAPPING_GROUP);
String oldConfigContent = configItem.getContent();
if (StringUtils.isNotEmpty(oldConfigContent)) {
boolean contains = StringUtils.isContains(oldConfigContent, getName());
if (contains) {
break;
}
newConfigContent = oldConfigContent + COMMA_SEPARATOR + getName();
}
// 在元数据中心创建mapping节点,并将暴露的服务数据存到元数据中心,这里的元数据中心用zookeeper实现的
// 目录:/dubbo/mapping/xxxService
// 数据:configItem.content为${application-name},configItem.ticket为版本好
success = metadataReport.registerServiceAppMapping(serviceInterface, DEFAULT_MAPPING_GROUP, newConfigContent, configItem.getTicket());
} while (!success && currentRetryTimes++ <= CAS_RETRY_TIMES);
});
}

3、ZookeeperMetadataReport#registerServiceAppMapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typescript复制代码public boolean registerServiceAppMapping(String key, String group, String content, Object ticket) {
try {
if (ticket != null && !(ticket instanceof Stat)) {
throw new IllegalArgumentException("zookeeper publishConfigCas requires stat type ticket");
}
String pathKey = buildPathKey(group, key);
// 1. 创建/dubbo/mapping/xxxService目录,存储的数据为configItem
// 2. 生成版本号
zkClient.createOrUpdate(pathKey, content, false, ticket == null ? 0 : ((Stat) ticket).getVersion());
return true;
} catch (Exception e) {
logger.warn("zookeeper publishConfigCas failed.", e);
return false;
}
}

到这里,暴露Triple协议的服务并注册 registry 协议的流程就结束了。主要是将以前接口级服务发现机制中注册到注册中心中的数据(应用实例数据+服务接口数据)拆分出来了。注册 registry 协议部分将应用实例数据注册到注册中心,在 Exporter 暴露完以后通过调用 MetadataUtils#publishServiceDefinition 将服务接口数据注册到元数据中心。

4、暴露MetadataService服务

MetadataService 主要是对 Consumer 侧提供一个可以获取元数据的 API,暴露流程是复用了 Triple 协议的服务暴露流程

整体时序图

  • 暴露 MetadataService 的入口

核心代码在 DubboBootstrap#exportMetadataService 中,具体如下:

1
2
3
4
csharp复制代码private void exportMetadataService() {
// 暴露MetadataServer
metadataServiceExporter.export();
}
  • 暴露 MetadataService

核心代码在 ConfigurableMetadataServiceExporter#export 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scss复制代码public ConfigurableMetadataServiceExporter export() {

if (!isExported()) {
// 定义MetadataService的ServiceConfig
ServiceConfig<MetadataService> serviceConfig = new ServiceConfig<>();
serviceConfig.setApplication(getApplicationConfig());
// 不会注册到注册中心
serviceConfig.setRegistry(new RegistryConfig("N/A"));
serviceConfig.setProtocol(generateMetadataProtocol());
serviceConfig.setInterface(MetadataService.class);
serviceConfig.setDelay(0);
serviceConfig.setRef(metadataService);
serviceConfig.setGroup(getApplicationConfig().getName());
serviceConfig.setVersion(metadataService.version());
serviceConfig.setMethods(generateMethodConfig());
// 用暴露Triple协议服务的流程来暴露MetadataService
// 采用的是Dubbo协议
serviceConfig.export();
this.serviceConfig = serviceConfig;
}
return this;
}

由于暴露 MetadataService 的流程是复用前面提到的暴露 Triple 协议服务的流程,整个过程有少许地方会不同,这些不同之处在上面的代码中都已经标明,所以就不再赘述了。

  • 注册 ServiceInstance 实例

注册 ServiceInstance 的目的是为了定时更新 Metadata,当有更新的时候就会通过 MetadataReport 来更新版本号让 Consumer 端感知到。

核心代码在 DubboBootstrap#registerServiceInstance 和 DubboBootstrap#doRegisterServiceInstance 中,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
scss复制代码private void registerServiceInstance() {
....
// 创建ServiceInstance
// ServiceInstance中包含以下字段
// 1. serviceName:${application-name}
// 2. host: 192.168.31.167
// 3. port: 2080
// 4. metadata: 服务接口级相关的数据,比如:methods等数据
// 同时,还会对ServiceInstance数据中的字段进行补充,分别调用下面4个ServiceInstanceCustomizer实例
// 1)ServiceInstanceMetadataCustomizer
// 2)MetadataServiceURLParamsMetadataCustomizer
// 3)ProtocolPortsMetadataCustomizer
// 4)ServiceInstanceHostPortCustomizer
ServiceInstance serviceInstance = createServiceInstance(serviceName);
boolean registered = true;
try {
// 注册ServiceInstance
doRegisterServiceInstance(serviceInstance);
} catch (Exception e) {
registered = false;
logger.error("Register instance error", e);
}
// 如果注册成功,定时更新Metadata,没10s更新一次
if(registered){
executorRepository.nextScheduledExecutor().scheduleAtFixedRate(() -> {
......
try {
// 刷新Metadata和ServiceInstance
ServiceInstanceMetadataUtils.refreshMetadataAndInstance(serviceInstance);
} catch (Exception e) {
......
} finally {
......
}
}, 0, ConfigurationUtils.get(METADATA_PUBLISH_DELAY_KEY, DEFAULT_METADATA_PUBLISH_DELAY), TimeUnit.MILLISECONDS);
}
}

DubboBootstrap#doRegisterServiceInstance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
scss复制代码private void doRegisterServiceInstance(ServiceInstance serviceInstance) {
if (serviceInstance.getPort() > 0) {
// 发布Metadata数据到远端存储元数据中心
// 调用RemoteMetadataServiceImpl#publishMetadata,
// 内部会调用metadataReport#publishAppMetadata
publishMetadataToRemote(serviceInstance);
logger.info("Start registering instance address to registry.");
getServiceDiscoveries().forEach(serviceDiscovery ->{
ServiceInstance serviceInstanceForRegistry = new DefaultServiceInstance((DefaultServiceInstance) serviceInstance);
calInstanceRevision(serviceDiscovery, serviceInstanceForRegistry);
......
// 调用ZookeeperServiceDiscovery#doRegister注册serviceInstance实例
// 将应用服务信息注册到注册中心中
// 目录:/services/${application-name}/192.168.31.167:20800
// 数据:serviceInstance序列化后的byte数组
serviceDiscovery.register(serviceInstanceForRegistry);
});
}
}

通过上面的分析,我们可以很容易知道

  • ServiceInstance 是中包含 Metadata
  • Metadata 是存储在 InMemoryWritableMetadataService 中的元数据,占用的是本地内存空间
  • InMemoryWritableMetadataService 用来更新 Metadata
  • ServiceInstance 是存储在远端元数据注册中心中的数据结构
  • RemoteMetadataServiceImpl 会调用 metadataReport 将 ServiceInstance 数据更新到远端元数据注册中心

总结

通过对 Dubbo 3.0 服务端暴露全流程的解析可以看出,尽管应用级服务发现机制的实现要复杂很多,但是 Dubbo 3.0 为了能够让使用者平滑迁移,兼容了 2.7.x 的版本,所以在设计的时候很多地方都尽可能复用之前的流程。

从最近 Dubbo 3.0 发布的 Benchmark 数据来看,Dubbo 3.0 的性能和资源利用上确实提升了不少。Dubbo 3.0 在拥抱云原生的道路上还有很长的一段路要走,社区正在对 Dubbo 3.0 中核心流程进行梳理和优化,后续计划支持多实例应用部署,希望有兴趣见证 Dubbo 云原生之路的同学可以积极参与社区贡献!

原文链接

本文为阿里云原创内容,未经允许不得转载。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…539540541…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%