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

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


  • 首页

  • 归档

  • 搜索

Redis使用setnx实现分布式锁及其问题、优化

发表于 2020-11-30

最近在工作中用到了分布式锁,然后查了很多分布式锁的实现方式。比较熟悉redis或者说,redis的用法比较简单,所以查了一下redis使用setnx实现分布式锁的方式。其中有一篇文章搜索到的次数最多,多到我不知道哪个是原创文章,就贴一下看到的链接吧blog.csdn.net/lihao21/art…

1
2
3
python复制代码reids > setnx(key,value)  //设置key.

redis > delete(key) //将key删除

当key存在的时候,设置会失败,返回-1。当key不存在的时候,设置成功,返回0。

实现方式一:

1
2
3
4
5
6
7
8
9
10
11
12
python复制代码lock_key = "distribute_lock"
def get_lock():
while True:
lock = redis_client.setnx(lock_key,1)
if lock: # 设置成功
break
else:
time.sleep(0.5) #如果没有获取成功,等待0.5继续获取,当然这个地方你也可以设置重试次数。
return True
if get_lock():
# do your work,处理临界资源
redis_client.delete(lock_key) //为防止死锁,处理完临界资源要及时释放锁。

可以看一下这个代码有什么问题?

问题出在了,最后锁的释放,假如在处理临界资源的过程中,进程挂掉了或者在执行删除操作的时候redis链接断掉了,那么这个分布式锁将永远得不到释放,进而产生死锁。所以接下来的优化是如果进程挂掉了,能够及时的释放锁,你想到了什么?超时机制。

实现方式二:

为了避免出现方式一的问题,所以加入了超时机制。

1
scss复制代码setnx(key, <current Unix time + lock timeout + 1>)

本质就是将key的value值设置成一个超时时间,按照方式一流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python复制代码lock_key = "distribute_lock"
def get_lock():
while True:
lock = redis_client.setnx(lock_key,<now_time+ lock timeout + 1>) # 直接设置成功
if lock: # 设置成功
break
now_time = <current Unix time + lock timeout + 1>
lock_time_out = redis.client.get(lock_key)
if now_time > lock_time_out: #检查是否超时
# 如果已经超时
redis_client.delete(lock_key) # 将这个key删除,进行重新设置。
lock = redis_client.setnx(lock_key,<now_time+ lock timeout + 1>)
if lock: # 设置成功
break
else:
time.sleep(0.5) #如果没有获取成功,等待0.5继续获取,当然这个地方你也可以设置重试次数。
return True
if get_lock():
# do your work,处理临界资源
redis_client.delete(lock_key) //为防止死锁,处理完临界资源要及时释放锁。

继续思考一下,这又会出现什么问题?

  1. 假如进程p1获取到了锁,同时进程p2和p3在不断的检测锁是否已经超时。
  2. 然后p1进程挂掉了,没有及时删除锁。
  3. 过了一段时间,p2和p3同时检测到了这个锁已经超时,即程序中now_time > lock_time_out都成立。
  4. 首先p2将锁删除了,然后将锁设置了超时时间,致此p2获取到了锁。
  5. p3也检测到了锁超时了,只不过它执行速度比慢,直接将锁删除了,实际上p3删除的是p2设置的锁,这个时候问题就出现了,p3和p2都获取到了锁。

实现方式三

实现方式二的关键问题是什么?是p3在删除锁的时候,没有检查是否又有新的进程获取到了该锁。
为了避免这种情况,p3在执行set操作的时候,用这个命令:

1
python复制代码getset(lock_key,now_time+lock timeout + 1)

这个命令会返回旧,然后设置成新的值。在set之前判断一下当前时间是否大于lock_key的旧值。如果大于,说明已经超时,获取到了锁。假如再次出现上面的情况,p2和p3同时检测到锁的超时,然后p2删除、并获取到了锁。p3执行getset操作,然后当前时间和lock_key的旧值(p2设置的)比较,当前时间小于旧值。获取锁失败,继续下一轮的等待。

其次最后删除的时候,也不能像前几次一样直接删除。要先判断一下,当前时间小于锁的超时时间的话在删除。避免删除其他进程设置的锁。程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
python复制代码def get_lock():
LOCK_TIMEOUT = 3
lock = 0
lock_timeout = 0
lock_key = 'distribute_lock'

# 获取锁
while lock != 1:
now = int(time.time())
lock_timeout = now + LOCK_TIMEOUT + 1
lock = redis_client.setnx(lock_key, lock_timeout)
if lock == 1 or (now > int(redis_client.get(lock_key))) and now > int(redis_client.getset(lock_key, lock_timeout)):
break
else:
time.sleep(0.001)
if get_lock():
# dou your lock.处理临界资源()

# 释放锁
now = int(time.time())
if now < lock_timeout:
redis_client.delete(lock_key)

继续看,有什么问题?

  1. 为什么要+1 current Unix time + lock timeout + 1 ?

+1是因为在 Redis 2.4 版本中,过期时间的延迟在 1 秒钟之内 —— 也即是,就算 key 已经过期,但它还是可能在过期之后一秒钟之内被访问到,而在新的 Redis 2.6 版本中,延迟被降低到 1 毫秒之内。
2. 这段代码问题在哪里?1) 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2)当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3) 锁不具备拥有者标识,即任何客户端都可以解锁。

(1)首先第一个问题是存在的,但是一般对于同一个分布群的项目,时间肯定是同步的,而且你见过哪台机器时间是用本地时间的?一般都是联网同步互联网全球设定的时区时间。 (2)Redis是单线程的,所以不存在你说的这种情况。是不可能同时执行的。即使客户端A,B,C 在同一时刻(精确到纳秒)发送了getset给redis。redis也会按照队列顺序依次执行。因此绝对保证只有一个客户端获得锁并进行业务处理最后释放锁,其他客户端一定会返回一个大于当前时间的结果,从而导致或许锁失败,也就不可能出现其他任何客户端能够解锁。你可能会说,“同时”。但是你说“同时”的时候,我就知道,兄弟你对redis的底层没有了解到这一层面,redis是强制单线程的。除非你改人家源码,就算你改人家源码,如果你改了人家的单线程设计理念,就等于说你不赞同redis作者的理念。然而,redis的单线程,却没有影响它的高性能。

  1. 实践中我发现的问题.
1
python复制代码if lock == 1 or (now > int(redis_client.get(lock_key))) and now > int(redis_client.getset(lock_key, lock_timeout))

会出现bug,因为当锁被删除之后,如果正好处于redis_client.get(lock_key)检查的时候,这个时候锁被删除了,所以redis_client.get(lock_key)为None,int(None)会出现类型转换错误。
然后这个时候如果为None的话,可以直接赋值为0,这样也不会出现问题,因为本身这个锁就被删除了,和时间超时是一样的,所以提出了优化。

1
python复制代码if lock == 1 or (now > int(redis_client.get(lock_key) or 0)) and now > int(redis_client.getset(lock_key, lock_timeout) or 0)

其次为了便于使用,我还优化成了一个类:

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
python复制代码class DistributeLock(object):

def __init__(self, lock_key=None, lock_timeout=2):
"""
:param lock_key: 分布式锁的key
:param lock_timeout: 锁的超时时间
"""
self.LOCK_TIMEOUT = lock_timeout
self.lock = 0
self.lock_timeout = 0
self.lock_key = lock_key # # 分布式锁的key
self.redis_client = #初始化redis客户端。

def __enter__(self):
# 获取锁
while self.lock != 1:
now = int(time.time())
self.lock_timeout = now + self.LOCK_TIMEOUT + 1
lock = self.redis_client.setnx(self.lock_key, self.lock_timeout)
if lock == 1 or (now > int(self.redis_client.get(self.lock_key) or 0)) and now > int(
self.redis_client.getset(self.lock_key, self.lock_timeout) or 0):
break
else:
time.sleep(0.001)

def __exit__(self, ex_type, ex_value, traceback):
# 释放锁
now = int(time.time())
if now < self.lock_timeout:
self.redis_client.delete(self.lock_key)
# 使用方式:
with DistributeLock(lock_key="your_distribute_lock",lock_timeout=3):
# do your work.
do_work() # 不用在关心锁的获取或者释放。

其实以上,还有有些问题:就是没有考虑redis挂掉或者主从切换的情况,后续再更新。

关注我,让我们一起成长。

本文转载自: 掘金

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

聊聊golang的error包装

发表于 2020-11-29

序

本文主要研究一下golang的error包装

error

1
2
3
go复制代码type error interface {
Error() string
}

error接口定义了Error方法,返回string

runtime.Error

1
2
3
4
5
6
go复制代码package runtime

type Error interface {
error
// and perhaps other methods
}

对于panic,产生的则是runtime.Error,该接口内嵌了error接口

wrap

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
go复制代码package main

import (
"errors"
"fmt"

pkgerr "github.com/pkg/errors"
)

func main() {
if err := methodA(false); err != nil {
fmt.Printf("%+v", err)
}
if err := methodA(true); err != nil {
fmt.Printf("%+v", err)
}
}

func methodA(wrap bool) error {
if err := methodB(wrap); err != nil {
if wrap {
return pkgerr.Wrap(err, "methodA call methodB error")
}
return err
}
return nil
}

func methodB(wrap bool) error {
if err := methodC(); err != nil {
if wrap {
return pkgerr.Wrap(err, "methodB call methodC error")
}
return err
}
return nil
}

func methodC() error {
return errors.New("test error stack")
}

使用内置的errors,则没办法打印堆栈;使用pkg/errors可以携带堆栈

输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码test error stack
test error stack
methodB call methodC error
main.methodB
/error-demo/error_wrap.go:33
main.methodA
/error-demo/error_wrap.go:21
main.main
/error-demo/error_wrap.go:15
runtime.main
/usr/local/go/src/runtime/proc.go:204
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1374
methodA call methodB error
main.methodA
/error-demo/error_wrap.go:23
main.main
/error-demo/error_wrap.go:15
runtime.main
/usr/local/go/src/runtime/proc.go:204
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1374%

小结

  • error接口定义了Error方法,返回string;对于panic,产生的则是runtime.Error,该接口内嵌了error接口
  • 使用内置的errors,则没办法打印堆栈;使用pkg/errors可以携带堆栈

doc

  • Errors
  • pkg/errors
  • Embedding Interfaces in Go (Golang)

本文转载自: 掘金

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

Android 开源库

发表于 2020-11-29

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。

本文是 Android 开源库系列的第 6 篇文章,完整文章目录请移步到文章末尾~

前言

  • 图片模块是 App 中非常重要的一个组件,而 Glide 作为官方和业界双重认可的解决方案,其学习价值不必多言;
  • 在这篇文章里,我将分析 Glide 生命周期管理,主要分为三个层次的生命周期:Activity & 网络 & 内存。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

提示: 本文源码基于Glide 4.11


目录


  1. 概述

使用 Glide 加载图片非常简单,类似这样:

1
2
3
scss复制代码Glide.with(activity)
.load(url)
.into(imageView)

相对地,取消加载也很简单,类似这样:

1
scss复制代码Glide.with(activity).clear(imageView)

一般认为,应该及时取消不必要的加载请求,但这并不是必须的操作。因为 Glide 会在页面生命周期 / 网络变化时,自动取消加载或重新加载。

  • 页面生命周期

当页面不可见时暂停请求;页面可见时恢复请求;页面销毁时销毁请求。在 第 2 节,我将详细分析 Glide 的生命周期模块,主要包括了 Activity / Fragment 两个主体。

  • 网络连接状态

如果从 URL 加载图片,Glide 会监听设备的连接状态,并在重新连接到网络时重启之前失败的请求。在 第 3 节,我将详细分析 Glide 的网络连接状态监听模块

  • 内存状态

Glide 会监听内存状态,并根据不同的 level 来释放内存。在 第 4 节,我将详细分析 Glide 的内存状态监听模块


  1. Activity / Fragment 生命周期监听

2.1 为什么要监听页面生命周期?

主要基于以下两个目的:

  • 以确保优先处理前台可见的 Activity / Fragment,提高资源利用率;
  • 在有必要时释放资源以避免在应用在后台时被杀死,提高稳定性。

提示: Low Memory Killer 会在合适的时机杀死进程,杀死优先级为:空进程->后台进程->服务进程->可见进程->前台进程。

2.2 三种生命周期作用域

首先,我们从 Glide 的入口方法入手:

Glide.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
csharp复制代码private final RequestManagerRetriever requestManagerRetriever;

入口方法:
public static RequestManager with(Context context) {
return getRetriever(context).get(context);
}

入口方法:
public static RequestManager with(Activity activity) {
return getRetriever(activity).get(activity);
}

此处省略参数为 FragmentActivity、Fragment、View 的类似方法...

private static RequestManagerRetriever getRetriever(Context context) {
其中,Glide.get(context) 基于 DCL 单例
return Glide.get(context).getRequestManagerRetriever();
}

public RequestManagerRetriever getRequestManagerRetriever() {
return requestManagerRetriever;
}

可以看到,with(...)方法的返回值是RequestManager,而真正创建的地方在RequestManagerRetriever#get(...)中。

先说结论,根据传入的参数不同,将对应于 Application & Activity & Fragment 的作用域,具体如下:

线程 参数 作用域
子线程 / Application
主线程(下同) ApplicationContext/ServiceContext Application
/ FragmentActivity Activity
/ Activity Activity
/ Fragment Fragment
/ View Activity / Fragment
  • 1、Application 作用域

对于 Application 作用域的请求,它的生命周期是全局的,不与具体页面绑定。

RequestManagerRetriever.java

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
less复制代码已简化

Application 域请求管理
private volatile RequestManager applicationManager;

private RequestManager getApplicationManager(@NonNull Context context) {
源码基于 DCL 单例
return applicationManager;
}

public RequestManager get(@NonNull Context context) {
if (Util.isOnMainThread() && !(context instanceof Application)) {

2、FragmentActivity
if (context instanceof FragmentActivity) {
return get((FragmentActivity) context);
}

3、Activity
else if (context instanceof Activity) {
return get((Activity) context);
}
}
1、Application
return getApplicationManager(context);
}

public RequestManager get(@NonNull FragmentActivity activity) {
if (Util.isOnBackgroundThread()) {
return get(activity.getApplicationContext());
} else {
见下文 ...
}
}

上面的代码已经非常简化了,主要关注以下几点:

1、Application 域对应的是 applicationManager,它是与RequestManagerRetriever 对象绑定的;

2、在子线程调用get(...),或者传入参数是 ApplicationContext & ServiceContext 时,对应的请求是 Application 域。

  • 2、Activity 作用域

RequestManagerRetriever.java

1
2
3
4
5
6
7
8
9
10
11
java复制代码已简化,并略去子线程的分支

public RequestManager get(FragmentActivity activity) {
FragmentManager fm = activity.getSupportFragmentManager();
return supportFragmentGet(activity, fm, null, isActivityVisible(activity));
}

public RequestManager get(Activity activity) {
android.app.FragmentManager fm = activity.getFragmentManager();
return fragmentGet(activity, fm, null, isActivityVisible(activity));
}

可以看到,这里先获得了 FragmentActivity 的 FragmentManager,之后调用supportFragmentGet(...)获得 RequestManager。

提示:Activity 分支与 FragmentActivity 分支类似,我不重复分析了。

  • 3、Fragment 作用域

RequestManagerRetriever.java

1
2
3
4
5
6
scss复制代码已简化,并略去子线程的分支

public RequestManager get(Fragment fragment) {
FragmentManager fm = fragment.getChildFragmentManager();
return supportFragmentGet(fragment.getContext(), fm, fragment, fragment.isVisible());
}

可以看到,这里先获得了 Fragment 的 FragmentManager(getChildFragmentManager()),之后调用supportFragmentGet(...)获得 RequestManager。

2.3 生命周期绑定

从上一节的分析知道,Activity 域和 Fragment 域都会调用supportFragmentGet(...)来获得 RequestManager,这一节我们专门分析这个方法:

RequestManagerRetriever.java

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
ini复制代码已简化(提示:这个方法必在主线程执行)

用于临时记录 FragmentManager - SupportRequestManagerFragment 的映射关系
final Map<FragmentManager, SupportRequestManagerFragment> pendingSupportRequestManagerFragments = new HashMap<>();

private RequestManager supportFragmentGet(
Context context,
FragmentManager fm,
Fragment parentHint,
boolean isParentVisible) {

1、从 FragmentManager 中获取 SupportRequestManagerFragment
SupportRequestManagerFragment current =
getSupportRequestManagerFragment(fm, parentHint, isParentVisible);

2、从该 Fragment 中获取 RequestManager
RequestManager requestManager = current.getRequestManager();

3、首次获取,则实例化 RequestManager
if (requestManager == null) {

3.1 实例化
Glide glide = Glide.get(context);
requestManager = factory.build(...);

3.2 设置 Fragment 对应的 RequestMananger
current.setRequestManager(requestManager);
}

return requestManager;
}

-> 1、从 FragmentManager 中获取 SupportRequestManagerFragment
private SupportRequestManagerFragment getSupportRequestManagerFragment(FragmentManager fm, Fragment parentHint, boolean isParentVisible) {

1.1 尝试获取 FRAGMENT_TAG 对应的 Fragment
SupportRequestManagerFragment current =
(SupportRequestManagerFragment) fm.findFragmentByTag(FRAGMENT_TAG);

if (current == null) {
1.2 尝试从临时记录中获取 Fragment
current = pendingSupportRequestManagerFragments.get(fm);

1.3 实例化 Fragment
if (current == null) {

1.3.1 创建对象
current = new SupportRequestManagerFragment();
current.setParentFragmentHint(parentHint);

1.3.2 如果父层可见,则调用 onStart() 生命周期
if (isParentVisible) {
current.getGlideLifecycle().onStart();
}

1.3.3 临时记录映射关系
pendingSupportRequestManagerFragments.put(fm, current);

1.3.4 提交 Fragment 事务
fm.beginTransaction().add(current, FRAGMENT_TAG).commitAllowingStateLoss();

1.3.5 post 一个消息
handler.obtainMessage(ID_REMOVE_SUPPORT_FRAGMENT_MANAGER, fm).sendToTarget();
}
}
return current;
}

-> 1.3.5 post 一个消息
case ID_REMOVE_SUPPORT_FRAGMENT_MANAGER:

1.3.6 移除临时记录中的映射关系
FragmentManager supportFm = (FragmentManager) message.obj;
key = supportFm;
removed = pendingSupportRequestManagerFragments.remove(supportFm);
break;

上面的代码已经非常简化了,主要关注以下几点:

  • 1、从 FragmentManager 中获取 SupportRequestManagerFragment;
  • 2、从该 Fragment 中获取 RequestManager;
  • 3、首次获取,则实例化 RequestManager,后续从同一个 SupportRequestManagerFragment 中都获取的是这个 RequestManager。

其中,获取 SupportRequestManagerFragment 的方法更为关键:

  • 1.1 尝试获取FRAGMENT_TAG对应的 Fragment
  • 1.2 尝试从临时记录中获取 Fragment
  • 1.3 实例化 Fragment
    • 1.3.1 创建对象
    • 1.3.2 如果父层可见,则调用 onStart() 生命周期
    • 1.3.3 临时记录映射关系
    • 1.3.4 提交 Fragment 事务
    • 1.3.5 post 一个消息
    • 1.3.6 移除临时记录中的映射关系

其实一步步看下来,逻辑上不算复杂了,只有 “临时记录” 比较考验源码框架理解度。即:在提交 Fragment 事务之前,为什么需要先保存记录?

这是 为了避免 SupportRequestManagerFragment 在一个作用域中重复创建。 因为commitAllowingStateLoss()是将事务 post 到消息队列中的,也就是说,事务是异步处理的,而不是同步处理的。假设没有临时保存记录,一旦在事务异步等待执行时调用了Glide.with(...),就会在该作用域中重复创建 Fragment。

提示: 需要注意的是,异步不一定需要多线程,异步往往伴随着并发,但不是必须的,近期我将会分享一篇 Kotlin 协程的文章,会具体谈到这个观点,请关注!

2.4 生命周期监听

从上面的分析我们得知,Glide 为每个Activity 和 Fragment 作用域创建了一个无界面的 Fragment,这一节我们来分析 Glide 如何监听这个无界面 Fragment 的生命周期。

SupportRequestManagerFragment.java

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
typescript复制代码private final ActivityFragmentLifecycle lifecycle;

public SupportRequestManagerFragment() {
this(new ActivityFragmentLifecycle());
}

@Override
public void onStart() {
super.onStart();
lifecycle.onStart();
}

@Override
public void onStop() {
super.onStop();
lifecycle.onStop();
}

@Override
public void onDestroy() {
super.onDestroy();
lifecycle.onDestroy();
unregisterFragmentWithRoot();
}

@NonNull
ActivityFragmentLifecycle getGlideLifecycle() {
return lifecycle;
}

RequestManagerRetriever.java

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复制代码-> 3.1 实例化 RequestManager
Glide glide = Glide.get(context);
requestManager = factory.build(glide, current.getGlideLifecycle(),
current.getRequestManagerTreeNode(), context);

RequestManager 工厂接口
public interface RequestManagerFactory {
RequestManager build(
Glide glide,
Lifecycle lifecycle,
RequestManagerTreeNode requestManagerTreeNode,
Context context);
}

默认 RequestManager 工厂接口实现类
private static final RequestManagerFactory DEFAULT_FACTORY = new RequestManagerFactory() {
@Override
public RequestManager build(
Glide glide,
Lifecycle lifecycle,
RequestManagerTreeNode requestManagerTreeNode,
Context context) {
return new RequestManager(glide, lifecycle, requestManagerTreeNode, context);
}
};
}

RequestManager.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码已简化

final Lifecycle lifecycle;

RequestManager(Glide glide, Lifecycle lifecycle, ...){
...
this.lifecycle = lifecycle;

添加监听
lifecycle.addListener(this);
}

@Override
public synchronized void onDestroy() {
...
移除监听
lifecycle.removeListener(this);
}

可以看到,实例化 RequestManager 时需要一个 com.bumptech.glide.manager.Lifecycle对象,这个对象是在无界面 Fragment 中创建的。当 Fragment 的生命周期变化时,就是通过这个 Lifecycle 对象将事件分发到 RequestManager。

ActivityFragmentLifecycle.java

1
2
3
4
5
6
7
typescript复制代码class ActivityFragmentLifecycle implements Lifecycle {

private final Set<LifecycleListener> lifecycleListeners =
Collections.newSetFromMap(new WeakHashMap<LifecycleListener, Boolean>());

...
}

2.5 生命周期回调

现在我们来看 RequestManager 收到生命周期回调后的处理。

LifecycleListener.java

1
2
3
4
5
csharp复制代码public interface LifecycleListener {
void onStart();
void onStop();
void onDestroy();
}

RequestManager.java

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
scss复制代码private final RequestTracker requestTracker;

public class RequestManager
implements ComponentCallbacks2, LifecycleListener, ... {

@Override
public synchronized void onStop() {
1、onStop() 时暂停任务(页面不可见)
pauseRequests();
targetTracker.onStop();
}

@Override
public synchronized void onStart() {
2、onStart() 时恢复任务(页面可见)
resumeRequests();
targetTracker.onStart();
}

@Override
public synchronized void onDestroy() {
3、onDestroy() 时销毁任务(页面销毁)
targetTracker.onDestroy();
for (Target<?> target : targetTracker.getAll()) {
clear(target);
}
targetTracker.clear();
requestTracker.clearRequests();
lifecycle.removeListener(this);
lifecycle.removeListener(connectivityMonitor);
mainHandler.removeCallbacks(addSelfToLifecycle);
glide.unregisterRequestManager(this);
}

public synchronized void pauseRequests() {
requestTracker.pauseRequests();
}

public synchronized void resumeRequests() {
requestTracker.resumeRequests();
}
}

主要关注以下几点:

  • 1、页面不可见时暂停请求(onStop() )
  • 2、页面可见时恢复请求(onStart() )
  • 3、页面销毁时销毁请求(onDestroy() )

  1. 网络连接状态监听

Glide 会监听网络连接状态,并在网络重新连接时重新启动失败的请求。具体分析如下:

3.1 广播监听器

RequestManager.java

1
2
3
4
5
6
7
8
scss复制代码private final ConnectivityMonitor connectivityMonitor;

RequestManager(...){
...
监听器
connectivityMonitor = factory.build(context.getApplicationContext(), new RequestManagerConnectivityListener(requestTracker));
...
}

可以看到,在 RequestManager 的构造器中,会构建一个ConnectivityMonitor对象。其中,默认的构建工厂是:

DefaultConnectivityMonitorFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class DefaultConnectivityMonitorFactory implements ConnectivityMonitorFactory {

private static final String TAG = "ConnectivityMonitor";
private static final String NETWORK_PERMISSION = "android.permission.ACCESS_NETWORK_STATE";

@Override
public ConnectivityMonitor build(Context context, ConnectivityMonitor.ConnectivityListener listener) {

1、检查是否授予监控网络状态的权限
int permissionResult = ContextCompat.checkSelfPermission(context, NETWORK_PERMISSION);
boolean hasPermission = permissionResult == PackageManager.PERMISSION_GRANTED;

2、实例化不同的 ConnectivityMonitor
return hasPermission
? new DefaultConnectivityMonitor(context, listener)
: new NullConnectivityMonitor();
}
}

如果有网络监听权限,则实例化DefaultConnectivityMonitor:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
java复制代码已简化
final class DefaultConnectivityMonitor implements ConnectivityMonitor {

private final Context context;
final ConnectivityListener listener;

boolean isConnected;
private boolean isRegistered;

1、广播监听器
private final BroadcastReceiver connectivityReceiver =
new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
boolean wasConnected = isConnected;
isConnected = isConnected(context);

5、状态变化,回调
if (wasConnected != isConnected) {
listener.onConnectivityChanged(isConnected);
}
}
};

DefaultConnectivityMonitor(Context context, ConnectivityListener listener) {
this.context = context.getApplicationContext();
this.listener = listener;
}

private void register() {
if (isRegistered) {
return;
}

2、注册广播监听器
isConnected = isConnected(context);
context.registerReceiver(connectivityReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
isRegistered = true;
}

private void unregister() {
if (!isRegistered) {
return;
}

3、注销广播监听器
context.unregisterReceiver(connectivityReceiver);
isRegistered = false;
}

4、检查网络连通性
boolean isConnected(Context context) {
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
return networkInfo.isConnected();
}

@Override
public void onStart() {
页面可见时注册
register();
}

@Override
public void onStop() {
页面不可见时注销
unregister();
}

@Override
public void onDestroy() {
// Do nothing.
}
}

可以看到,在页面可见时注册广播监听器,而在页面不可见时注销广播监听器。

3.2 网络连接变化回调

现在我们看 RequestManager 中是如何处理网络连接状态变化的。

RequestManager.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码
private class RequestManagerConnectivityListener
implements ConnectivityMonitor.ConnectivityListener {

private final RequestTracker requestTracker;

RequestManagerConnectivityListener(RequestTracker requestTracker) {
this.requestTracker = requestTracker;
}

@Override
public void onConnectivityChanged(boolean isConnected) {

网络连接,重新开启请求
if (isConnected) {
synchronized (RequestManager.this) {
requestTracker.restartRequests();
}
}
}
}

小结:如果应用有监控网络状态的权限,那么 Glide 会监听网络连接状态,并在网络重新连接时重新启动失败的请求。


  1. 内存状态监听

这一节我们来分析 Glide 的内存状态监听模块,具体分析如下:

Glide.java

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
scss复制代码private static void initializeGlide(...) {
...
applicationContext.registerComponentCallbacks(glide);
}

1、内存紧张级别
@Override
public void onTrimMemory(int level) {
trimMemory(level);
}

2、低内存状态
@Override
public void onLowMemory() {
clearMemory();
}

public void trimMemory(int level) {
1.1 确保是主线程
Util.assertMainThread();

1.2 每个 RequestManager 处理内存紧张级别
for (RequestManager manager : managers) {
manager.onTrimMemory(level);
}

1.3 内存缓存处理内存紧张级别
memoryCache.trimMemory(level);
bitmapPool.trimMemory(level);
arrayPool.trimMemory(level);
}

public void clearMemory() {
1.2 确保是主线程
Util.assertMainThread();

1.2 内存缓存处理低内存状态
memoryCache.clearMemory();
bitmapPool.clearMemory();
arrayPool.clearMemory();
}

RequestManager.java

1
2
3
4
5
6
7
java复制代码@Override
public void onTrimMemory(int level) {
if (level == TRIM_MEMORY_MODERATE && pauseAllRequestsOnTrimMemoryModerate) {
暂停请求
pauseAllRequestsRecursive();
}
}

小结:在构建 Glide 时,会调用registerComponentCallbacks()进行全局注册, 系统在内存紧张的时候回调onTrimMemory(level)。而 Glide 则根据系统内存紧张级别(level)进行 memoryCache / bitmapPool / arrayPool 的回收,而 RequestManager 在 TRIM_MEMORY_MODERATE 级别会暂停请求。


  1. 总结

  • 页面生命周期

当页面不可见时暂停请求;页面可见时恢复请求;页面销毁时销毁请求。

  • 网络连接状态

如果应用有监控网络状态的权限,那么 Glide 会监听网络连接状态,并在网络重新连接时重新启动失败的请求。

  • 内存状态

Glide 则根据系统内存紧张级别(level)进行 memoryCache / bitmapPool / arrayPool 的回收,而 RequestManager 在 TRIM_MEMORY_MODERATE 级别会暂停请求。

参考资料

  • 《Glide 官方文档》

推荐阅读

Android 开源库系列完整目录如下(2023/07/12 更新):

  • #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
  • #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
  • #3 IO 框架 Okio 的实现原理,到底哪里 OK?
  • #4 IO 框架 Okio 的实现原理,如何检测超时?
  • #5 序列化框架 Gson 原理分析,可以优化吗?
  • #6 适可而止!看 Glide 如何把生命周期安排得明明白白
  • #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!
  • #8 内存缓存框架 LruCache 的实现原理,手写试试?
  • #9 这是一份详细的 EventBus 使用教程

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

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

一文超干货的HTTP发展史 HTTP09 HTTP10

发表于 2020-11-29

HTTP0.9

  1. http0.9只是一个简单的协议,只有一个GET命令,没有首部,目标用来获取HTML
  2. 服务器发送完毕,就关闭TCP连接

HTTP1.0

  1. 从单一的GET请求,增加了POST命令和HEAD命令
  2. 支持发送任何格式的内容
  3. 每次通信都必须包括头信息(HTTP header),用来描述一些元数据
  4. 新增功能还包括状态码(status code)、多字符集支持、多部分发送(multi-part type)、权限(authorization)、缓存(cache)、内容编码(content encoding)等

HTTP1.0的缺点

  1. 连接无法复用:每个TCP连接只能发送一个请求,发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。
  2. 队头阻塞(Head-of-line blocking):当页面中需要请求很多资源的时候,队头阻塞会导致在达到最大请求数量时,剩余的资源需要等待其它资源请求完成后才能发起请求。

HTTP1.1

  1. 长连接:HTTP1.1增加Connection字段,通过设置Keep-Alive保持TCP连接不关闭。避免每次客户端与服务器请求都要重复建立释放建立TCP连接。关闭可以在请求头中携带Connection:false来告知服务器关闭请求。
  2. 管道化(pipelining):同一个TCP连接里面,客户端可以同时发送多个请求,但服务器必须按照客户端请求的先后顺序依次回送相应的结果。
  3. 缓存处理:HTTP 1.0 中主要使用 header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP 1.1则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match等更多可供选择的缓存头来控制缓存策略。
  4. 新增命令:PUT、PATCH、HEAD、 OPTIONS、DELETE。
  5. 错误通知:HTTP1.1中新增了24个错误状态响应码。

HTTP1.1的缺点

  1. 臃肿的消息首部:HTTP/1.1能压缩请求内容,但是消息首部却无法压缩,HTTP/1 在使用时,header 里携带的内容过大,在一定程度上增加了传输的成本。
  2. 队头阻塞:虽然允许复用TCP连接,但是同一个TCP连接里面,管道化要求只能按照发送顺序依次接收响应。那么前一个请求如果很耗时,那么后面的请求即使服务器已经处理完,仍会等待前面的请求处理完才开始按序返回,后面就会有许多请求排队等着。

补充:为了防止这种问题,现代浏览器会针对单个域名开启6个连接,通过各个连接分别发送请求。它实现了某种程度上的并行,但是每个连接仍会受到队头阻塞的影响。另外,这也没有高效利用有限的设备资源。

  1. 低效的TCP利用:TCP协议作为最可靠的协议之一,其核心是拥塞窗口。

传输控制协议(TCP) 的设计思路是:对假设情况很保守,并能够公平对待同一网络的不同流量的应用。它的避免拥塞机制被设计成即使在最差的网络状况下仍能起作用,并且如果有需求冲突也保证相对公平。这是它取得成功的原因之一。

拥塞窗口指在接收方确认数据包之前,发送方可以发送的TCP包的数据。(如拥塞窗口指定为1的情况,那么发送方就发出1个数据包之后,只有接收方确认了那个发出的数据包,才能发送下一个)

一般来讲,每次发送一个数据包并不是非常低效。TCP有个概念叫慢启动(Slow Start), 它用来探索当前连接对应拥塞窗口的合适大小。慢启动的设计目标是为了让新连接搞清楚当前网络状况,避免给已经拥堵的网络继续添乱。它允许发送者在收到每个确认回复后额外发送 1 个未确认包。这意味着新连接在收到1个确认回复之后,可以发送2个数据包; 在收到2个确认回复之后,可以发4个;以此类推。这种几何级数增长很快就会到达协议规定的发包数上限,这时候连接将进入拥塞避免阶段,这种机制需要往返几次才能得知最佳拥塞窗口大小,但往返几次所需的时间成本不可忽略。

现代操作系统一般会取 4~10 个数据包作为初始拥塞窗口大小。如果你把一个数据包设置为最大值下限 1460 字节(也就是最大有效负载),那么只能先发送 5840 字节(假定拥塞窗口为 4),然后就需要等待接收确认回复。

  1. 受限的优先级设置:如果浏览器针对指定域名开启了多个socket(每个都会受队头阻塞问题的困扰),开始请求资源,这时候浏览器能指定优先级的方式是有限的:要么发起请求,要么不发起。

然而 Web 页面上某些资源会比另一些更重要,这必然会加重资源的 排队效应。这是因为浏览器为了先请求优先级高的资源,会推迟请求其他资源。

但是优先级高的资源获取之后,在处理的过程中,浏览器并不会发起新的资源请求,所以服务器无法利用这段时间发送优先级低的资源,总的页面下载时间因此延长了。还会出现这样的情况:一个高优先级资源被浏览器发现,但是受制于浏览器处理的方式,它被排在了一个正在获取的低优先级资源之后。

HTTP2.0

SPDY 协议是在 TCP 协议之上。相比 HTTP/1 的文本传输格式,HTTP/2 采用二进制格式传输数据,具有更小的传输体积以及负载。

HTTP2.0通过在应用层和传输层之间增加一个二进制分层帧(http2多路复用能力的核心部分),突破了HTTP1.1的性能限制,改进传输性能。

  1. 多路复用机制:引入了二进制的分帧层机制来实现多路复用。(分帧层是基于帧的二进制协议。这方便了机器解析。请求和响应交织在一起。)多路复用很好地解决了浏览器限制同一个域名下的请求数量的问题,同时也更容易实现全速传输,毕竟新开一个 TCP 连接都需要慢慢提升传输速度。
  2. 可以设置请求的优先级:HTTP2.0里每个数据流都可以设置优先级和依赖,优先级高的数据流会被服务器优先处理和返回客户端,数据流还可以依赖其他的子数据流。(客户端的分帧层对分割块标上请求的优先级)
  3. 头部压缩:请求头压缩,增加传输效率。
  4. 服务器推送:服务器除了最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确的需求。

多路复用机制的实现:

所有HTTP2.0通信都在一个TCP管道,使用一个TCP长连接。下载整个资源页面,只需要一次慢启动,并且避免了竞态,浏览器发起请求,分帧层会对每个请求进行分割,将同一个请求的分割块打上相同的id编号,然后通过协议栈将所有的分割体发送给服务器,然后通过服务器的分帧层根据id编号进行请求组装,服务器的分帧层将回应数据分割按同一个回应体进行id分割回应给客户端,客户端拼装回应。

对于http2中的帧(frame),http1不是基于帧的,是文本分隔的。这样,对于http1的请求或者是响应可能有的问题:

  1. 一次只能处理一个请求或者是响应,完成之前是不能停止解析的。
  2. 无法预判解析需要多少内层。

HTTP/1 的请求和响应报文,是由起始行、首部和正文组成,换行符分隔;HTTP/2是将请求和响应数据分割成更小的帧,采用二进制编码,易于解析的。

帧结构总结 所有的帧都包含一个9 byte的帧头 + 可边长的正文不同。根据帧的类型不同,正文部分的结构也不一样。

HTTP/2 帧结构

HTTP3.0

为什么要有HTTP3.0,HTTP/2底层TCP的局限带来的问题:

由于HTTP/2使用了多路复用,一般来说,同一个域名下只需要使用一个TCP链接,但当这个连接中出现了丢包的情况(TCP协议并不是不会丢包,而是丢包后会快速重传),整个TCP都要开始等待重传,导致后面的所有数据都被阻塞。而对于HTTP/1.1来说,可以开启多个TCP连接,出现这种情况只会影响其中一个连接,剩余的TCP链接还可以正常传输数据。

谷歌基于 UDP 协议研发一种名为QUIC(全称是“快速UDP互联网连接”)的实验性网络协议。

优势:

  1. 缓存当前会话的上下文:下次恢复会话的时候,只需要将之前的缓存传递给服务器,验证通过,就可以进行传输了。
  2. 多路复用:QUIC基于UDP,一个连接上的多个流之间没有依赖,即使丢包,只需要重发丢失的包即可,不需要重传整个连接。
  3. 更好的移动端表现:QUIC在移动端的表现比TCP好,因为TCP是基于IP识别连接,而QUIC是通过ID识别链接。无论网络环境如何变化,只要ID不便,就能迅速重新连上。
  4. 加密认证的根文——武装到牙齿:TCP协议头没有经过任何加密和认证,在传输过程中很容易被中间网络设备篡改、注入和窃听。

QUIC:除了个别报文,比如PUBLIC_RESET和CHLO,所有报文头部都是经过认证的,报文体都是经过加密的。所以只要对 QUIC 做任何更改,接收端都能及时发现,有效地降低了安全风险。
5. 向前纠错机制:QUIC协议有一个非常独特的特性,成为向前纠错(Foward Error Connec,FEC),每个数据包除了它本身的内容之外还包括了其他数据包的数据,因此少量的丢包可以通过其他包的冗余数据直接组装而无需重传。
向前纠错牺牲了每个数据包可以发送数据的上限,但是带来的提升大于丢包导致的数据重传,因为数据重传将会消耗更多的时间(包括确认数据包丢失,请求重传,等待新数据包等步骤的时间消耗)。

例如:我总共发送三个包,协议会算出这个三个包的异或值并单独发出一个校验包,也就是总共发出了四个包。当其中出现了非校验包丢失的情况,可以通过另外三个包计算出丢失的数据包的内容。

当然这种技术只能使用在丢失一个包的情况下,如果出现丢失多个包,就不能使用纠错机制了,只能使用重传的方式了。

如何在Chrome中启用 QUIC 协议:

  • Chrome 浏览器地址栏上输入 chrome://flags/ 。
  • Experimental QUIC protocol,将Default改为Enabled

部分内容没有进行详细书写,大佬们可以补充在评论区

相关推荐:

HTTP/2基础教程

当我们在谈论HTTP队头阻塞时,我们在谈论什么?

HTTP/1.x 的连接管理

本文转载自: 掘金

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

聊聊golang的类型断言

发表于 2020-11-28

序

本文主要研究一下golang的类型断言

类型断言

1
r复制代码x.(T)
  • 断言x不为nil且x为T类型
  • 如果T不是接口类型,则该断言x为T类型
  • 如果T类接口类型,则该断言x实现了T接口

实例1

1
2
3
4
5
6
7
go复制代码func main() {
var x interface{} = 7
i := x.(int)
fmt.Println(reflect.TypeOf(i))
j := x.(int32)
fmt.Println(j)
}

直接赋值的方式,如果断言为true则返回该类型的值,如果断言为false则产生runtime panic;j这里赋值直接panic

输出

1
2
3
4
5
6
7
go复制代码int
panic: interface conversion: interface {} is int, not int32

goroutine 1 [running]:
main.main()
type_assertion.go:12 +0xda
exit status 2

不过一般为了避免panic,通过使用ok的方式

1
2
3
4
5
6
7
8
9
go复制代码func main() {
var x interface{} = 7
j, ok := x.(int32)
if ok {
fmt.Println(reflect.TypeOf(j))
} else {
fmt.Println("x not type of int32")
}
}

switch type

另外一种就是variable.(type)配合switch进行类型判断

1
2
3
4
5
6
7
8
go复制代码func main() {
switch v := x.(type) {
case int:
fmt.Println("x is type of int", v)
default:
fmt.Printf("Unknown type %T!\n", v)
}
}

判断struct是否实现某个接口

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
go复制代码type shape interface {
getNumSides() int
getArea() int
}
type rectangle struct {
x int
y int
}

func (r *rectangle) getNumSides() int {
return 4
}
func (r rectangle) getArea() int {
return r.x * r.y
}

func main() {
// compile time Verify that *rectangle implement shape
var _ shape = &rectangle{}
// compile time Verify that *rectangle implement shape
var _ shape = (*rectangle)(nil)

// compile time Verify that rectangle implement shape
var _ shape = rectangle{}
}

输出

1
2
rust复制代码cannot use rectangle literal (type rectangle) as type shape in assignment:
rectangle does not implement shape (getNumSides method has pointer receiver)

小结

  • x.(T)可以在运行时判断x是否为T类型,如果直接使用赋值,当不是T类型时则会产生runtime panic
  • 使用var _ someInterface = someStruct{}可以在编译时期校验某个struct或者其指针类型是否实现了某个接口

doc

  • Type_assertions
  • Go : Check if type implements an interface

本文转载自: 掘金

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

李子柒130个视频1万图片5万颜色数据可视化的背后,是古柳三

发表于 2020-11-28

温馨提醒:本文有点长,5000+字数,可耐心阅读或收藏有空再看。写作花了很多时间,欢迎转发、点赞、留言,多多捧场,古柳也有动力持续创作。对文章任何内容有疑问欢迎可视化群里交流。PS:堀未央奈,毕业快乐!文章剩最后一节没写时,突然看到谁都没料到的乃木坂46成员宣布毕业,被击沉懵了,再也写不下去,很久缓过来后才写完,有了这么个插曲,记录一笔。

书接上文

上篇文章说到古柳不知何故陷入“个人可视化的至暗时刻”,挺长一段时间,没啥干劲,但期间依然发生了许多事,因而简单回顾了下:盘点这个月可视化的那些事。

文章最后放上了几张最近关于李子柒b站视频画面颜色的可视化图,其实也是在这次实现中一点点找回了之前的状态,果然自己还是喜欢研究、复现作品,或许唯有如此古柳才能从糟糕的状态中“拯救自己”,人啊“都得一步一步救自己”。

什么叫做救自己呢?就是忠于自己的感觉,认真做每一件事,不要烦,不要放弃,不要敷衍。哪怕写文章时标点符号弄清楚,不要有错别字——这就是我所谓的自己救自己。我们都得一步一步救自己。
——陈丹青

契机

使古柳这次下定决心开始动手实践颜色可视化的契机,说起来或许过于简单。

某天刷推特时,看到这几张色彩鲜艳的照片,仿佛啪得一下很快,无干劲的古柳被明媚的颜色冲击到,脑海中遂又一次响起“要不搞搞颜色可视化”的声音,动心起念后开始研究与实践,并还算顺利地做出了几张效果图。
twitter.com/mitsuyuka_l…

埋下想复现种子的一席演讲

这里说是“又一次”,是因为这样的念头在过去曾有过很多次,但大概都因时机不成熟,便没了后文,毕竟真的好好学了 D3.js 和前端基础知识还是近半年的事。若要问为何有此念头,那还要从很久以前看向帆老师的一席演讲说起。

链接:【一席】向帆:如果把每年的春晚都像蚊香一样卷起来的话,它就是这样的 - bilibili

链接:向帆:秘密的设计 - 一席
v.qq.com/x/cover/7qm…

如果此前你没看过这个视频,或许此刻可以好好看看,其中尤为相关的是后半段春晚和全国美展获奖作品的内容,这里专门剪辑出来方便观看。

视频里向帆老师用大家不曾有过的方式、以更宏观角度各种花式排列数据、进行可视化,令人叹为观止。惊艳之余,那时啥也不会的古柳也仿佛痴人说梦般在心中埋下了想实现类似效果的种子。毕竟鲁迅曾说过“听君一席终觉浅,绝知此事要躬行”。

如果你曾看过古柳以前的这两篇文章「图像检索(一):因缘际会与前瞻 - 20180506」、「伴随 P5.js 入坑创意编程 - 20190628」,便会发现其中都出现了向帆老师那次演讲。再看发布日期,此间岁月的念念不忘便不言自明。

一晃三年

其实这次终于初步打通全部流程、作出几张颜色可视化效果图后,古柳才开始想自己到底最早是什么时候看到那个视频的,一开始没翻历史文章,想着可能是2018年末吧,然后试着搜了下日记(正经人谁写日记),没想到竟然是2017年11月的事,至今正好满三年,令人唏嘘。

念念不忘,必有回响,而古柳这次的回响竟然是在三年后。大家又是否有什么念念不忘的事,结果又如何了呢?

动手实践

说完那些因缘际会的事,将时间拉回到这次开始着手颜色可视化的时刻。

其实这次用到的数据:也就是李子柒的所有B站视频及每个视频隔几秒钟抽帧出一幅画面,古柳在8月份时就完成了。上百个视频加上万张照片,大小超过40GB,所以整个流程已经打通一半。

这回只需要搞清楚如何从一张照片里抽取主要颜色,然后批量化处理,再选择合适的方式进行可视化即可。

古柳这次可能就不开源代码了,但作为守武德的年轻人,还是将每个步骤我是如何实现的简单讲下,点到为止,有能力的小伙伴其实足够复现,当然有任何疑问可群里交流。不过又想了想基于已抽取的颜色数据用 D3.js 进行可视化这一步后续倒是开源。

五步通关

本次项目分为以下几步,用到了各种工具,搭配到一起才打通整个流程,大家也不一定完全按照古柳的方式实现,可相应选择自己顺手的技术进行替换,仅供参考:

  1. 选择感兴趣的、想要进行颜色可视化的对象,比如李子柒的所有视频;
  2. 爬取李子柒所有b站视频数据,并下载全部视频;
  3. 从每一个视频里隔几秒抽一帧画面,并存储在相应b站视频ID命名的文件夹下;
  4. 从每一帧画面里提取几种主要颜色,如 TOP5 颜色;
  5. 用 D3.js 进行颜色可视化。

1. 选对象

这一步没啥好说的,任何能抽取出颜色的内容大概都可以,可以挑自己感兴趣的UP主/电影/动画/纪录片等视频;也可以用自己以往拍摄的照片,这样就不用抽帧了。

这次选择李子柒的视频,一来是觉得她的视频虽然挺有争议,但毕竟在海外也有很多人喜欢,算是挺成功的文化输出;二来印象里她的视频画面都挺好看、意境唯美、有自己的风格,拿来可视化下或许是不错的选择。

2. 爬取b站数据,搭配 you-get 下载视频

李子柒在YouTube/微博/b站等很多平台都有更新视频,选择容易爬取的平台即可,比如b站。进到个人主页-投稿栏目下,抓下包,就会发现是数据通过 API 返回,无需自己解析页面、抽取数据,用最简单的爬虫,翻几页就能拿到所有视频数据。

李子柒 - b站个人主页

数据格式,以「芋头的一生,辣椒…是送的」这个视频为例,重要的属性大概有这些,其中每个视频有 aid 和 bvid 两个 id,bvid: BV1f54y1r7HV 是视频URL会带上的,如 https://www.bilibili.com/video/BV1f54y1r7HV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码[{
aid: 842739336,
author: "李子柒",
bvid: "BV1f54y1r7HV",
comment: 5742,
created: 1604843617,
description: "小时候我爷爷教过一句让我很疑惑的顺口溜:“红薯芋头本姓张,煮的没有烧的香”…虽然到现在也想不明白为啥一定要姓“张”但一点也不妨碍我爱吃它!年初在前院大门口种了几排毛芋儿后门那多种了香芋,刚好也到了能下嘴的季节。芋儿鸡、烤芋儿、红糖芋圆蛋…你们呢?最喜欢怎么吃芋头啊?",
length: "19:59",
mid: 19577966,
pic: "//i1.hdslb.com/bfs/archive/4f65418c058899f40638a55e0cd13bef4bf93be1.jpg",
play: 2479512,
review: 0,
subtitle: "",
title: "芋头的一生,辣椒…是送的",
typeid: 214,
video_review: 37999,
},]

而 aid: 842739336 可用来下载视频,这里古柳搭配使用 you-get 命令行工具,其支持下载很多平台的视频/图像/音频。

和安装其他 Python 库一样简单:pip install you-get,然后用下面的命令即可下载单个视频。更多用法见:you-get 中文说明

1
2
3
4
5
shell复制代码# 下载b站视频
you-get http://www.bilibili.com/video/av842739336

# --output-dir/-o 设置存储路径, --output-filename/-O 设置文件名: av842739336 => av842739336.mp4
you-get -o ./liziqi -O av842739336 http://www.bilibili.com/video/av842739336

批量下载可以通过将所有视频的 aid 都生成一句命令然后写到 shell 脚本里,然后 bash you-get-cmd.sh 运行脚本即可。

3. 用 ffmpeg 进行视频抽帧

视频抽帧或许大家之前没接触过,但其实也并不难,用 ffmpeg 同样一行命令搞定。Mac 下可直接通过 brew install ffmpeg 安装就行。

官网:https://ffmpeg.org/

原本古柳担心安装这类工具会不会有坑,专门提前搜了些文章,以防万一,没想到一帆风顺,没出问题。但保不齐大家自己安装时可能依旧有坑,所以最好有个心理准备……

  • Mac OS上使用ffmpeg的“血泪”总结
  • macos安装ffmpeg以及出现问题的解决方案,一次成功

在正式开始抽帧前,古柳提前计算了下李子柒所有视频的累计总时长,以便评估不同间隔秒数分别会抽出多少图片。

其实计算方式在8月28日这篇文章里有写到:收益3583万?我是如何快速统计「李子柒」YouTube频道视频累计播放量并计算收益的,只不过当时没透露是想做这个项目,也并不知道何时会打通后半流程,所以就先写了这篇有点标题党的文章,但其实背后是有原因的,草蛇灰线,伏脉千里。

当时李子柒油管有116个视频,共50637秒,约14小时时长,如果每秒抽1帧,就会有5万张图片。

链接:李子柒Liziqi - YouTube

1
2
3
4
5
6
7
8
9
10
11
shell复制代码# 116 个视频
$$('span.ytd-thumbnail-overlay-time-status-renderer').forEach(el=>console.log(el.innerHTML.trim()))

# 计算全部下一共多少秒
$$('span.ytd-thumbnail-overlay-time-status-renderer').reduce((sum, el)=>{
let item = el.innerHTML.trim()
let min = +item.split(':')[0]
let sec = +item.split(':')[1]
return sum + min * 60 + sec
}, 0)
# 50637s = 843.95 min = 14h

但抽太多内存会爆炸,且李子柒的视频画面不会快速变换,所以选择间隔大些,隔5秒抽1帧,就会有1万多张图片。直接用这里的命令即可对一个视频进行抽帧。

链接:使用ffmpeg从视频中截取图像帧(最简单实用的视频抽帧,一句命令)

1
2
shell复制代码# 抽帧,每5秒抽1帧
ffmpeg -i av842739336.mp4 -f image2 -vf fps=fps=1/5 image-%05d.png

同样通过 shell 脚本来批量抽帧,然后……就可以在电脑上煎鸡蛋了。

4. 图片颜色抽取

终于到后半流程了。

能抽取图片颜色的第三方库想来很多编程语言都有,但抽取后对颜色进行可视化的项目古柳却也想不起来太多(欢迎安利),可能唯一记得的就是 Shirely Wu 有做过,于是找了下 data sketches 上「九月旅行」主题的项目就是。后续可能会翻译这篇文章,所以这里挑要紧的、相关的说下。

首先介绍下,该项目是 Shirely Wu 对自己2012年5月至2016年8月,4年间13场旅行、4千张照片2万个颜色(每张抽取5种主要颜色)进行的可视化,同一年的排列成一行,行内每场旅行用一个圆圈表示(径向图),圆圈内会展示本次旅行所拍摄照片的日期与颜色以及相关信息。

链接:http://sxywu.com/travel

文章中 Shirely Wu 写到颜色抽取用的 node.js 的第三方模块包 get-image-colors,并且为了加快抽取速度,会先用 gm 把照片缩小到8*8大小。不过古柳这次偷个懒就没缩小,批量抽取上万张图片颜色,耗时大概在20min内,也还行。

这里是抽取一张照片颜色的代码,getColors() 默认会抽取5种主要颜色,colors 是 chroma.js 颜色对象组成的数组,需要转换成16进制颜色表示的数组。

1
2
3
4
5
6
7
8
9
10
js复制代码const getColors = require("get-image-colors");

async function extractImageColor(imgPath){
let colors = await getColors(imgPath);
colors = colors.map((color) => color.hex());
return colors;
}

const imgPath = '⁨cnliziqi⁩/bilibili-videos-20200807⁩/⁨av5491231av5491231-00001.png⁩'
const colors = extractImageColor(imgPath);

接着挑一个视频看看抽出来的颜色效果如何,其实也看不出有啥规律,所以还是继续下一步用全量数据、从各种维度看看能否挖掘出什么好玩的。

注:其他关于如何安装 node.js、npm,如何初始化 Node.js 项目、如何安装 get-image-colors 、如何结合 fs 模块读取所有文件夹里的所有图片然后批量抽取,就不再展开了,其实也不难(逃),可自行解决或群里交流。

5. 颜色可视化

这部分内容可能会另写文章详细讲,并开源代码,所以此处择其要点讲下,有不清楚的地方可关注后续文章。

经过上一步的处理,有了一份如下格式的数据,含视频avid、该视频里所有抽帧图片的路径及其5种主要颜色,因为有上万张图片,所以相应有5万多颜色数据点。至于每个视频的其他信息可结合第2步爬取的数据来查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
json复制代码[
{
"avid": "av753928375",
"imgColor": [
{
"img": "../my-media/cnliziqi/bilibili-videos-20200807/av753928375/av753928375-00001.png",
"colors": [
"#25312f",
"#bdcfdf",
"#708ba6",
"#ad2629",
"#826276"
]
},
},
]

github.com/sxywu/trave…

终于到最后一步颜色可视化,临门一脚古柳却不知道该怎么踢,颜色仿佛很熟悉但又很陌生,但从没想过该如何排列颜色,因而再好好看看 Shirely Wu 是怎么做的。

在代表每一场旅行的径向图里,按日期划分不同扇形区域,每一天拍的照片所涉及的颜色按照 Hue/色相 值(0-360)的不同而径向排列,并加上x/y力布局以避免重叠。

HSB 又称 HSV,表示一种颜色模式:在HSB模式中,H(hues) 表示色相,S(saturation) 表示饱和度,B(brightness)表示亮度。色相(H)是色彩的基本属性,就是平常所说的颜色名称,如红色、黄色等,取0-360;饱和度(S)是指色彩的纯度,越高色彩越纯,低则逐渐变灰,取0-100%;明度/亮度(B or V),取0-100%。

链接:https://github.com/sxywu/travel/blob/master/src/App.js

照猫画虎,先不用径向图,而是在x/y轴二维坐标系下进行展示。古柳先将上面的颜色数据打平变成一个仅包含5万多颜色的数组。

x轴用 Hue 值 区分,y轴固定在页面中间,然后加上x/y力布局,看看效果:蛮意外绿色占比那么少,蓝色居然是最多的,本以为画面里绿色自然植物应该会很多,不过本身只看过零星几个李子柒的视频,也说不上来准不准。

接着尝试其他设置,x轴用 Hue 值,y轴用 Saturation 值,不加力布局。

x轴用 Hue 值,y轴用 Saturation 值,加x/y力布局。

x轴用 Hue 值,y轴用 Saturation 值,加x/y力布局。

小结

这些图都挺有趣的,都是古柳以前不曾有过的尝试。另外还可以结合视频发布日期、季节等不同维度进行布局,甚至其他能想到的角度都可以试试,看看会有什么发现。

由于本次并不完全是为了从李子柒的视频画面颜色中挖掘出什么内容,更多是为了打通爬取数据、下载视频、视频抽帧、颜色抽取、可视化整个流程,所以最后出了几张图后,想到三年来念念不忘的颜色可视化终于有了个初步结果,就想先写篇文章记录下,更多可视化探索留到后面有时间再搞,到时候看看有没有值得分享的新发现。


以上就是本文内容,如果大家还想看到更多干货,欢迎【转发】、【点赞】、【在看】和【留言】,多多捧场,古柳也有持续创作的动力,毕竟这惨淡的阅读量实在也是有点说服不了自己太频繁更新,还真不是因为懒。逃。

本文转载自: 掘金

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

为什么要微服务架构服务化?

发表于 2020-11-28

微服务架构,这 5 年左右一直被认可,是软件架构的未来方向。需要大家理解的是,为什么需要服务化。比如微服务架构对企业来说,带来什么价值?有啥弊端?

这里浅谈一下微服务架构,主要还是在理解 Why :为什么需要服务化?

一、对微服务架构的理解

1.1 微服务架构

file

微服务架构,主要是多了个 “微”。亚马逊有个粗粗的定义:一个微服务应用工程的所有开发、测试、运维加起来大约 6 到 8 个人,只需要两个披萨就可以聚餐了。

反例:不是一个 Service 类组成的应用工程,发布成服务就是微服务。这样分的太小,理解微服务就很片面。杭州某金融大厂,曾经分的很细,造成了运维测试成本巨大。开始分了合,折腾…

1.2 为啥需要微服务?

由 SOA 架构 -> 微服务架构的转变,得理解为什么微服务架构被广泛提到并实践。它解决了什么问题,带来了什么价值?

传统企业或者很多企业的软件,大多不止一套系统,都是各个独立大系统的堆砌。整体存在的问题是:

  • 扩展性差
  • 可靠性不高
  • 维护成本还很大
  • 重复轮子很多

file

那么这些问题,可以想到的解决方案就是:

  • 组件化
  • 服务化

微服务架构,将各个组件或者模块分散到各个服务中,对整个系统实现解耦。那微服务架构强调的重中之重就是业务系统需要完善的组件化和服务化。什么是组件化?

组件化,即将一个大系统,按照一定的业务或者技术维度关注形式,拆分成独立的组件。目的是为了分而治之,为了可重用,为了减少耦合度。比如按照技术维度:搜索组件、缓存组件;按照业务维度:用户中心、支付中心等

file

组件化是不是有点中台的意思?阿里巴巴提出 大中台,小前台;就是把组件化、插件化、服务化解决方案到极致。通过产品线公共业务或者技术下沉,形成各种技术或者业务中台

file

(图来自漫画程序员小灰)

二、服务化前的问题

2.1 没有服务化,不代表不是分布式或集群

分布式,就是多个实例提供相同的服务。比如多个地方动车站里面,多个机器提供取票服务。多个地方,北京上海等,就是多机房,多个取票服务一起组成了集群,形成分布式服务。那啥是服务化?

服务化,强调 “化”!核心就是不同服务之间的通信。是一种以服务为中心的解决方案:

  • 服务注册
  • 服务发布
  • 服务调用
  • 服务监控
  • 服务负载均衡
  • 等等

file

2.2 没有服务化的架构问题

没有服务化前,举个例子,会更形象:

假设有个取票服务、买票服务、改座服务都需要验证下用户身份真实性,那么会存在下面的问题:

  • 取票服务 -> 调用用户DB代码 -> 用户DB
  • 买票服务 -> 调用用户DB代码 -> 用户DB
  • 改座服务 -> 调用用户DB代码 -> 用户DB

file

明显的问题是:

  • 代码重复:不同业务相同访问 DB 的 userDAO 代码逻辑。而且每个服务这块代码是不同人维护的。
  • 可维护性低:不同人维护;不同地方维护;每次 DB 字段改变或者迁库,全部业务都有修改
  • DB 访问耦合

自然也有解决方案是:lib。维护一个 user-DAO-lib 1.0.0 release 包,给各个业务方。

解决了问题,引入了新的问题,lib 升级是巨大而又漫长的问题。比如小李是维护 user-DAO-lib 的人,有一次写了隐蔽的 bug 。user-lib 升级到了 1.0.1 release,花了 1 个月左右时间,推几十个业务方升级完毕。然后这个 bug 运行了几天出现了,考虑升级fix或者回滚都是巨大的成本

基于服务化,就可以完美解决问题。

三、服务化后的好处

file

如图 Post 文章服务调用 Video 视频服务,需要通过最上层的 Service 之间相互调用。服务化明显改变:

  • DB 隔离:这样底层细节设计可以屏蔽,后续加上其他存储 Cache 等对业务调用方无感知。
  • 通过 Service 之间通信:具体协议可以 RPC / HTTP 等

服务化后的好处:

  • 调用简单:不用写相同的访问用户服务代码,调用一个服务即可
  • 代码复用:跟 lib 形式的代码复用有所区别在于,服务化通过通信的方式解决
  • 业务隔离
  • 数据库解耦
  • 等等

四、不可否认的微服务架构或者服务化带来新的问题

1、本身不大的系统,业务不复杂的系统也不需要微服务架构。微服务架构会带来一定的复杂性,是一套完整的服务治理方案

2、多个模块数据库,分布式事务是一个挑战

3、开发过程,增加了测试等一定的复杂性

有利必有弊,具体场景具体选择

五、小结

本小结,不是讲how,讲的是 why。只有懂 why ,才能更好地 do。从为啥服务化?到为啥微服务架构这么流行:

  • 微服务扩展性高
  • 微服务可靠性高
  • 微服务 维护成本小
  • 微服务几乎没有重复轮子
  • 微服务直接调用调用简单
  • 微服务业务隔离
  • 微服务数据库解耦
  • 等等

本文转载自: 掘金

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

什么是接口测试和功能测试?

发表于 2020-11-27

一、什么是功能测试

功能测试 就是对产品的各功能进行验证,根据功能测试用例,逐项测试,检查产品是否达到用户要求的功能。Functionaltesting(功能测试),也称为behavioral testing(行为测试)、黑盒测试或数据驱动测试

黑盒测试(Black-box Testing,又称为功能测试或数据驱动测试)是把测试对象看作一个黑盒子。利用黑盒测试法进行动态测试时,需要测试软件产品的功能,不需测试软件产品的内部结构和处理过程。

二、功能测试怎么做

功能测试如何进行的:编写测试用例,测试用例当中最主要的是测试步骤和预期结果;测试人员根据测试用例执行操作步骤,然后通过眼睛和思考判断实际结果与预期结果是否相等。如果相等,测试通过;如果不相等,测试失败。

三、什么是接口测试

接口测试是测试系统组件间接口的一种测试。接口测试主要用于检测外部系统与系统之间以及内部各个子系统之间的交互点。测试的重点是要检查数据的交换,传递和控制管理过程,以及系统间的相互逻辑依赖关系等。

四、接口测试怎么做

接口测试可以通过接口测试工具或者接口测试脚本来进行测试。

接口测试工具:apipost、jmeter、laodrunner等

使用apipost进行接口测试示例:

python脚本进行接口测试示例:


五、功能测试和接口测试的区别

功能测试和接口测试的区别在于,功能测试侧重点在于前端ui界面,数据展示,图形界面,业务逻辑操作等,接口测试侧重点在于,后端返回的数据是否正确,接口是否正常。

接口测试工具下载地址:
ApiPost - www.apipost.cn

本文转载自: 掘金

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

领域驱动设计(DDD):项目目录(包、模块)结构 项目目录(

发表于 2020-11-27

项目目录(包、模块)结构

在项目的开发阶段,目录结构的划分往往被看做是迈向成功的第一步。这一步的迈出往往伴随着很多方面的权衡(考量),总的来说是两个方面的考量:业务方面和技术方面。

  • 业务方面的考量包括:限界上下文、子域、业务模块。
  • 技术方面的考量包括:软件架构(分层架构、六边形架构)、构造型分类。

目录结构构成

常见的项目的目录结构基本上由:领域名(domain)、层名(layer)、构造型名(stereotype)、业务模块名(module)这四个部分组成。

领域(业务域、子域)名称

在《领域驱动设计》中的领域通常是指一个业务域,是一个特定的业务范围。同类项目中的业务可能雷同,但对于大多数的项目要解决的业务(问题)来说不会超出所在业务域的范围,因此在项目的目录(包、模块)结构中包含业务域的名称能起到限界作用。比如:产品目录子域(Catalog)、订单子域(Order)、物流子域(Shipping)、发票子域(Invoice)等等。

分层架构中层次名称

在项目的目录结构中显式的引入层名是一种技术考量,更具体一些是编码的考量。分层架构是一种从混乱到有序的解决方案(架构模式)。它的做法是将一个应用程序(流程)划分为多组子任务,其中每组子任务都位于特定抽象层中。例如:分层架构在应用系统的后端开发中,常将一个应用系统划分为三层架构或者四层架构。

三层架构:

  • 表现层(Presentation)
  • 业务逻辑层(Business)
  • 持久层(Persistence)

四层架构:

  • 表现层(Presentation)
  • 应用(逻辑)层(Application)
  • 领域层(Domain)
  • 基础设施层(Infrastructure)

在四层架构中的基础设施层要比三层架构中的持久层的功能多一些。

在应用系统开发中的分层架构并不是严格意义上的分层架构。真正的分层表示为上层只能依赖下层,是单向依赖,不能存在双向依赖。具体来说有以下特点:

  • J 层依赖 J - 1 层,J + 1 层依赖 J 层。
  • J - 1 层不会依赖 J 层,J 层也不会依赖 J + 1 层。
  • J + 1 层也不会依赖 J - 1 层。

层与层之间通过数据的封装、转换或者直接使用来做到隔离。

在追求性能和灵活性方面,出现了两种分层变种:宽松的分层系统(Relaxed Layered System)和通过继承进行分层(Layering Through Inheritance)。我们将简要讨论 宽松的分层系统,因为普遍地应用系统采用的就是宽松的分层系统。

宽松的分层系统表示:每层都可以使用它的下层服务,而不仅仅是下一层的服务。每层都可能是半透明的,这意味着有些服务只对上一层可见,而有些服务对上面的所有层都可见。

relaxed-layers

就算是严格地分层架构或者宽松的分层架构,都表明是上层依赖下层。但在实际地应用系统的架构中并没有完全遵循这种依赖关系,因为架构人员需要在整体架构与分层架构之间进行摇摆球式思考。比如:在领域(Domain)层中的 Repository 接口的实现类往往会放在基础设施(Infrastructure)层中,这显然违反了分层架构中的单向依赖关系。这样的问题有两种解决方案:一是诚然接受。二是将领域层中的 Repository 接口的实现类放置在领域层内。

构造型名称(stereotype)

构造型使用书名号(<<>>)来表示,用于区分不同地建模元素。

stereotypes

如:实体(entity)、枚举(enumeration)、异常(exception)、查询(query)、事件(event)、资源库(repository)、服务(service)、控制器(controller)等等都是常见的构造型。

补充:在 UML1.4 及以后版本允许一个建模元素可以附加多个构造型。

有些项目在划分项目的目录结构时,会将构造型显式的引入到目录(包)结构中,这是一种归类的组织方式。

业务模块名称(module)

在分析一个业务(问题)域时,会将一个业务域划分为多个业务模块。比如在商店(Store)子域中会被划分为:商店员工(Staff)、商店会员(Member)、商店角色(Role)等等。

目录结构分类

在分别对目录(包)结构的构成元素做了简单介绍后,下面要开始具体探讨由这些元素组合而成的目录结构了。

  • 业务域名.层名.*
  • 业务域名.层名.业务模块名.*
  • 业务域名.构造型名.*
  • 业务域名.构造型名.业务模块名.*
  • 业务域名.(层名 & 构造型名).*
  • 业务域名.(层名 & 构造型名).业务模块名.*
  • 业务域名.业务模块名.*
  • 业务域名.业务模块名.层名.*
  • 业务域名.业务模块名.构造型名.*
  • 业务域名.业务模块名.(层名 & 构造型名).*
  • 业务域名.(业务模块名 & 层名 & 构造型名).*
  • (构造型名 || 层名).业务域名.业务模块名.*

补充说明:

  • 省略包(package)的反向域名(org.mallfoundry.*)前缀的部分。
  • 领域看作是业务域,其中业务域名是业务域 名,而不是业务 域名。
  • (层名 & 构造型名)是一种混合,表示在同一级别的目录(包)结构上同时存在按层和按构造型划分的两种方式。

目录结构:业务域名.层名.*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scss复制代码├─catalog            // 商品目录子域
│ ├─application
│ ├─domain
│ ├─infrastructure
│ └─presentation
├─order // 订单子域
│ ├─application
│ ├─domain
│ ├─infrastructure
│ └─presentation
└─store // 商家子域
├─application
├─domain
├─infrastructure
└─presentation

业务域名.层名.业务模块名.*

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
scss复制代码├─catalog            // 商品目录子域
│ ├─application // 应用层
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─domain // 领域层
│ │ ├─brand // 商品品牌模块
│ │ ├─category // 商品类目模块
│ │ ├─collection // 商品集合模块
│ │ └─product // 商品模块
│ ├─infrastructure // 基础设施层
│ │ └─persistent // 持久化
│ │ ├─jpa
│ │ ├─mybatis
│ │ └─redis
│ └─presentation // 表现层
│ ├─graphql
│ ├─grpc
│ ├─rest
│ ├─view
│ └─websocket
└─order
├─application
│ ├─dispute
│ ├─review
│ ├─shipping
│ └─source
├─domain
│ ├─dispute
│ ├─review
│ ├─shipping
│ └─source
├─infrastructure
└─presentation

业务域名.构造型名.*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
erlang复制代码├─catalog
│ ├─controller
│ ├─exception
│ ├─model
│ ├─query
│ ├─repository
│ └─service
└─order
├─controller
├─exception
├─model
├─query
├─repository
└─service

备注:

  • Model 包中中包含:实体、值对象、枚举等领域模型。在有些项目中会将 Model 包命名为:Pojo、Bean、Entity 等。
  • 在按构造型划分目录时还会存在:DTO、VO 等包结构。

业务域名.构造型名.业务模块名.*

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
css复制代码├─catalog
│ ├─controllers
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─exceptions
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─models
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─queries
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─repositories
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ └─services
│ ├─brand
│ ├─category
│ ├─collection
│ └─product
└─order
├─controllers
├─exceptions
├─models
├─queries
├─repositories
└─services

备注:在采用这种目录结构时,构造型名称常采用复数形式命名。

业务域名.(层名 & 构造型名).*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
erlang复制代码├─catalog
│ ├─controller
│ ├─dao
│ ├─exception
│ ├─model
│ ├─query
│ └─service
└─order
├─controller
├─dao
├─exception
├─model
├─query
└─service

备注:在目录(包)结构中采用(层名 & 构造型名)混合式的方式,常常出现在将分层架构与构造型混淆在一起的项目中。

  • Controller 代表表现层。
  • Service 代表业务逻辑层。
  • Dao 或者 Repository 代表数据访问层。
  • Model 代表领域模型。

业务域名.(层名 & 构造型名).业务模块名.*

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
erlang复制代码├─catalog
│ ├─controller
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─dao
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─exception
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─model
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ ├─query
│ │ ├─brand
│ │ ├─category
│ │ ├─collection
│ │ └─product
│ └─service
│ ├─brand
│ ├─category
│ ├─collection
│ └─product
└─order
├─controller
├─dao
├─exception
├─model
├─query
└─service

业务域名.业务模块名.*

1
2
3
4
5
6
7
8
9
10
bash复制代码├─catalog
│ ├─brand
│ ├─category
│ ├─collection
│ └─product
└─order
├─dispute
├─review
├─shipping
└─source

业务域名.业务模块名.层名.*

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
bash复制代码├─catalog
│ ├─brand
│ │ ├─application
│ │ ├─domain
│ │ ├─infrastructure
│ │ └─presentation
│ ├─category
│ │ ├─application
│ │ ├─domain
│ │ ├─infrastructure
│ │ └─presentation
│ ├─collection
│ │ ├─application
│ │ ├─domain
│ │ ├─infrastructure
│ │ └─presentation
│ └─product
│ ├─application
│ ├─domain
│ ├─infrastructure
│ └─presentation
└─order
├─dispute
├─review
├─shipping
└─source

备注:分层会在模块内部。

业务域名.业务模块名.构造型名.*

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
erlang复制代码├─catalog
│ ├─brand
│ │ ├─controller
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ ├─repository
│ │ └─service
│ ├─category
│ │ ├─controller
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ ├─repository
│ │ └─service
│ ├─collection
│ │ ├─controller
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ ├─repository
│ │ └─service
│ └─product
│ ├─controller
│ ├─exception
│ ├─model
│ ├─query
│ ├─repository
│ └─service
└─order
├─dispute
├─review
├─shipping
└─source

备注:构造型会在模块内部。

业务域名.业务模块名.(层名 & 构造型名).*

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
erlang复制代码├─catalog
│ ├─brand
│ │ ├─controller
│ │ ├─dao
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ └─service
│ ├─category
│ │ ├─controller
│ │ ├─dao
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ └─service
│ ├─collection
│ │ ├─controller
│ │ ├─dao
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ └─service
│ └─product
│ ├─controller
│ ├─dao
│ ├─exception
│ ├─model
│ ├─query
│ └─service
└─order
├─dispute
├─review
├─shipping
└─source

业务域名.(业务模块名 & 层名 & 构造型名).*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scss复制代码├─catalog
│ ├─brand
│ ├─category
│ ├─collection
│ └─product // Product 模块
│ ├─controller
│ ├─dao
│ ├─exception
│ ├─model
│ ├─query
│ ├─review // Product 模块内的 Review 模块
│ │ ├─controller
│ │ ├─dao
│ │ ├─exception
│ │ ├─model
│ │ ├─query
│ │ └─service
│ └─service
└─order
├─dispute
├─review
├─shipping
└─source

备注:(业务模块名 & 层名 & 构造型名)三者混合是一种项目目录(包)划分的方式。这种结构往往看上去会有些混乱。

(构造型名 || 层名).业务域名.业务模块名.*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scss复制代码├─catalog
│ ├─brand
│ ├─category
│ ├─collection
│ └─product
├─order
│ ├─dispute
│ ├─review
│ ├─shipping
│ └─source
└─rest // 发布 RESTful 接口。
├─catalog
│ ├─brand
│ ├─category
│ ├─collection
│ └─product
└─order
├─dispute
├─review
├─shipping
└─source

使用 Module 横向分割

在 Java 中会有 jar 的形式来组织模块(Module),我们可以使用 Module 先横向分割,然后在模块内部再划分目录结构。

先将产品目录(Catalog)子域和订单(Order)子域横向分割成四个模块:

  • catalog
  • catalog-rest
  • order
  • order-rest

Module:catalog

1
2
3
4
5
markdown复制代码└─org.mallfoundry.catalog
├─brand
├─category
├─collection
└─product

Module:catalog-rest

1
2
3
4
5
markdown复制代码└─org.mallfoundry.rest.catalog
├─brand
├─category
├─collection
└─product

Module:order

1
2
3
4
5
bash复制代码└─org.mallfoundry.order
├─dispute
├─review
├─shipping
└─source

Module:order-rest

1
2
3
4
5
bash复制代码└─org.mallfoundry.rest.order
├─dispute
├─review
├─shipping
└─source

重名混淆(业务模块名 & 层名 & 构造型名)

在初次浏览一个不熟悉的项目时,可能会对(业务模块名 & 层名 & 构造型名)这三种结构发生重名混淆。

在一个以业务模块名为主的目录(包)结构中出现像 .repository. 、 .dao. 这样的目录结构时,你可能瞬间想到是构造型或者分层。但是 .repository. 、 .dao. 最大可能只是在表示一个 repository 或者 dao 的业务模块。

总结

由:领域名(domain)、层名(layer)、构造型名(stereotype)、业务模块名(module)这四个部分组成的目录(包)结构是项目中常采用的。同时在划分目录结构时也可以使用 Module 先进行横向切割的方式。

本文转载自: 掘金

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

看了 5种分布式事务方案,我司最终选择了 Seata,真香!

发表于 2020-11-27

好长时间没发文了,最近着实是有点忙,当爹的第 43 天,身心疲惫。这又赶上年底,公司冲 KPI 强制技术部加班到十点,晚上孩子隔两三个小时一醒,基本没睡囫囵觉的机会,天天处于迷糊的状态,孩子还时不时起一些奇奇怪怪的疹子,总让人担惊受怕的。

本就不多的写文章时间又被无限分割,哎~ 打工人真是太难了。

小眼神几个意思

本来不知道写点啥,正好手头有个新项目试着用阿里的 Seata 中间件做分布式事务,那就做一个实践分享吧!

介绍 Seata 之前在简单回顾一下分布式事务的基本概念。

分布式事务的产生

我们先看看百度上对于分布式事务的定义:分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

额~ 有点抽象,简单的画个图好理解一下,拿下单减库存、扣余额来说举例:

当系统的体量很小时,单体架构完全可以满足现有业务需求,所有的业务共用一个数据库,整个下单流程或许只用在一个方法里同一个事务下操作数据库即可。此时做到所有操作要么全部提交 或 要么全部回滚很容易。

分库分表、SOA

可随着业务量的不断增长,单体架构渐渐扛不住巨大的流量,此时就需要对数据库、表做 分库分表处理,将应用 SOA 服务化拆分。也就产生了订单中心、用户中心、库存中心等,由此带来的问题就是业务间相互隔离,每个业务都维护着自己的数据库,数据的交换只能进行 RPC 调用。

当用户再次下单时,需同时对订单库 order、库存库 storage、用户库 account 进行操作,可此时我们只能保证自己本地的数据一致性,无法保证调用其他服务的操作是否成功,所以为了保证整个下单流程的数据一致性,就需要分布式事务介入。

Seata 优势

实现分布式事务的方案比较多,常见的比如基于 XA 协议的 2PC、3PC,基于业务层的 TCC,还有应用消息队列 + 消息表实现的最终一致性方案,还有今天要说的 Seata 中间件,下边看看各个方案的优缺点。

2PC

基于 XA 协议实现的分布式事务,XA 协议中分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往往由数据库实现,比如 Oracle、MYSQL 这些数据库都实现了 XA 接口,而事务管理器则作为一个全局的调度者。

两阶段提交(2PC),对业务侵⼊很小,它最⼤的优势就是对使⽤⽅透明,用户可以像使⽤本地事务⼀样使⽤基于 XA 协议的分布式事务,能够严格保障事务 ACID 特性。

可 2PC的缺点也是显而易见,它是一个强一致性的同步阻塞协议,事务执⾏过程中需要将所需资源全部锁定,也就是俗称的 刚性事务。所以它比较适⽤于执⾏时间确定的短事务,整体性能比较差。

一旦事务协调者宕机或者发生网络抖动,会让参与者一直处于锁定资源的状态或者只有一部分参与者提交成功,导致数据的不一致。因此,在⾼并发性能⾄上的场景中,基于 XA 协议的分布式事务并不是最佳选择。

3PC

三段提交(3PC)是二阶段提交(2PC)的一种改进版本 ,为解决两阶段提交协议的阻塞问题,上边提到两段提交,当协调者崩溃时,参与者不能做出最后的选择,就会一直保持阻塞锁定资源。

2PC 中只有协调者有超时机制,3PC 在协调者和参与者中都引入了超时机制,协调者出现故障后,参与者就不会一直阻塞。而且在第一阶段和第二阶段中又插入了一个准备阶段(如下图,看着有点啰嗦),保证了在最后提交阶段之前各参与节点的状态是一致的。

虽然 3PC 用超时机制,解决了协调者故障后参与者的阻塞问题,但与此同时却多了一次网络通信,性能上反而变得更差,也不太推荐。

TCC

所谓的 TCC 编程模式,也是两阶段提交的一个变种,不同的是 TCC 为在业务层编写代码实现的两阶段提交。TCC 分别指 Try、Confirm、Cancel ,一个业务操作要对应的写这三个方法。

以下单扣库存为例,Try 阶段去占库存,Confirm 阶段则实际扣库存,如果库存扣减失败 Cancel 阶段进行回滚,释放库存。

TCC 不存在资源阻塞的问题,因为每个方法都直接进行事务的提交,一旦出现异常通过则 Cancel 来进行回滚补偿,这也就是常说的补偿性事务。

原本一个方法,现在却需要三个方法来支持,可以看到 TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增。还要考虑到网络波动等原因,为保证请求一定送达都会有重试机制,所以考虑到接口的幂等性。

消息事务(最终一致性)

消息事务其实就是基于消息中间件的两阶段提交,将本地事务和发消息放在同一个事务里,保证本地操作和发送消息同时成功。
下单扣库存原理图:

  • 订单系统向 MQ 发送一条预备扣减库存消息,MQ 保存预备消息并返回成功 ACK
  • 接收到预备消息执行成功 ACK,订单系统执行本地下单操作,为防止消息发送成功而本地事务失败,订单系统会实现 MQ 的回调接口,其内不断的检查本地事务是否执行成功,如果失败则 rollback 回滚预备消息;成功则对消息进行最终 commit 提交。
  • 库存系统消费扣减库存消息,执行本地事务,如果扣减失败,消息会重新投,一旦超出重试次数,则本地表持久化失败消息,并启动定时任务做补偿。

基于消息中间件的两阶段提交方案,通常用在高并发场景下使用,牺牲数据的强一致性换取性能的大幅提升,不过实现这种方式的成本和复杂度是比较高的,还要看实际业务情况。

Seata

Seata 也是从两段提交演变而来的一种分布式事务解决方案,提供了 AT、TCC、SAGA 和 XA 等事务模式,这里重点介绍 AT模式。

既然 Seata 是两段提交,那我们看看它在每个阶段都做了点啥?下边我们还以下单扣库存、扣余额举例。

先介绍 Seata 分布式事务的几种角色:

  • Transaction Coordinator(TC): 全局事务协调者,用来协调全局事务和各个分支事务(不同服务)的状态, 驱动全局事务和各个分支事务的回滚或提交。
  • Transaction Manager™ : 事务管理者,业务层中用来开启/提交/回滚一个整体事务(在调用服务的方法中用注解开启事务)。
  • Resource Manager(RM): 资源管理者,一般指业务数据库代表了一个分支事务(Branch Transaction),管理分支事务与 TC 进行协调注册分支事务并且汇报分支事务的状态,驱动分支事务的提交或回滚。

Seata 实现分布式事务,设计了一个关键角色 UNDO_LOG (回滚日志记录表),我们在每个应用分布式事务的业务库中创建这张表,这个表的核心作用就是,将业务数据在更新前后的数据镜像组织成回滚日志,备份在 UNDO_LOG 表中,以便业务异常能随时回滚。

第一个阶段

比如:下边我们更新 user 表的 name 字段。

1
sql复制代码update user set name = '小富最帅' where name = '程序员内点事'

首先 Seata 的 JDBC 数据源代理通过对业务 SQL 解析,提取 SQL 的元数据,也就是得到 SQL 的类型(UPDATE),表(user),条件(where name = '程序员内点事')等相关的信息。

第一个阶段的流程图

先查询数据前镜像,根据解析得到的条件信息,生成查询语句,定位一条数据。

1
sql复制代码select  name from user where name = '程序员内点事'

数据前镜像

紧接着执行业务 SQL,根据前镜像数据主键查询出后镜像数据

1
sql复制代码select name from user where id = 1

数据后镜像

把业务数据在更新前后的数据镜像组织成回滚日志,将业务数据的更新和回滚日志在同一个本地事务中提交,分别插入到业务表和 UNDO_LOG 表中。

回滚记录数据格式如下:包括 afterImage 前镜像、beforeImage 后镜像、 branchId 分支事务ID、xid 全局事务ID

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
json复制代码{
"branchId":641789253,
"xid":"xid:xxx",
"undoItems":[
{
"afterImage":{
"rows":[
{
"fields":[
{
"name":"id",
"type":4,
"value":1
}
]
}
],
"tableName":"product"
},
"beforeImage":{
"rows":[
{
"fields":[
{
"name":"id",
"type":4,
"value":1
}
]
}
],
"tableName":"product"
},
"sqlType":"UPDATE"
}
]
}

这样就可以保证,任何提交的业务数据的更新一定有相应的回滚日志。

在本地事务提交前,各分支事务需向 全局事务协调者 TC 注册分支 ( Branch Id) ,为要修改的记录申请 全局锁 ,要为这条数据加锁,利用 SELECT FOR UPDATE 语句。而如果一直拿不到锁那就需要回滚本地事务。TM 开启事务后会生成全局唯一的 XID,会在各个调用的服务间进行传递。

有了这样的机制,本地事务分支(Branch Transaction)便可以在全局事务的第一阶段提交,并马上释放本地事务锁定的资源。相比于传统的 XA 事务在第二阶段释放资源,Seata 降低了锁范围提高效率,即使第二阶段发生异常需要回滚,也可以快速 从UNDO_LOG 表中找到对应回滚数据并反解析成 SQL 来达到回滚补偿。

最后本地事务提交,业务数据的更新和前面生成的 UNDO LOG 数据一并提交,并将本地事务提交的结果上报给全局事务协调者 TC。

第二个阶段

第二阶段是根据各分支的决议做提交或回滚:

如果决议是全局提交,此时各分支事务已提交并成功,这时 全局事务协调者(TC) 会向分支发送第二阶段的请求。收到 TC 的分支提交请求,该请求会被放入一个异步任务队列中,并马上返回提交成功结果给 TC。异步队列中会异步和批量地根据 Branch ID 查找并删除相应 UNDO LOG 回滚记录。

如果决议是全局回滚,过程比全局提交麻烦一点,RM 服务方收到 TC 全局协调者发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。

注意:这里删除回滚日志记录操作,一定是在本地业务事务执行之后

上边说了几种分布式事务各自的优缺点,下边实践一下分布式事务中间 Seata 感受一下。

Seata 实践

Seata 是一个需独立部署的中间件,所以先搭 Seata Server,这里以最新的 seata-server-1.4.0 版本为例,下载地址:https://seata.io/en-us/blog/download.html

解压后的文件我们只需要关心 \seata\conf 目录下的 file.conf 和 registry.conf 文件。

Seata Server

file.conf

file.conf 文件用于配置持久化事务日志的模式,目前提供 file、db、redis 三种方式。

file.conf 文件配置

注意:在选择 db 方式后,需要在对应数据库创建 globalTable(持久化全局事务)、branchTable(持久化各提交分支的事务)、 lockTable(持久化各分支锁定资源事务)三张表。

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
48
49
50
51
52
53
54
55
56
57
58
sql复制代码-- the table to store GlobalSession data
-- 持久化全局事务
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

-- the table to store BranchSession data
-- 持久化各提交分支的事务
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

-- the table to store lock data
-- 持久化每个分支锁表事务
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

registry.conf

registry.conf 文件设置 注册中心 和 配置中心:

目前注册中心支持 nacos 、eureka、redis、zk、consul、etcd3、sofa 七种,这里我使用的 eureka作为注册中心 ; 配置中心支持 nacos 、apollo、zk、consul、etcd3 五种方式。

registry.conf 文件配置

配置完以后在 \seata\bin 目录下启动 seata-server 即可,到这 Seata 的服务端就搭建好了。

Seata Client

Seata Server 环境搭建完,接下来我们新建三个服务 order-server(下单服务)、storage-server(扣减库存服务)、account-server(账户金额服务),分别服务注册到 eureka。

每个服务的大体核心配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码spring:
application:
name: storage-server
cloud:
alibaba:
seata:
tx-service-group: my_test_tx_group
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://47.93.6.1:3306/seat-storage
username: root
password: root

# eureka 注册中心
eureka:
client:
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:8761/eureka/
instance:
hostname: 47.93.6.5
prefer-ip-address: true

业务大致流程:用户发起下单请求,本地 order 订单服务创建订单记录,并通过 RPC 远程调用 storage 扣减库存服务和 account 扣账户余额服务,只有三个服务同时执行成功,才是一个完整的下单流程。如果某个服执行失败,则其他服务全部回滚。

Seata 对业务代码的侵入性非常小,代码中使用只需用 @GlobalTransactional 注解开启一个全局事务即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Override
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void create(Order order) {

String xid = RootContext.getXID();

LOGGER.info("------->交易开始");
//本地方法
orderDao.create(order);

//远程方法 扣减库存
storageApi.decrease(order.getProductId(), order.getCount());

//远程方法 扣减账户余额
LOGGER.info("------->扣减账户开始order中");
accountApi.decrease(order.getUserId(), order.getMoney());
LOGGER.info("------->扣减账户结束order中");

LOGGER.info("------->交易结束");
LOGGER.info("全局事务 xid: {}", xid);
}

前边说过 Seata AT 模式实现分布式事务,必须在相关的业务库中创建 undo_log 表来存数据回滚日志,表结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

到这环境搭建的工作就完事了,完整案例会在后边贴出 GitHub 地址,就不在这占用篇幅了。

测试 Seata

项目中的服务调用过程如下图:

服务调用过程

启动各个服务后,我们直接请求下单接口看看效果,只要 order 订单表创建记录成功,storage 库存表 used 字段数量递增、account 余额表 used 字段数量递增则表示下单流程成功。

原始数据

请求后正向流程是没问题的,数据和预想的一样

下单数据

而且发现 TM 事务管理者 order-server 服务的控制台也打印出了两阶段提交的日志

控制台两次提交

那么再看看如果其中一个服务异常,会不会正常回滚呢?在 account-server 服务中模拟超时异常,看能否实现全局事务回滚。

全局事务回滚

发现数据全没执行成功,说明全局事务回滚也成功了

那看一下 undo_log 回滚记录表的变化情况,由于 Seata 删除回滚日志的速度很快,所以要想在表中看见回滚日志,必须要在某一个服务上打断点才看的更明显。

回滚记录

总结

上边简单介绍了 2PC、3PC、TCC、MQ、Seata 这五种分布式事务解决方案,还详细的实践了 Seata 中间件。但不管我们选哪一种方案,在项目中应用都要谨慎再谨慎,除特定的数据强一致性场景外,能不用尽量就不要用,因为无论它们性能如何优越,一旦项目套上分布式事务,整体效率会几倍的下降,在高并发情况下弊端尤为明显。

本案例 github 地址:github.com/chengxy-nds…

如果有一丝收获,欢迎 点赞、转发 ,您的认可是我最大的动力。

整理了几百本各类技术电子书,有需要的同学可以,关注公号回复 [ 666 ] 自取。还有想要加技术群的可以加我好友,和大佬侃技术、不定期内推,一起学起来。

本文转载自: 掘金

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

1…761762763…956

开发者博客

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