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

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


  • 首页

  • 归档

  • 搜索

一文读懂 SharedPreferences 的缺陷及一点点

发表于 2021-02-23

公众号:字节数组

希望对你有所帮助 🤣🤣

SharedPreferences 是系统提供的一个适合用于存储少量键值对数据的持久化存储方案,结构简单,使用方便,很多应用都会使用到。另一方面,SharedPreferences 存在的问题也挺多的,当中 ANR 问题就屡见不鲜,字节跳动技术团队就曾经发布过一篇文章专门来阐述该问题:剖析 SharedPreference apply 引起的 ANR 问题。到了现在,Google Jetpack 也推出了一套新的持久化存储方案:DataStore,大有取代 SharedPreferences 的趋势

本文就结合源码来剖析 SharedPreferences 存在的缺陷以及背后的具体原因,基于 SDK 30 进行分析,让读者做到知其然也知其所以然,并在最后介绍下我个人的一种存储机制设计方案,希望对你有所帮助 🤣🤣

不得不说的坑

会一直占用内存

SharedPreferences 本身是一个接口,具体的实现类是 SharedPreferencesImpl,Context 中各个和 SP 相关的方法都是由 ContextImpl 来实现的。我们项目中的每个 SP 或多或少都是保存着一些键值对,而每当我们获取到一个 SP 对象时,其对应的数据就会一直被保留在内存中,直到应用进程被终结,因为每个 SP 对象都被系统作为静态变量缓存起来了,对应 ContextImpl 中的静态变量 sSharedPrefsCache

1
2
3
4
5
6
7
8
9
10
java复制代码class ContextImpl extends Context {

//先根据应用包名缓存所有 SharedPreferences
//再根据 xmlFile 和具体的 SharedPreferencesImpl 对应上
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

//根据 fileName 拿到对应的 xmlFile
private ArrayMap<String, File> mSharedPrefsPaths;

}

每个 SP 都对应一个本地磁盘中的 xmlFile,fileName 则是由开发者来显式指定的,每个 xmlFile 都对应一个 SharedPreferencesImpl。所以 ContextImpl 的逻辑是先根据 fileName 拿到 xmlFile,再根据 xmlFile 拿到 SharedPreferencesImpl,最终应用内所有的 SharedPreferencesImpl 就都会被缓存在 sSharedPrefsCache 这个静态变量中。此外,由于 SharedPreferencesImpl 在初始化后就会自动去加载 xmlFile 中的所有键值对数据,而 ContextImpl 内部并没有看到有清理 sSharedPrefsCache 缓存的逻辑,所以 sSharedPrefsCache 会被一直保留在内存中直到进程终结,其内存大小会随着我们引用到的 SP 增多而加大,这就可能会持续占用很大一块内存空间

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
java复制代码    @Override
public SharedPreferences getSharedPreferences(String name, int mode) {
···
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}

@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
···
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
···
return sp;
}

@GuardedBy("ContextImpl.class")
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
if (sSharedPrefsCache == null) {
sSharedPrefsCache = new ArrayMap<>();
}
final String packageName = getPackageName();
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}
return packagePrefs;
}

getValue 可能导致线程阻塞

SharedPreferencesImpl 在构造函数中直接就启动了一个子线程去加载磁盘文件,这意味着该操作是一个异步操作(我好像在说废话),如果文件很大或者线程调度系统没有马上启动该线程的话,那么该操作就需要一小段时间后才能执行完毕

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
java复制代码final class SharedPreferencesImpl implements SharedPreferences {

@UnsupportedAppUsage
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
startLoadFromDisk();
}

@UnsupportedAppUsage
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
//加载磁盘文件
loadFromDisk();
}
}.start();
}

}

而如果我们在初始化 SharedPreferencesImpl 后紧接着就去 getValue 的话,势必也需要确保子线程已经加载完成后才去进行取值操作,所以 SharedPreferencesImpl 就通过在每个 getValue 方法中调用 awaitLoadedLocked()方法来判断是否需要阻塞外部线程,确保取值操作一定会在子线程执行完毕后才执行。loadFromDisk()方法会在任务执行完毕后调用 mLock.notifyAll()唤醒所有被阻塞的线程

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
java复制代码    @Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
//判断是否需要让外部线程等待
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}

@GuardedBy("mLock")
private void awaitLoadedLocked() {
if (!mLoaded) {
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
//还未加载线程,让外部线程暂停等待
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}

private void loadFromDisk() {
···
synchronized (mLock) {
mLoaded = true;
mThrowable = thrown;
try {
if (thrown == null) {
if (map != null) {
mMap = map;
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<>();
}
}
} catch (Throwable t) {
mThrowable = t;
} finally {
//唤醒所有被阻塞的线程
mLock.notifyAll();
}
}
}

所以说,如果 SP 存储的数据量很大的话,那么就有可能导致外部的调用者线程被阻塞,严重时甚至可能导致 ANR。当然,这种可能性也只是发生在加载磁盘文件完成之前,当加载完成后 awaitLoadedLocked()方法自然不会阻塞线程

getValue 不保证数据类型安全

以下代码在编译阶段是完全正常的,但在运行时就会抛出异常:java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String。很明显,这是由于同个 key 先后对应了不同数据类型导致的,SharedPreferences 没有办法对这种操作做出限制,完全需要依赖于开发者自己的代码规范来进行限制

1
2
3
4
5
6
kotlin复制代码val sharedPreferences: SharedPreferences = getSharedPreferences("UserInfo", Context.MODE_PRIVATE)
val key = "userName"
val edit = sharedPreferences.edit()
edit.putInt(key, 11)
edit.apply()
val name = sharedPreferences.getString(key, "")

不支持多进程数据共享

在获取 SP 实例的时候需要传入一个 int 类型的 mode 标记位参数,存在一个和多进程相关的标记位 MODE_MULTI_PROCESS,该标记位能起到一定程度的多进程数据同步的保障,但作用不大,且并不保证多进程并发安全性

1
kotlin复制代码val sharedPreferences: SharedPreferences = getSharedPreferences("UserInfo", Context.MODE_MULTI_PROCESS)

上文有讲到,SharedPreferencesImpl 在被加载后就会一直保留在内存中,之后每次获取都是直接使用缓存数据,通常情况下也不会再次去加载磁盘文件。而 MODE_MULTI_PROCESS 起到的作用就是每当再一次去获取 SP 实例时,会判断当前磁盘文件相对最后一次内存修改是否被改动过了,如果是的话就主动去重新加载磁盘文件,从而可以做到在多进程环境下一定的数据同步

但是,这种同步本身作用不大,因为即使此时重新加载磁盘文件了,后续修改 SP 值时不同进程中的内存数据也不会实时同步,且多进程同时修改 SP 值也存在数据丢失和数据覆盖的可能。所以说,SP 并不支持多进程数据共享,MODE_MULTI_PROCESS 也已经被废弃了,其注释也推荐使用 ContentProvider 来实现跨进程通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码class ContextImpl extends Context {

@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
···
}
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
//重新去加载磁盘文件
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}

}

不支持增量更新

我们知道,SP 提交数据的方法有两个:commit() 和 apply(),分别对应着同步修改和异步修改,而这两种方式对应的都是全量更新,SP 以文件为最小单位进行修改,即使我们只修改了一个键值对,这两个方法也会将所有键值对数据重新写入到磁盘文件中,即 SP 只支持全量更新

我们平时获取到的 Editor 对象,对应的都是 SharedPreferencesImpl 的内部类 EditorImpl。EditorImpl 的每个 putValue 方法都会将传进来的 key-value 保存在 mModified 中,暂时还没有涉及任何文件改动。比较特殊的是 remove 和 clear 两个方法,remove 方法会将 this 作为键值对的 value,后续就通过对比 value 的相等性来知道是要移除键值对还是修改键值对,clear 方法则只是将 mClear 标记位置为 true

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复制代码public final class EditorImpl implements Editor {

private final Object mEditorLock = new Object();

@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();

@GuardedBy("mEditorLock")
private boolean mClear = false;

@Override
public Editor putString(String key, @Nullable String value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}

@Override
public Editor remove(String key) {
synchronized (mEditorLock) {
//存入当前的 EditorImpl 对象
mModified.put(key, this);
return this;
}
}

@Override
public Editor clear() {
synchronized (mEditorLock) {
mClear = true;
return this;
}
}

}

commit() 和apply()两个方法都会通过调用 commitToMemory()方法拿到修改后的全量数据

commitToMemory()采用了 diff 算法,SP 包含的所有键值对数据都存储在 mapToWriteToDisk 中,Editor 改动到的所有键值对数据都存储在 mModified 中。如果 mClear 为 true,则会先清空 mapToWriteToDisk,然后再遍历 mModified,将 mModified 中的所有改动都同步给 mapToWriteToDisk。最终 mapToWriteToDisk 就保存了要重新写入到磁盘文件中的全量数据,SP 会根据 mapToWriteToDisk 完全覆盖掉旧的 xml 文件

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
java复制代码        // Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
// We optimistically don't make a deep copy until
// a memory commit comes in when we're already
// writing to disk.
if (mDiskWritesInFlight > 0) {
// We can't modify our mMap as a currently
// in-flight write owns it. Clone it before
// modifying it.
// noinspection unchecked
mMap = new HashMap<String, Object>(mMap);
}
//拿到内存中的全量数据
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
//用于标记最终是否改动到了 mapToWriteToDisk
boolean changesMade = false;
if (mClear) {
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
//清空所有在内存中的数据
mapToWriteToDisk.clear();
}
keysCleared = true;
//恢复状态,避免二次修改时状态错位
mClear = false;
}
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
// "this" is the magic value for a removal mutation. In addition,
// setting a value to "null" for a given key is specified to be
// equivalent to calling remove on that key.
if (v == this || v == null) { //意味着要移除该键值对
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else { //对应修改键值对值的情况
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
//只有在的确是修改了或新插入键值对的情况才需要保存值
mapToWriteToDisk.put(k, v);
}
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
//恢复状态,避免二次修改时状态错位
mModified.clear();
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
}

clear 的反直觉用法

看以下例子。按照语义分析的话,最终 SP 中应该是只剩下 blog 一个键值对才符合直觉,而实际上最终两个键值对都会被保留,且只有这两个键值对被保留下来

1
2
3
4
kotlin复制代码val sharedPreferences: SharedPreferences = getSharedPreferences("UserInfo", Context.MODE_PRIVATE)
val edit = sharedPreferences.edit()
edit.putString("name", "业志陈").clear().putString("blog", "https://juejin.cn/user/923245496518439")
edit.apply()

造成该问题的原因还需要看commitToMemory()方法。clear()会将 mClear 置为 true,所以在执行到第一步的时候就会将内存中的所有键值对数据 mapToWriteToDisk 清空。当执行到第二步的时候,mModified 中的所有数据就都会同步到 mapToWriteToDisk 中,从而导致最终 name 和 blog 两个键值对都会被保留下来,其它键值对都被移除了

所以说,Editor.clear() 之前不应该连贯调用 putValue 语句,这会造成理解和实际效果之间的偏差

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
java复制代码        // Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
// We optimistically don't make a deep copy until
// a memory commit comes in when we're already
// writing to disk.
if (mDiskWritesInFlight > 0) {
// We can't modify our mMap as a currently
// in-flight write owns it. Clone it before
// modifying it.
// noinspection unchecked
mMap = new HashMap<String, Object>(mMap);
}
//拿到内存中的全量数据
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
boolean changesMade = false;
if (mClear) { //第一步
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
//清空所有在内存中的数据
mapToWriteToDisk.clear();
}
keysCleared = true;
//恢复状态,避免二次修改时状态错位
mClear = false;
}
for (Map.Entry<String, Object> e : mModified.entrySet()) { //第二步
String k = e.getKey();
Object v = e.getValue();
// "this" is the magic value for a removal mutation. In addition,
// setting a value to "null" for a given key is specified to be
// equivalent to calling remove on that key.
if (v == this || v == null) { //意味着要移除该键值对
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else { //对应修改键值对值的情况
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
//只有在的确是修改了或新插入键值对的情况才需要保存值
mapToWriteToDisk.put(k, v);
}
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
//恢复状态,避免二次修改时状态错位
mModified.clear();
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
}

commit、applay 可能导致 ANR

commit() 方法会通过 commitToMemory() 方法拿到本次修改后的全量数据,即 MemoryCommitResult,然后向 enqueueDiskWrite 方法提交将全量数据写入磁盘文件的任务,在写入完成前调用者线程都会由于 CountDownLatch 一直阻塞等待着,方法返回值即本次修改操作的成功状态

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复制代码        @Override
public boolean commit() {
long startTime = 0;
if (DEBUG) {
startTime = System.currentTimeMillis();
}
//拿到修改后的全量数据
MemoryCommitResult mcr = commitToMemory();
//提交写入磁盘文件的任务
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//阻塞等待,直到 xml 文件写入完成(不管成功与否)
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}

enqueueDiskWrite 方法就是包含了具体的磁盘写入逻辑的地方了,由于外部可能存在多个线程在同时执行 apply() 和 commit() 两个方法,而对应的磁盘文件只有一个,所以 enqueueDiskWrite 方法就必须保证写入操作的有序性,避免数据丢失或者覆盖,甚至是文件损坏

enqueueDiskWrite 方法的具体逻辑:

  1. writeToDiskRunnable 使用到了内部锁 mWritingToDiskLock 来保证 writeToFile 操作的有序性,避免多线程竞争
  2. 对于 commit 操作,如果当前只有一个线程在执行提交修改的操作的话,那么直接在该线程上执行 writeToDiskRunnable,流程结束
  3. 对于其他情况(apply 操作、多线程同时 commit 或者 apply),都会将 writeToDiskRunnable 提交给 QueuedWork 执行
  4. QueuedWork 内部使用到了 HandlerThread 来执行 writeToDiskRunnable,HandlerThread 本身也可以保证多个任务执行时的有序性
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
java复制代码    private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
//写入磁盘文件
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) { //commit() 方法会走进这里面
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
//wasEmpty 为 true 说明当前只有一个线程在执行提交操作,那么就直接在此线程上完成任务
writeToDiskRunnable.run();
return;
}
}
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

此外,还有一个比较重要的知识点需要注意下。在 writeToFile 方法中会对本次任务进行校验,避免连续多次执行无效的磁盘任务。当中,mDiskStateGeneration 代表的是最后一次成功写入磁盘文件时的任务版本号,mCurrentMemoryStateGeneration 是当前内存中最新的修改记录版本号,mcr.memoryStateGeneration 是本次要执行的任务的版本号。通过两次版本号的对比,就避免了在连续多次 commit 或者 apply 时造成重复执行 I/O 操作的情况,而是只会执行最后一次,避免了无效的 I/O 任务

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
java复制代码    @GuardedBy("mWritingToDiskLock")
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
···
if (fileExists) {
boolean needsWrite = false;

// Only need to write if the disk state is older than this commit
//判断版本号
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
if (isFromSyncCommit) {
needsWrite = true;
} else {
synchronized (mLock) {
// No need to persist intermediate states. Just wait for the latest state to
// be persisted.
//判断版本号
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}

if (!needsWrite) {
//当前版本号并非最新,无需执行,直接返回即可
mcr.setDiskWriteResult(false, true);
return;
}
···
}

再回过头看 commit() 方法。不管该方法关联的 writeToDiskRunnable 最终是在本线程还是 HandlerThread 中执行,await()方法都会使得本线程阻塞等待直到 writeToDiskRunnable 执行完毕,从而实现了 commit()同步提交的效果

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复制代码        @Override
public boolean commit() {
long startTime = 0;
if (DEBUG) {
startTime = System.currentTimeMillis();
}
//拿到修改后的全量数据
MemoryCommitResult mcr = commitToMemory();
//提交写入磁盘文件的任务
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
//阻塞等待,直到 xml 文件写入完成(不管成功与否)
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}

而对于 apply() 方法,其本身具有异步提交的含义,I/O 操作应该都是交由给了子线程来执行才对,按道理来说只需要调用 enqueueDiskWrite 方法提交任务且不等待任务完成即可,可实际上apply()方法反而要比commit()方法复杂得多

apply()方法包含一个 awaitCommit 任务,用于阻塞其执行线程直到磁盘任务执行完毕,而 awaitCommit 又被包裹在 postWriteRunnable 中一起提交给了 enqueueDiskWrite 方法,enqueueDiskWrite 方法又会在 writeToDiskRunnable 执行完毕后执行 enqueueDiskWrite

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
java复制代码        @Override
public void apply() {
final long startTime = System.currentTimeMillis();

final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
//阻塞线程直到磁盘任务执行完毕
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}

if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};

QueuedWork.addFinisher(awaitCommit);

Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};

//提交任务
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}

单独看以上逻辑会显得十分奇怪,从上文就可以得知 writeToDiskRunnable 最终是会交由 HandlerThread 来执行的,那按照流程看 awaitCommit 最终也是会由 HandlerThread 调用,那么 awaitCommit 的等待操作就显得十分奇怪了,因为 awaitCommit 肯定是会在磁盘任务执行完毕才被调用,就相当于 HandlerThread 在自己等待自己执行完毕。此外,HandlerThread 属于子线程,按道理来说子线程即使执行了耗时操作也不会导致主线程 ANR 才对

要理解以上操作,还需要再看看 ActivityThread 这个类。当 Service 和 Activity 的生命周期处于 handleStopService() 、handlePauseActivity() 、handleStopActivity() 的时候,ActivityThread 会调用 QueuedWork.waitToFinish() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    private void handleStopService(IBinder token) {
Service s = mServices.remove(token);
if (s != null) {
try {
···
//重点
QueuedWork.waitToFinish();
···
} catch (Exception e) {
···
}
} else {
Slog.i(TAG, "handleStopService: token=" + token + " not found.");
}
//Slog.i(TAG, "Running services: " + mServices);
}

QueuedWork.waitToFinish() 方法会主动去执行所有的磁盘写入任务,并执行所有的 postWriteRunnable,这就造成了 Activity 或 Service 在切换生命周期的过程中有可能因为存在大量的磁盘写入任务而被阻塞住,最终导致 ANR

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
java复制代码    public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;
Handler handler = getHandler();
synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);
if (DEBUG) {
hadMessages = true;
Log.d(LOG_TAG, "waiting");
}
}
// We should not delay any work as this might delay the finishers
sCanDelay = false;
}
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
//执行所有的磁盘写入任务
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
try {
//执行所有的 postWriteRunnable
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run();
}
} finally {
sCanDelay = true;
}
synchronized (sLock) {
long waitTime = System.currentTimeMillis() - startTime;
if (waitTime > 0 || hadMessages) {
mWaitTimes.add(Long.valueOf(waitTime).intValue());
mNumWaits++;
if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
mWaitTimes.log(LOG_TAG, "waited: ");
}
}
}
}

ActivityThread 为什么要主动去触发执行所有的磁盘写入任务我无从得知,字节技术跳动团队给出的猜测是:Google 在 Activity 和 Service 调用 onStop 之前阻塞主线程来处理 SP,我们能猜到的唯一原因是尽可能的保证数据的持久化。因为如果在运行过程中产生了 crash,也会导致 SP 未持久化,持久化本身是 IO 操作,也会失败

综上所述,由于 SP 本身只支持全量更新,如果 SP 文件很大,即使是小数据量的 apply/commit 操作也有可能导致 ANR

正反面

SharedPreferencesImpl 在不同的系统版本中有着比较大的差别,例如 writeToFile 方法对于任务版本号的校验也是从 8.0 系统开始的,在 8.0 系统之前对于连续的 commit 和 apply 每次都会触发 I/O 操作,所以在 8.0 系统之前 ANR 问题会更加容易复现。我们需要根据系统版本来看待以上列举出来的各个缺陷

需要强调的是,SP 本身的定位是轻量级数据存储,设计初衷是用于存储简单的数据结构(基本数据类型),且提供了按模块分区存储的功能。如果开发者能够严格遵守这一个规范的话,那么其实以上所述的很多“缺陷”都是可以避免的。而 SP 之所以现在看起来问题很多,也是因为如今大部分应用的业务比以前复杂太多了,有些时候为了方便就直接用来存储非常复杂的数据结构,或者是没有做好数据分区存储,导致单个文件过大,这才是造成问题的主要原因

如何做好持久化

以下的示例代码估计是很多开发者的噩梦

1
2
kotlin复制代码val sharedPreference = getSharedPreferences("user_preference", Context.MODE_PRIVATE)
val name = sharedPreference.getString("name", "")

以上代码存在什么问题呢?我觉得至少有五点:

  • 强引用到了 SP,导致后续需要切换存储库时需要全局搜索替换,工作量非常大
  • key 值难维护,每次获取 value 时都需要显式声明 key 值
  • 可读性差,键值对的含义基本只能靠 key 值进行表示
  • 只支持基本数据类型,在存取自定义数据类型时存在很多重复工作。要向 SP 存入自定义的 JavaBean 对象时,只能将 Bean 对象转为 Json 字符串后存入 SP,在取值时再手动反序列化
  • 数据类型不明确,基本只能靠注释来引导开发者使用正确的数据类型

开发者往往是会声明出各种 SpUtils 类进行多一层封装,但也没法彻底解决以上问题。SP 的确是存在着一些设计缺陷,但对于大部分应用开发者来说其实并没有多少选择,我们只能选择用或者不用,并没有多少余地可以来解决或者避免其存在的问题,我们往往只能在遇到问题后切换到其它的持久化存储方案

目前有两个比较知名的持久化存储方案:Jetpack DataStore 和腾讯的 MMKV,我们当然可以选择将项目中的 SP 切换为这两个库之一,但这也不禁让人想到一个问题,如果以后这两个库也遇到了问题甚至是直接被废弃了,难道我们又需要再来全局替换一遍吗?我们应该如何设计才能使得每次的替换成本降到最低呢?

在我看来,开发者在为项目引入一个新的依赖库之前就应该为以后移除该库做好准备,做好接口隔离,屏蔽具体的底层逻辑(当然,也不是每个依赖库都可以做到)。笔者的项目之前也是使用 SP 来存储配置信息,后来我也将其切换到了 MMKV,下面就来介绍下笔者当时是如何设计存储结构避免硬编码的

目前的效果

我将应用内所有需要存储的键值对数据分为了三类:用户强关联数据、应用配置数据、不可二次变更的数据。每一类数据的存储区域各不相同,互不影响。进行数据分组的好处就在于可以根据需要来清除特定数据,例如当用户退登后我们可以只清除 UserKVHolder,而 PreferenceKVHolder 和 FinalKVHolder 则可以一直保留

IKVHolder 接口定义了基本的存取方法,MMKVKVHolder 通过 MMKV 实现了具体的存储逻辑

1
2
3
4
5
6
7
8
9
kotlin复制代码//和用户强绑定的数据,在退出登录时需要全部清除,例如 UserBean
//设置 encryptKey 以便加密存储
private val UserKVHolder: IKVHolder = MMKVKVHolder("user", "加密key")

//和用户不强关联的数据,在退出登录时无需清除,例如夜间模式、字体大小等
private val PreferenceKVHolder: IKVHolder = MMKVKVHolder("preference")

//用于存储不会二次变更只用于历史溯源的数据,例如应用首次安装的时间、版本号、版本名等
private val FinalKVHolder: IKVHolder = MMKVKVFinalHolder("final")

之后我们就可以利用 Kotlin 强大的语法特性来定义键值对了

例如,对于和用户强关联的数据,每个键值对都定义为 UserKV 的一个属性字段,键值对的含义和作用通过属性名来进行标识,且键值对的 key 必须和属性名保持一致,这样可以避免 key 值重复。每个 getValue 操作也都支持设置默认值。IKVHolder 内部通过 Gson 来实现序列化和反序列化,这样 UserKV 就可以直接存储 JavaBean、JavaBeanList,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
28
29
30
kotlin复制代码object UserKV : IKVHolder by UserKVHolder {

var name: String
get() = get("name", "")
set(value) = set("name", value)

var blog: String
get() = get("blog", "")
set(value) = set("blog", value)

var userBean: UserBean?
get() = getBeanOrNull("userBean")
set(value) = set("userBean", value)

var userBeanOfDefault: UserBean
get() = getBeanOrDefault(
"userBeanOfDefault",
UserBean("业志陈", "https://juejin.cn/user/923245496518439")
)
set(value) = set("userBeanOfDefault", value)

var userBeanList: List<UserBean>
get() = getBean("userBeanList")
set(value) = set("userBeanList", value)

var map: Map<Int, String>
get() = getBean("map")
set(value) = set("map", value)

}

此外,我们也可以在 setValue 方法中对 value 进行校验,避免无效值

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码object UserKV : IKVHolder by UserKVHolder {

var age: Int
get() = get("age", 0)
set(value) {
if (value <= 0) {
return
}
set("age", value)
}

}

之后我们在存取值时,就相当于在直接读写 UserKV 的属性值,也支持动态指定 Key 进行赋值取值,在易用性和可读性上相比 SharedPreferences 都有很大的提升,且对于外部来说完全屏蔽了具体的存储实现逻辑

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码//存值
UserKV.name = "业志陈"
UserKV.blog = "https://juejin.cn/user/923245496518439"

//取值
val name = UserKV.name
val blog = UserKV.blog

//动态指定 Key 进行赋值和取值
UserKV.set("name", "业志陈")
val name = UserKV.get("name", "")

如何设计的

首先,IKVHolder 定义了基本的存取方法,除了需要支持基本数据类型外,还需要支持自定义的数据类型。依靠 Kotlin 的 扩展函数 和 内联函数 这两个语法特性,我们在存取自定义类型时都无需声明泛型类型,使用上十分简洁。JsonHolder 则是通过 Gson 实现了基本的序列化和反序列化方法

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
kotlin复制代码interface IKVHolder {

companion object {

inline fun <reified T> IKVHolder.getBean(key: String): T {
return JsonHolder.toBean(get(key, ""))
}

inline fun <reified T> IKVHolder.getBeanOrNull(key: String): T? {
return JsonHolder.toBeanOrNull(get(key, ""))
}

inline fun <reified T> IKVHolder.getBeanOrDefault(key: String, defaultValue: T): T {
return JsonHolder.toBeanOrDefault(get(key, ""), defaultValue)
}

fun toJson(ob: Any?): String {
return JsonHolder.toJson(ob)
}

}

//数据分组,用于标明不同范围内的数据缓存
val keyGroup: String

fun verifyBeforePut(key: String, value: Any?): Boolean

fun get(key: String, default: Int): Int

fun set(key: String, value: Int)

fun <T> set(key: String, value: T?)

fun containsKey(key: String): Boolean

fun removeKey(vararg keys: String)

fun allKeyValue(): Map<String, Any?>

fun clear()

···

}

BaseMMKVKVHolder 实现了 IKVHolder 接口,内部引入了 MMKV 作为具体的持久化存储方案

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
kotlin复制代码/**
* @param selfGroup 用于指定数据分组,不同分组下的数据互不关联
* @param encryptKey 加密 key,如果为空则表示不进行加密
*/
sealed class BaseMMKVKVHolder constructor(
selfGroup: String,
encryptKey: String
) : IKVHolder {

final override val keyGroup: String = selfGroup

override fun verifyBeforePut(key: String, value: Any?): Boolean {
return true
}

private val kv: MMKV? = if (encryptKey.isBlank()) MMKV.mmkvWithID(
keyGroup,
MMKV.MULTI_PROCESS_MODE
) else MMKV.mmkvWithID(keyGroup, MMKV.MULTI_PROCESS_MODE, encryptKey)

override fun set(key: String, value: Int) {
if (verifyBeforePut(key, value)) {
kv?.putInt(key, value)
}
}

override fun <T> set(key: String, value: T?) {
if (verifyBeforePut(key, value)) {
if (value == null) {
removeKey(key)
} else {
set(key, toJson(value))
}
}
}

override fun get(key: String, default: Int): Int {
return kv?.getInt(key, default) ?: default
}

override fun containsKey(key: String): Boolean {
return kv?.containsKey(key) ?: false
}

override fun removeKey(vararg keys: String) {
kv?.removeValuesForKeys(keys)
}

override fun allKeyValue(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
kv?.allKeys()?.forEach {
map[it] = getObjectValue(kv, it)
}
return map
}

override fun clear() {
kv?.clearAll()
}

···

}

BaseMMKVKVHolder 有两个子类,其区别只在于 MMKVKVFinalHolder 保存键值对后无法再次更改值,用于存储不会二次变更只用于历史溯源的数据,例如应用首次安装时的时间戳、版本号、版本名等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码/**
* @param selfGroup 用于指定数据分组,不同分组下的数据互不关联
* @param encryptKey 加密 key,如果为空则表示不进行加密
*/
class MMKVKVHolder constructor(selfGroup: String, encryptKey: String = "") :
BaseMMKVKVHolder(selfGroup, encryptKey)

/**
* 存储后值无法二次变更
* @param selfGroup 用于指定数据分组,不同分组下的数据互不关联
* @param encryptKey 加密 key,如果为空则表示不进行加密
*/
class MMKVKVFinalHolder constructor(selfGroup: String, encryptKey: String = "") :
BaseMMKVKVHolder(selfGroup, encryptKey) {

override fun verifyBeforePut(key: String, value: Any?): Boolean {
return !containsKey(key)
}

}

通过接口隔离,UserKV 就完全不会接触到具体的存储实现机制了,对于开发者来说也只是在读写 UserKV 的一个属性字段而已,当后续我们需要替换存储方案时,也只需要去改动 MMKVKVHolder 的内部实现即可,上层应用完全不需要进行任何改动

KVHolder

KVHolder 的实现思路还是十分简单的,再加上 Kotlin 本身强大的语法特性就进一步提高了易用性和可读性 😇😇 我也将其发布为开源库,感兴趣的读者可以直接远程导入依赖

1
2
3
4
5
6
7
8
9
groovy复制代码allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}

dependencies {
implementation 'com.github.leavesCZY:KVHolder:latest_version'
}

GitHub 点击这里:KVHolder

本文转载自: 掘金

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

再见命令行!K8S傻瓜式安装,图形化管理真香!

发表于 2021-02-23

SpringBoot实战电商项目mall(40k+star)地址:github.com/macrozheng/…

摘要

之前我们一直都是使用命令行来管理K8S的,这种做法虽然对程序员来说看起来很炫酷,但有时候用起来还是挺麻烦的。今天我们来介绍一个K8S可视化管理工具Rancher,使用它可以大大减少我们管理K8S的工作量,希望对大家有所帮助!

Rancher简介

Rancher是为使用容器的公司打造的容器管理平台。Rancher简化了使用K8S的流程,开发者可以随处运行K8S,满足IT需求规范,赋能DevOps团队。

Docker安装

虽然Rancher的安装方法有好几种,但是使用Docker来安装无疑是最简单!没有安装Docker的朋友可以先安装下。

  • 安装yum-utils:
1
bash复制代码yum install -y yum-utils device-mapper-persistent-data lvm2
  • 为yum源添加docker仓库位置:
1
bash复制代码yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
  • 安装Docker:
1
bash复制代码yum install docker-ce
  • 启动Docker:
1
bash复制代码systemctl start docker

Rancher安装

安装完Docker之后,我们就可以开始安装Rancher了。Rancher已经内置K8S,无需再额外安装。就像我们安装好Minikube一样,K8S直接就内置了。

  • 首先下载Rancher镜像;
1
bash复制代码docker pull rancher/rancher:v2.5-head
  • 下载完成后运行Rancher容器,Rancher运行起来有点慢需要等待几分钟:
1
2
3
4
bash复制代码docker run -p 80:80 -p 443:443 --name rancher \
--privileged \
--restart=unless-stopped \
-d rancher/rancher:v2.5-head
  • 运行完成后就可以访问Rancher的主页了,第一次需要设置管理员账号密码,访问地址:https://192.168.5.46

  • 设置下Rancher的Server URL,一个其他Node都可以访问到的地址,如果我们要安装其他Node的话需要用到它;

Rancher使用

我们首先来简单使用下Rancher。

  • 在首页我们可以直接查看所有集群,当前我们只有安装了Rancher的集群;

  • 点击集群名称可以查看集群状态信息,也可以点击右上角的按钮来执行kubectl命令;

  • 点击仪表盘按钮,我们可以查看集群的Dashboard,这里可以查看的内容就丰富多了,Deployment、Service、Pod信息都可以查看到了。

Rancher实战

之前我们都是使用命令行的形式操作K8S,这次我们使用图形化界面试试。还是以部署SpringBoot应用为例,不过先得部署个MySQL。

部署MySQL

  • 首先我们以yaml的形式创建Deployment,操作路径为Deployments->创建->以YAML文件编辑;

  • Deployment的yaml内容如下,注意添加namespace: default这行,否则会无法创建;
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
yaml复制代码apiVersion: apps/v1
kind: Deployment
metadata:
# 指定Deployment的名称
name: mysql-deployment
# 指定Deployment的空间
namespace: default
# 指定Deployment的标签
labels:
app: mysql
spec:
# 指定创建的Pod副本数量
replicas: 1
# 定义如何查找要管理的Pod
selector:
# 管理标签app为mysql的Pod
matchLabels:
app: mysql
# 指定创建Pod的模板
template:
metadata:
# 给Pod打上app:mysql标签
labels:
app: mysql
# Pod的模板规约
spec:
containers:
- name: mysql
# 指定容器镜像
image: mysql:5.7
# 指定开放的端口
ports:
- containerPort: 3306
# 设置环境变量
env:
- name: MYSQL_ROOT_PASSWORD
value: root
# 使用存储卷
volumeMounts:
# 将存储卷挂载到容器内部路径
- mountPath: /var/log/mysql
name: log-volume
- mountPath: /var/lib/mysql
name: data-volume
- mountPath: /etc/mysql
name: conf-volume
# 定义存储卷
volumes:
- name: log-volume
# hostPath类型存储卷在宿主机上的路径
hostPath:
path: /home/docker/mydata/mysql/log
# 当目录不存在时创建
type: DirectoryOrCreate
- name: data-volume
hostPath:
path: /home/docker/mydata/mysql/data
type: DirectoryOrCreate
- name: conf-volume
hostPath:
path: /home/docker/mydata/mysql/conf
type: DirectoryOrCreate
  • 其实我们也可以通过页面来配置Deployment的属性,如果你对yaml中的配置不太熟悉,可以在页面中修改属性并对照下,比如hostPath.type这个属性,一看就知道有哪些了;

  • 之后以yaml的形式创建Service,操作路径为Services->创建->节点端口->以YAML文件编辑;

  • Service的yaml内容如下,namespace属性不能少;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
yaml复制代码apiVersion: v1
kind: Service
metadata:
# 定义空间
namespace: default
# 定义服务名称,其他Pod可以通过服务名称作为域名进行访问
name: mysql-service
spec:
# 指定服务类型,通过Node上的静态端口暴露服务
type: NodePort
# 管理标签app为mysql的Pod
selector:
app: mysql
ports:
- name: http
protocol: TCP
port: 3306
targetPort: 3306
# Node上的静态端口
nodePort: 30306
  • 部署完成后需要新建mall数据库,并导入相关表,表地址:github.com/macrozheng/…
  • 这里有个比较简单的方法来导入数据库,通过Navicat创建连接,先配置一个SSH通道;

  • 接下来要获得Rancher容器运行的IP地址(在Minikube中我们使用的使用Minikube的地址);
1
2
3
4
bash复制代码[root@linux-local ~]# docker inspect rancher |grep IPAddress
"SecondaryIPAddresses": null,
"IPAddress": "172.17.0.3",
"IPAddress": "172.17.0.3",
  • 之后我们就可以像在Linux服务器上访问数据库一样访问Rancher中的数据库了,直接添加Rancher的IP和数据库端口即可。

部署SpringBoot应用

  • 以yaml的形式创建SpringBoot应用的Deployment,操作路径为Deployments->创建->以YAML文件编辑,配置信息如下;
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
yaml复制代码apiVersion: apps/v1
kind: Deployment
metadata:
namespace: default
name: mall-tiny-fabric-deployment
labels:
app: mall-tiny-fabric
spec:
replicas: 1
selector:
matchLabels:
app: mall-tiny-fabric
template:
metadata:
labels:
app: mall-tiny-fabric
spec:
containers:
- name: mall-tiny-fabric
# 指定Docker Hub中的镜像地址
image: macrodocker/mall-tiny-fabric:0.0.1-SNAPSHOT
ports:
- containerPort: 8080
env:
# 指定数据库连接地址
- name: spring.datasource.url
value: jdbc:mysql://mysql-service:3306/mall?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
# 指定日志文件路径
- name: logging.path
value: /var/logs
volumeMounts:
- mountPath: /var/logs
name: log-volume
volumes:
- name: log-volume
hostPath:
path: /home/docker/mydata/app/mall-tiny-fabric/logs
type: DirectoryOrCreate
  • 以yaml的形式创建Service,操作路径为Services->创建->节点端口->以YAML文件编辑,配置信息如下;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
yaml复制代码apiVersion: v1
kind: Service
metadata:
namespace: default
name: mall-tiny-fabric-service
spec:
type: NodePort
selector:
app: mall-tiny-fabric
ports:
- name: http
protocol: TCP
port: 8080
targetPort: 8080
# Node上的静态端口
nodePort: 30180
  • 创建成功后,在Deployments标签中,我们可以发现实例已经就绪了。

外部访问应用

依然使用Nginx反向代理的方式来访问SpringBoot应用。

  • 由于Rancher服务已经占用了80端口,Nginx服务只能重新换个端口了,这里运行在2080端口上;
1
2
3
4
5
bash复制代码docker run -p 2080:2080 --name nginx \
-v /mydata/nginx/html:/usr/share/nginx/html \
-v /mydata/nginx/logs:/var/log/nginx \
-v /mydata/nginx/conf:/etc/nginx \
-d nginx:1.10
  • 创建完Nginx容器后,添加配置文件mall-tiny-rancher.conf,将mall-tiny.macrozheng.com域名的访问反向代理到K8S中的SpringBoot应用中去;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ini复制代码server {
listen 2080;
server_name mall-tiny.macrozheng.com; #修改域名

location / {
proxy_set_header Host $host:$server_port;
proxy_pass http://172.17.0.3:30180; #修改为代理服务地址
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}

}
  • 再修改访问Linux服务器的本机host文件,添加如下记录;
1
复制代码192.168.5.46 mall-tiny.macrozheng.com
  • 之后即可直接在本机上访问K8S上的SpringBoot应用了,访问地址:mall-tiny.macrozheng.com:2080/swagger-ui.…

总结

使用Rancher可视化管理K8S还真是简单,大大降低了K8S的部署和管理难度。一个Docker命令即可完成部署,可视化界面可以查看应用运行的各种状态。K8S脚本轻松执行,不会写脚本的图形化界面设置下也能搞定。总结一句:真香!

参考资料

Rancher官方文档:docs.rancher.cn/rancher2/

项目源码地址

github.com/macrozheng/…

本文 GitHub github.com/macrozheng/… 已经收录,欢迎大家Star!

本文转载自: 掘金

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

Spring Boot 如何测试打包部署

发表于 2021-02-23

人生有涯,学海无涯

前言

Spring Boot 项目如何测试,如何部署,在生产中有什么好的部署方案吗?

这篇文章就来介绍一下 Spring Boot 如何开发、调试、打包到最后的投产上线。

开发阶段

单元测试

在开发阶段的时候最重要的是单元测试了, Spring Boot 对单元测试的支持已经很完善了。

1、在 pom 包中添加 spring-boot-starter-test 包引用

1
2
3
4
5
java复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

2、开发测试类

以最简单的 helloworld 为例,在测试类的类头部需要添加:@RunWith(SpringRunner.class)和@SpringBootTest注解,在测试方法的顶端添加@Test即可,最后在方法上点击右键run就可以运行。

1
2
3
4
5
6
7
8
9
10
java复制代码@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTests {

@Test
public void hello() {
System.out.println("hello world");
}

}

实际使用中,可以按照项目的正常使用去注入数据层代码或者是 Service 层代码进行测试验证,spring-boot-starter-test 提供很多基础用法,更难得的是增加了对 Controller 层测试的支持。

1
2
3
4
5
6
java复制代码//简单验证结果集是否正确
Assert.assertEquals(3, userMapper.getAll().size());

//验证结果集,提示
Assert.assertTrue("错误,正确的返回值为200", status == 200);
Assert.assertFalse("错误,正确的返回值为200", status != 200);

引入了MockMvc支持了对 Controller 层的测试,简单示例如下:

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
java复制代码public class HelloControlerTests {

private MockMvc mvc;

//初始化执行
@Before
public void setUp() throws Exception {
mvc = MockMvcBuilders.standaloneSetup(new HelloController()).build();
}

//验证controller是否正常响应并打印返回结果
@Test
public void getHello() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().isOk())
.andDo(MockMvcResultHandlers.print())
.andReturn();
}

//验证controller是否正常响应并判断返回结果是否正确
@Test
public void testHello() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().string(equalTo("Hello World")));
}

}

单元测试是验证你代码第一道屏障,要养成每写一部分代码就进行单元测试的习惯,不要等到全部集成后再进行测试,集成后因为更关注整体运行效果,很容易遗漏掉代码底层的bug.

集成测试

整体开发完成之后进入集成测试, Spring Boot 项目的启动入口在 Application 类中,直接运行 run 方法就可以启动项目,但是在调试的过程中我们肯定需要不断的去调试代码,如果每修改一次代码就需要手动重启一次服务就很麻烦, Spring Boot 非常贴心的给出了热部署的支持,很方便在 Web 项目中调试使用。

pom 需要添加以下的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码 <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<fork>true</fork>
</configuration>
</plugin>
</plugins>
</build>

添加以上配置后,项目就支持了热部署,非常方便集成测试。

投产上线

其实我觉得这个阶段,应该还是比较简单一般分为两种;一种是打包成 jar 包直接执行,另一种是打包成 war 包放到 tomcat 服务器下。

打成 jar 包

如果你使用的是 maven 来管理项目,执行以下命令既可以

1
2
3
4
5
java复制代码cd 项目跟目录(和pom.xml同级)
mvn clean package
## 或者执行下面的命令
## 排除测试代码后进行打包
mvn clean package -Dmaven.test.skip=true

打包完成后 jar 包会生成到 target 目录下,命名一般是 项目名+版本号.jar

启动 jar 包命令

1
java复制代码java -jar  target/spring-boot-scheduler-1.0.0.jar

这种方式,只要控制台关闭,服务就不能访问了。下面我们使用在后台运行的方式来启动:

1
java复制代码nohup java -jar target/spring-boot-scheduler-1.0.0.jar &

也可以在启动的时候选择读取不同的配置文件

1
java复制代码java -jar app.jar --spring.profiles.active=dev

也可以在启动的时候设置 jvm 参数

1
java复制代码java -Xms10m -Xmx80m -jar app.jar &

gradle
如果使用的是 gradle,使用下面命令打包

1
2
java复制代码gradle build
java -jar build/libs/mymodule-0.0.1-SNAPSHOT.jar

打成 war 包

打成 war 包一般可以分两种方式来实现,第一种可以通过 eclipse 这种开发工具来导出 war 包,另外一种是使用命令来完成,这里主要介绍后一种

1、maven 项目,修改 pom 包

将

1
java复制代码<packaging>jar</packaging>

改为

1
java复制代码<packaging>war</packaging>

2、打包时排除tomcat.

1
2
3
4
5
6
7
8
9
java复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>

在这里将 scope 属性设置为 provided,这样在最终形成的 WAR 中不会包含这个 JAR 包,因为 Tomcat 或 Jetty 等服务器在运行时将会提供相关的 API 类。

3、注册启动类

创建 ServletInitializer.java,继承 SpringBootServletInitializer ,覆盖 configure(),把启动类 Application 注册进去。外部 Web 应用服务器构建 Web Application Context 的时候,会把启动类添加进去。

1
2
3
4
5
6
java复制代码public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(Application.class);
}
}

最后执行

1
java复制代码mvn clean package  -Dmaven.test.skip=true

会在 target 目录下生成:项目名+版本号.war文件,拷贝到 tomcat 服务器中启动即可。

gradle

如果使用的是 Gradle,基本步奏一样,build.gradle中 添加 war 的支持,排除 spring-boot-starter-tomcat:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码...

apply plugin: 'war'

...

dependencies {
compile("org.springframework.boot:spring-boot-starter-web:1.4.2.RELEASE"){
exclude mymodule:"spring-boot-starter-tomcat"
}
}
...

再使用构建命令

1
java复制代码gradle build

war 会生成在 build\libs 目录下。

生产运维

查看 JVM 参数的值

可以根据 Java 自带的 jinfo 命令:

1
java复制代码jinfo -flags pid

来查看 jar 启动后使用的是什么 gc、新生代、老年代分批的内存都是多少,示例如下:

1
java复制代码-XX:CICompilerCount=3 -XX:InitialHeapSize=234881024 -XX:MaxHeapSize=3743416320 -XX:MaxNewSize=1247805440 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=78118912 -XX:OldSize=156762112 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseParallelGC
  • -XX:CICompilerCount :最大的并行编译数
  • -XX:InitialHeapSize 和 -XX:MaxHeapSize :指定 JVM 的初始和最大堆内存大小
  • -XX:MaxNewSize : JVM 堆区域新生代内存的最大可分配大小
  • …
  • -XX:+UseParallelGC :垃圾回收使用 Parallel 收集器

如何重启

简单粗暴

直接 kill 掉进程再次启动 jar 包

1
2
3
4
5
java复制代码ps -ef|grep java 
##拿到对于Java程序的pid
kill -9 pid
## 再次重启
Java -jar xxxx.jar

当然这种方式比较传统和暴力,所以建议大家使用下面的方式来管理

脚本执行

如果使用的是maven,需要包含以下的配置

1
2
3
4
5
6
7
java复制代码<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
</configuration>
</plugin>

如果使用是 gradle,需要包含下面配置

1
2
3
java复制代码springBoot {
executable = true
}

启动方式:

1、 可以直接./yourapp.jar 来启动

2、注册为服务

也可以做一个软链接指向你的jar包并加入到init.d中,然后用命令来启动。

init.d 例子:

1
2
java复制代码ln -s /var/yourapp/yourapp.jar /etc/init.d/yourapp
chmod +x /etc/init.d/yourapp

这样就可以使用stop或者是restart命令去管理你的应用。

1
java复制代码/etc/init.d/yourapp start|stop|restart

或者

1
java复制代码service yourapp start|stop|restart

到此 Spring Boot 项目如何测试、联调和打包投产均已经介绍完。

本文转载自: 掘金

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

干货,肝了一周的CPU缓存基础

发表于 2021-02-23

图片

目录结构

一、CPU缓存基础知识

二、缓存命中

三、缓存的一致性

四、典型用例

五、队列伪共享

img

前言导读

基本上cpu缓存知识是进入大厂的一个基本知识点了,而且也相当看重,这部分的知识掌握的比较好的话,会很加分的!

说下历史:

在计算的前几十年中,主内存非常慢且昂贵得令人难以置信,但是CPU也不是特别快。从1980年代开始,差距开始迅速扩大。微处理器的时钟速度飞速发展,但是内存访问时间的改善远没有那么明显。随着这种差距的扩大,越来越明显的是需要一种新型的快速存储器来弥合这种差距。

1980及以前:cpu没有cache

1980~1995: cpu开始有2级缓存

至今:有过L4,有些有L0,普遍有L1、L2、L3

图片

实战演练

CPU缓存基础知识

寄存器(Register)是中央处理器内用来暂存指令、数据和地址的电脑存储器。寄存器的存贮容量有限,读写速度非常快。在计算机体系结构里,寄存器存储在已知时间点所作计算的中间结果,通过快速地访问数据来加速计算机程序的运行。

寄存器位于存储器层次结构的最顶端,也是CPU可以读写的最快的存储器。寄存器通常都是以他们可以保存的比特数量来计量,举例来说,一个8位寄存器或32位寄存器。在中央处理器中,包含寄存器的部件有指令寄存器(IR)、程序计数器和累加器。寄存器现在都以寄存器数组的方式来实现,但是他们也可能使用单独的触发器、高速的核心存储器、薄膜存储器以及在数种机器上的其他方式来实现出来。

寄存器也可以指代由一个指令之输出或输入可以直接索引到的寄存器组群,这些寄存器的更确切的名称为“架构寄存器”。例如,x86指令集定义八个32位寄存器的集合,但一个实现x86指令集的CPU内部可能会有八个以上的寄存器。

CPU 缓存

在计算机系统中,CPU高速缓存(英语:CPU Cache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。

在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。

目前的计算机普遍都有3级缓存(L1、L2、L3),来看看结构:

图片

其中:

  • L1缓分成两种,一种是指令缓存,一种是数据缓存。L2缓存和L3缓存不分指令和数据。
  • L1和L2缓存在每一个CPU核中,L3则是所有CPU核心共享的内存。
  • L1、L2、L3的越离CPU近就越小,速度也越快,越离CPU远,速度也越慢。
  • 再往后面就是内存,内存的后面就是硬盘

再来看看它们的速度:

图片

看下我工作用的处理器吧,虽然有点垃圾~

图片

具体的信息我们可以看到:

L1的速度是主存的约27~36倍,L1、L2都是KB级别的,L3是M级别的,L1分为数据和指令缓存分别为32KB。想一想为什么没有L4?

下面来看一张图

图片

来自Anandtech的Haswell评论的这张图表很有用,因为它说明了添加巨大的(128MB)L4缓存以及常规L1 / L2 / L3结构对性能的影响。每个阶梯代表一个新的缓存级别。红线是带有L4的芯片-请注意,对于大文件,它的速度仍然几乎是其他两块英特尔芯片的两倍。但是较大的缓存需要更多的晶体管既慢又昂贵,而且还要增加芯片的尺寸。

那有没有L0呢?

答案是:有的。现代CPU通常还具有非常小的“ L0”高速缓存,其大小通常只有几KB,用于存储微操作。AMD和Intel都使用这种缓存。Zen的缓存为2,048 µOP,而Zen 2的缓存为4,096 µOP。这些微小的缓存池在与L1和L2相同的通用原则下运行,但是代表的是甚至更小的内存池,CPU可以以比L1更低的延迟来访问它们。通常,公司会相互调整这些功能。Zen 1和Zen +(Ryzen 1xxx,2xxx,3xxx APU)具有一个64KB L1指令高速缓存,该指令高速缓存是4路关联的,并具有一个2,048 µOP L0高速缓存。Zen 2(Ryzen 3xxx台式机CPU,Ryzen Mobile 4xxx)具有一个32KB L1指令高速缓存,该指令高速缓存是8路关联的,并且具有4,096 µOP高速缓存。将设置的关联性和µOP缓存的大小加倍,可以使AMD将L1缓存的大小减少一半。

说了这么多,cpu缓存到底是怎么工作的嘛?

cpu缓存存在的目的:我cpu这么快,我每次去主存去取数据那代价也太大了,我就在我自己开辟一个内存池,来存我最想要的一些数据。那哪些数据可以被加载到cpu缓存道中来呢?复杂的计算和编程代码呗。

如果我在L1的内存池当中没有找到我想要的数据呢?就是缓存未命中

还能怎么办,去L2找呗,一些处理器使用包含性缓存设计(意味着存储在L1缓存中的数据也将在L2缓存中重复),而其他处理器则是互斥的(意味着两个缓存永不共享数据)。如果在L2高速缓存中找不到数据,则CPU会继续沿链条向下移动到L3(通常仍在裸片上),然后是L4(如果存在)和主内存(DRAM)。

又引出一个问题:怎么找才是比较高效的呢?cpu不可能挨着挨着遍历吧

CPU 缓存命中

cache line

缓存行也叫缓存块,就是说cpu加载数据是一块一块加载的。一般来说,一个cache line最小加载的数据是64Bytes=16个32位的整型(也有其他cpu32Bytes和128Bytes的),如下是我的计算机处理器的一条Cache line size.

图片

在上图我的处理器的L1的数据缓存有32KBytes:

32KBytes/64Bytes=512 Cache line

cacheline与内存之间的映射策略 :

  • Hash: (内存地址 % 缓存行) * 64 容易出现Hash冲突
  • N-Way Set Associative : 简单的说就是将N个cacheline分为一组,每个cacheline中,根据偏移进行寻址

从上图可以看出L1的数据缓存32KBytes分为了8-way,那么每一路就是4KBytes.

怎么寻址呢?

前面我们知道:大部分的Cache line一条为64Bytes

  • Tag : 每条Cache line 前都会有一个独立分配的24bits=3Bytes来存的tag,也就是内存地址的前24bits.
  • Index : 内存地址的后面的6bits=3/4Bytes存的是这一路(way)Cache line的索引,通过6bits我们可以索引2^6=64条Cache line。
  • Offset : 在索引后面的6bits存的事Cache line的偏移量。

具体流程:

  1. 用索引定位到相应的缓存块。
  2. 用标签尝试匹配该缓存块的对应标签值。其结果为命中或未命中。
  3. 如命中,用块内偏移定位此块内的目标字。然后直接改写这个字。
  4. 如未命中,依系统设计不同可有两种处理策略,分别称为按写分配(Write allocate)和不按写分配(No-write allocate)。如果是按写分配,则先如处理读未命中一样,将未命中数据读入缓****存,然后再将数据写到被读入的字单元。如果是不按写分配,则直接将数据写回内存。

如果某一路的缓存写满了怎么办呢?

替换一些最晚访问的字节呗,也就是常说的LRU(最久未使用)

分析了L1的数据缓存,大家也可以照着分析其他L2、L3缓存,这里就不再分析了。

缓存一致性

部分来自维基百科:

为了和下级存储(如内存)保持数据一致性,就必须把数据更新适时传播下去。这种传播通过回写来完成。一般有两种回写策略:写回(Write back)和写通(Write through)。

根据回写策略和上面提到的未命中的分配策略,请看下表

图片

通过上图,我们知道:

写回时:如果缓存命中,不用更新内存,为的就是减少内存写操作,通常分配策略是分配

  • 怎么标记缓存在被其他cpu加载时被更新过?每个Cache line提供了一个脏位(dirty bit)来标识被加载后是否发生过更新。(cpu在加载时是一块一块加载的不是一个字节一个字节加载的,前面说过)
  • 图片

写通:

  • 写通是指,每当缓存接收到写数据指令,都直接将数据写回到内存。如果此数据地址也在缓存中,则必须同时更新缓存。由于这种设计会引发造成大量写内存操作,有必要设置一个缓冲来减少硬件冲突。这个缓冲称作写缓冲器(Write buffer),通常不超过4个缓存块大小。不过,出于同样的目的,写缓冲器也可以用于写回型缓存。
  • 写通较写回易于实现,并且能更简单地维持数据一致性。
  • 通常分配策略是非分配

对于一个两级缓存系统,一级缓存可能会使用写通来简化实现,而二级缓存使用写回确保数据一致性

MESI协议:

这里有一个网页(MESI Interactive Animations),这个地址太6x了,参考了很多资料,还是不如动画嗄。。。。www.scss.tcd.ie/Jeremy.Jone…

建议先玩一玩上面网址的动图,可以了解下,各个cpu的缓存和主存的读、写数据。

这里简单阐述一下:我们主存有个x=0的值,处理器有两个cpu0,cpu1

  • cpu0读x的值,cpu0先在cpu0缓存找,找不到,有一个地址总线,就是路由cpu的和主存,同时去cpu和主存找,比较版本,去主存拿x,拿到x的值通过数据总线将值赋值cpu0的缓存
  • 图片
  • cpu0对x+1写,直接获取cpu0的x=0,进行加1(这里不会更新主存,也不会更新cpu1的缓存,cpu1缓存还没有x的值)
  • 图片
  • cpu1读x的值,首先在cpu1的缓存中找,找不到,根据地址总线,同时去cpu和主存找,比较版本(如果版本一样,会优先去主存的值),找到cpu0的x值,cpu0通过数据总线将数据优先更新cpu1的缓存x的值,在更新主存x的值
  • 图片
  • cpu1对x+1,直接获取cpu1的x=1,进行加1(这里会更新主存,也不会更新cpu0的缓存,但是会通过RFO通知其他cpu
  • 图片

其他情况可以自己去试一下。

通知协议:

Snoopy 协议。这种协议更像是一种数据通知的总线型的技术。CPU Cache通过这个协议可以识别其它Cache上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它CPU Cache。这个协议要求每个CPU Cache 都可以“窥探”数据事件的通知并做出相应的反应。

MESI协议的状态:

Modified(已修改), Exclusive(独占的),Shared(共享的),Invalid(无效的)。

跟着动画走一遍,其实也不是很复杂。

扩展一下:

MOESI: MOESI是一个完整的缓存一致性协议,其中包含其他协议中常用的所有可能状态。除了四个常见的MESI协议状态外,还有第五个“拥有”状态,表示已修改和共享的数据。这样避免了在共享之前将修改后的数据写回主存储器的需要。尽管最终仍必须回写数据,但可以推迟回写。

MOESF: Forward状态下的数据是clean的,可以丢弃而不用另行通知

AMD用MOESI,Intel用MESIF

这里就不继续深入下去啦~

用例走一波

用例1:

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 CpuCache {
​
static int LEN = 64 * 1024 * 1024;
static int arr[] = new int[LEN]; // 64M
public static void main(String[] args) {
long currAddTwo = System.currentTimeMillis();
addTwo();
System.out.println(System.currentTimeMillis() - currAddTwo);
long currAddEight = System.currentTimeMillis();
addEight();
System.out.println(System.currentTimeMillis() - currAddEight);
}
private static void addTwo() {
for (int i = 0;i<LEN;i += 2) {
arr[i]*=i;
}
}
private static void addEight() {
for (int i = 0;i<LEN;i += 8) {
arr[i]*=i;
}
}
}

大家可以猜一猜,打印出来的时间可能会相差多少,或者相差几倍

分析一下时间复杂度:addTwo如果4n,那么addEight就是n

但是别忘记了CPU加载时一个Cache line 64Bytes来加载的,所以他们无论加2还是加8他们的消耗的时间都是差不多的,我的机器耗时是:

1
2
复制代码48
36

伪共享:

引用Martin的例子, 稍做修改,代码如下:

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
java复制代码public class FalseShare implements Runnable {
public static int NUM_THREADS = 2; // change
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
​
public FalseShare(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
​
public static void main(final String[] args) throws Exception {
Thread.sleep(1000);
System.out.println("starting....");
if (args.length == 1) {
NUM_THREADS = Integer.parseInt(args[0]);
}
​
longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
final long start = System.currentTimeMillis();
runTest();
System.out.println("duration = " + (System.currentTimeMillis() - start));
}
​
private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseShare(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
// System.out.println(t);
}
}
​
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
​
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;//, p7, p8, p9;
}
}

代码的逻辑是默认4个线程修改一数组不同元素的内容. 元素的类型是VolatileLong, 只有一个长整型成员value和6个没用到的长整型成员. value设为volatile是为了让value的修改所有线程都可见

当我线程设置为4时:第50行代码,有6个长整型,运行了13s,反而在只有4个长整型是只有9s,当注释掉第50行时,运行了24s。发现这个测试结果有点蹊跷。

先来梳理一下伪共享的定义:

在Java程序中,数组的成员在缓存中也是连续的. 其实从Java对象的相邻成员变量也会加载到同一缓存行中. 如果多个线程操作不同的成员变量, 如果是相同的缓存行, 伪共享(False Sharing)就可能发生。

下面引用Disruptor项目(github.com/LMAX-Exchan…

图片

一个运行在处理器core 1上的线程想要更新变量X的值, 同时另外一个运行在处理器core 2上的线程想要更新变量Y的值. 但是, 这两个频繁改动的变量都处于同一条缓存行. 两个线程就会轮番发送RFO消息, 占得此缓存行的拥有权.

表面上X和Y都是被独立线程操作的, 而且两操作之间也没有任何关系.只不过它们共享了一个缓存行, 但所有竞争冲突都是来源于共享.

根据上面代码实例,简单的说,我们对数组操作时,一个对象8字节(32位系统)或12字节(64位系统),如果加了6个long整型=48个字节,这样就可以让不同对象都用一个缓存行,就可以避免缓存行频繁发送RFO消息共享缓存行,减少竞争,那为什么我们测试出来的数据有问题?当有6个long反而比4个long还消耗时间。

原因是咱们的机器是2核的,当线程设置为2时,6个long就变成了4s,注释掉第50行变成了10s.

这样通过缓存行填充(padding),让一个对象尽量用一个缓存行,减少缓存行的同步。

队列伪共享

在JDK的LinkedBlockingQueue中, 存在指向队列头的引用head和指向队列尾的引用last. 而这种队列经常在异步编程中使有,这两个引用的值经常的被不同的线程修改, 但它们却很可能在同一个缓存行, 于是就产生了伪共享. 线程越多, 核越多,对性能产生的负面效果就越大.

但是: 伪共享也不要为了优化而优化,在Grizzly中,自带了LinkedTransferQueue,和JDK 7自带的LinkedTransferQueue有所不同,不同之处就是使用PaddedAtomicReference来提升并发性能,其实这是一种错误的编码技巧,没有意义。

Netty之前使用了PaddedAtomicReference来代替原来的Node, 使用了补齐的办法解决了队列伪共享的问题,但是后来也取消了。

AtomicReference和LinkedTransferQueue的本质是乐观锁,乐观锁的在激烈竞争的时候性能都很糟糕,乐观锁应使用在非激烈竞争的场景,为乐观锁优化激烈竞争下的性能,是错误的方向,因为如果需要激烈竞争,就应该使用悲观锁。

Padded-AtomicReference也是一个伪命题,如果激励竞争,为什么不使用Lock + volatile,如果非激烈竞争,使用PaddedAtomicReference对于AtomicReference又没有优势。所以使用Padded-AtomicReference是一个错误的编码技巧。

所以在1.8去掉了LinkedTransferQueue相关的pad逻辑,贴一个1.7的代码吧–

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
java复制代码public class FalseShare implements Runnable {
public static int NUM_THREADS = 2; // change
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
​
public FalseShare(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
​
public static void main(final String[] args) throws Exception {
Thread.sleep(1000);
System.out.println("starting....");
if (args.length == 1) {
NUM_THREADS = Integer.parseInt(args[0]);
}
​
longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
final long start = System.currentTimeMillis();
runTest();
System.out.println("duration = " + (System.currentTimeMillis() - start));
}
​
private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseShare(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
// System.out.println(t);
}
}
​
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
​
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;//, p7, p8, p9;
}
}

50个线程争抢10个对象,LinkedBlockingQueue比LinkedTransferQueue

在1.7是要快几倍的,但是在1.8运行速度就差不多了。

最后在讲讲Disruptor

  • 环形数组结构

为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好。

  • 元素位置定位

数组长度2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。

  • 无锁设计

每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据。

下面忽略数组的环形结构,介绍一下如何实现无锁设计。整个过程通过原子变量CAS,保证操作的线程安全。

消费者等待策略:

  • BlockingWaitStrategy:加锁 CPU资源紧缺,吞吐量和延迟并不重要的场景
  • BusySpinWaitStrategy:自旋 通过不断重试,减少切换线程导致的系统调用,而降低延迟。推荐在线程绑定到固定的CPU的场景下使用
  • PhasedBackoffWaitStrategy:自旋 + yield + 自定义策略,CPU资源紧缺,吞吐量和延迟并不重要的场景
  • SleepingWaitStrategy:自旋 + yield + sleep,性能和CPU资源之间有很好的折中。延迟不均匀
  • TimeoutBlockingWaitStrategy:加锁,有超时限制,CPU资源紧缺,吞吐量和延迟并不重要的场景(logfj2默认使用此策略)
  • YieldingWaitStrategy:自旋 + yield + 自旋,性能和CPU资源之间有很好的折中。延迟比较均匀
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
77
78
79
80
81
82
83
84
85
86
87
88
java复制代码import com.lmax.disruptor.*;
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType;
​
import java.util.concurrent.ThreadFactory;
​
​
public class DisruptorMain
{
public static void main(String[] args) throws Exception
{
// 队列中的元素
class Element {
​
private String value;
​
public String get(){
return value;
}
​
public void set(String value){
this.value= value;
}
​
}
​
// 生产者的线程工厂
ThreadFactory threadFactory = new ThreadFactory(){
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "simpleThread");
}
};
​
// RingBuffer生产工厂,初始化RingBuffer的时候使用
EventFactory<Element> factory = new EventFactory<Element>() {
@Override
public Element newInstance() {
return new Element();
}
};
​
// 处理Event的handler
EventHandler<Element> handler = new EventHandler<Element>(){
@Override
public void onEvent(Element element, long sequence, boolean endOfBatch)
{
System.out.println("Element: " + element.get());
}
};
​
// 阻塞策略
BlockingWaitStrategy strategy = new BlockingWaitStrategy();
​
// 指定RingBuffer的大小
int bufferSize = 16;
​
// 创建disruptor,采用单生产者模式
Disruptor<Element> disruptor = new Disruptor(factory, bufferSize, threadFactory, ProducerType.SINGLE, strategy);
​
// 设置EventHandler
disruptor.handleEventsWith(handler);
​
// 启动disruptor的线程
disruptor.start();
​
RingBuffer<Element> ringBuffer = disruptor.getRingBuffer();
​
for (int l = 0; true; l++)
{
// 获取下一个可用位置的下标
long sequence = ringBuffer.next();
try
{
// 返回可用位置的元素
Element event = ringBuffer.get(sequence);
// 设置该位置元素的值
event.set(l+"rs");
}
finally
{
System.out.println("push" + sequence);
ringBuffer.publish(sequence);
}
Thread.sleep(10);
}
}
}

贴一下测试用例!等下一篇在深入讲解吧!下下一篇在讲讲Volatile,让我休息一下~~

欢迎指正交流哦!!

欢迎关注我的公号和我的掘金<搜索:汀雨笔记>,会首发一些最新文章哦!

下面是我的个人网站:ransongv587.com:996

热门推荐:

  • 【今日leecode】两数相加
  • 万字图文浅析 :ThreadPoolExecutor线程池
  • 万字解析:最近服务准备上线,统一对微服务增加JVM参数
  • 万文长字分析:想进大厂,你不得不掌握的CPU缓存基础

文末福利,最近整理一份面试资料《Java面试通关手册》,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。获取方式:GitHub github.com/Tingyu-Note…,更多内容陆续奉上。

本文转载自: 掘金

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

春节期间,我用责任链模式重构了业务代码

发表于 2021-02-22

前言

文章开篇,抛出一个老生常谈的问题,学习设计模式有什么作用?

设计模式主要是为了应对代码的复杂性,让其满足开闭原则,提高代码的扩展性

另外,学习的设计模式 一定要在业务代码中落实,只有理论没有真正实施,是无法真正掌握并且灵活运用设计模式的

这篇文章主要说 责任链设计模式,认识此模式是在读 Mybatis 源码时, Interceptor 拦截器主要使用的就是责任链,当时读过后就留下了很深的印象(内心 OS:还能这样玩)

文章先从基础概念说起,另外分析一波 Mybatis 源码中是如何运用的,最后按照 “习俗”,设计一个真实业务场景上的应用

责任链设计模式大纲如下:

  1. 什么是责任链模式
  2. 完成真实的责任链业务场景设计
  3. Mybatis Interceptor 底层实现
  4. 责任链模式总结

什么是责任链模式

举个例子,SpringMvc 中可以定义拦截器,并且可以定义多个。当一个用户发起请求时,顺利的话请求会经过所有拦截器,最终到达业务代码逻辑,SpringMvc 拦截器设计就是使用了责任链模式

为什么说顺利的话会经过所有拦截器?因为请求不满足拦截器自定义规则会被打回,但这并不是责任链模式的唯一处理方式,继续往下看

在责任链模式中,多个处理器(参照上述拦截器)依次处理同一个请求。一个请求先经过 A 处理器处理,然后再把请求传递给 B 处理器,B 处理器处理完后再传递给 C 处理器,以此类推,形成一个链条,链条上的每个处理器 各自承担各自的处理职责

责任链模式中多个处理器形成的处理器链在进行处理请求时,有两种处理方式:

  1. 请求会被 所有的处理器都处理一遍,不存在中途终止的情况,这里参照 MyBatis 拦截器理解
  2. 二则是处理器链执行请求中,某一处理器执行时,如果不符合自制定规则的话,停止流程,并且剩下未执行处理器就不会被执行,大家参照 SpringMvc 拦截器理解

这里通过代码的形式对两种处理方式作出解答,方便读者更好的理解。首先看下第一种,请求会经过所有处理器执行的情况

图1 责任链模式一种实现

IHandler 负责抽象处理器行为,handle() 则是不同处理器具体需要执行的方法,HandleA、HandleB 为具体需要执行的处理器类,HandlerChain 则是将处理器串成一条链执行的处理器链

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class ChainApplication {
public static void main(String[] args) {
HandlerChain handlerChain = new HandlerChain();
handlerChain.addHandler(Lists.newArrayList(new HandlerA(), new HandlerB()));
handlerChain.handle();
/**
* 程序执行结果:
* HandlerA打印:执行 HandlerA
* HandlerB打印:执行 HandlerB
*/
}
}

这种责任链执行方式会将所有的 处理器全部执行一遍,不会被打断。Mybatis 拦截器用的正是此类型,这种类型 重点在对请求过程中的数据或者行为进行改变

图2 参考Mybatis拦截器实现

而另外一种责任链模式实现,则是会对请求有阻断作用,阻断产生的前置条件是在处理器中自定义的,代码中的实现较简单,读者可以联想 SpringMvc 拦截器的实现流程

图3 责任链模式一种实现

根据代码看的出来,在每一个 IHandler 实现类中会返回一个布尔类型的返回值,如果返回布尔值为 false,那么责任链发起类会中断流程,剩余处理器将不会被执行。就像我们定义在 SpringMvc 中的 Token 拦截器,如果 Token 失效就不能继续访问系统,处理器将请求打回

1
2
3
4
5
6
7
8
9
10
java复制代码public class ChainApplication {
public static void main(String[] args) {
HandlerChain handlerChain = new HandlerChain();
handlerChain.addHandler(Lists.newArrayList(new HandlerA(), new HandlerB()));
boolean resultFlag = handlerChain.handle();
if (!resultFlag) {
System.out.println("责任链中处理器不满足条件");
}
}
}

读者可以自己在 IDEA 中实现两种不同的责任链模式,对比其中的不同,设想下业务中真实的应用场景,再或者可以跑 SpringBoot 项目,创建多个拦截器来佐证文中的说辞

图4 参考SpringMvc拦截器实现

本章节介绍了责任链设计模式的具体语义,以及不同责任链实现类型代码举例,并以 Mybatis、SpringMvc 拦截器为参照点,介绍各自不同的代码实现以及应用场景

责任链业务场景设计

趁热打铁,本小节对使用的真实业务场景进行举例说明。假设业务场景是这样的,我们 系统处在一个下游服务,因为业务需求,系统中所使用的 基础数据需要从上游中台同步到系统数据库

基础数据包含了很多类型数据,虽然数据在中台会有一定验证,但是 数据只要是人为录入就极可能存在问题,遵从对上游系统不信任原则,需要对数据接收时进行一系列校验

最初是要进行一系列验证原则才能入库的,后来因为工期问题只放了一套非空验证,趁着春节期间时间还算宽裕,把这套验证规则骨架放进去

从我们系统的接入数据规则而言,个人觉得需要支持以下几套规则

  1. 必填项校验,如果数据无法满足业务所必须字段要求,数据一旦落入库中就会产生一系列问题
  2. 非法字符校验,因为数据如何录入,上游系统的录入规则是什么样的我们都不清楚,这一项规则也是必须的
  3. 长度校验,理由同上,如果系统某字段长度限制 50,但是接入来的数据 500长度,这也会造成问题

为了让读者了解业务嵌入责任链模式的前因,这里列举了三套校验规则,当然真实中可能不止这三套。但是 一旦将责任链模式嵌入数据同步流程,就会 完全符合文初所提的开闭原则,提高代码的扩展性

本案例设计模式中的开闭原则通过 Spring 提供支持,后续添加新的校验规则就可以不必修改原有代码

这里要再强调下,设计模式的应用场景一定要灵活掌握,只有这样才能在合适的业务场景合理运用对象的设计模式

既然设计模式场景说过了,最后说一下需要达成的业务需求。将一个批量数据经过处理器链的处理,返回出符合要求的数据分类

定义顶级验证接口和一系列处理器实现类没什么难度,但是应该如何进行链式调用呢?

这一块代码需要有一定 Spring 基础才能理解,一起来看下 VerifyHandlerChain 如何将所有处理器串成一条链

VerifyHandlerChain 处理流程如下:

  1. 实现自 InitializingBean 接口,在对应实现方法中获取 IOC 容器中类型为 VerifyHandler 的 Bean,也就是 EmptyVerifyHandler、SexyVerifyHandler
  2. 将 VerifyHandler 类型的 Bean 添加到处理器链容器中
  3. 定义校验方法 verify(),对入参数据展开处理器链的全部调用,如果过程中发现已无需要验证的数据,直接返回

这里使用 SpringBoot 项目中默认测试类,来测试一下如何调用

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@SpringBootTest
class ChainApplicationTests {

@Autowired
private VerifyHandlerChain verifyHandlerChain;

@Test
void contextLoads() {
List<Object> verify = verifyHandlerChain.verify(Lists.newArrayList("源码兴趣圈", "@龙台"));
System.out.println(verify);
}
}

这样的话,如果客户或者产品提校验相关的需求时,我们只需要实现 VerifyHandler 接口新建个校验规则实现类就 OK 了,这样符合了设计模式的原则:满足开闭原则,提高代码的扩展性

熟悉之前作者写过设计模式的文章应该知道,强调设计模式重语意,而不是具体的实现过程。所以,你看这个咱们这个校验代码,把责任链两种模式结合了使用

上面的代码只是示例代码,实际业务中的实现要比这复杂很多,比如:

  1. 如何定义处理器的先后调用顺序。比如说某一个处理器执行时间很长并且过滤数据很少,所以希望把它放到最后面执行
  2. 这是为当前业务的所有数据类型进行过滤,如何自定义单个数据类型过滤。比如你接入学生数据,学号有一定校验规则,这种处理器类肯定只适合单一类型

还有很多的业务场景,所以设计模式强调的应该是一种思想,而不是固定的代码写法,需要结合业务场景灵活变通

责任链模式的好处

一定要使用责任链模式么?不使用能不能完成业务需求?

回答是肯定可以,设计模式只是帮助减少代码的复杂性,让其满足开闭原则,提高代码的扩展性。如果不使用同样可以完成需求

如果不使用责任链模式,上面说的真实同步场景面临两个问题

  1. 如果把上述说的代码逻辑校验规则写到一起,毫无疑问这个类或者说这个方法函数奇大无比。减少代码复杂性一贯方法是:将大块代码逻辑拆分成函数,将大类拆分成小类,是应对代码复杂性的常用方法。如果此时说:可以把不同的校验规则拆分成不同的函数,不同的类,这样不也可以满足减少代码复杂性的要求么。这样拆分是能解决代码复杂性,但是这样就会面临第二个问题
  2. 开闭原则:添加一个新的功能应该是,在已有代码基础上扩展代码,而非修改已有代码。大家设想一下,假设你写了三套校验规则,运行过一段时间,这时候领导让加第四套,是不是要在原有代码上改动

综上所述,在合适的场景运用适合的设计模式,能够让代码设计复杂性降低,变得更为健壮。朝更远的说也能让自己的编码设计能力有所提高,告别被人吐槽的烂代码…

Mybatis Interceptor底层实现

上面说了那么多,框架底层源码是怎么设计并且使用责任链模式的?之前在看 Mybatis 3.4.x 源码时了解到 Interceptor 底层实现就是责任链模式,这里和读者分享 Interceptor 具体实现

开门见山,直接把视线聚焦到 Mybatis 源码,版本号 3.4.7-SNAPSHOT

熟悉么?是不是和我们上面用到的责任链模式差不太多,有处理器集合 interceptors,有添加处理器方法

Mybatis Interceptor 不仅用到了责任链,还用到了动态代理,服务于 Mybatis 四大 “护教法王”,在创建对象时通过动态代理和责任链相结合组装而成插件模块

  1. ParameterHandler
  2. ResultSetHandler
  3. StatementHandler
  4. Executor

使用过 Mybatis 的读者应该知道,查询 SQL 的分页语句就是使用 Interceptor 实现,比如市场上的 PageHelper、Mybatis-Plus 分页插件再或者我们自实现的分页插件(应该没有项目组使用显示调用多条语句组成分页吧)

拿查询语句举例,如果定义了多个查询相关的拦截器,会先经过拦截器的代码加工,所有的拦截器执行完毕后才会走真正查询数据库操作

扯的话就扯远了,能够知道如何用、在哪用就可以了。通过 Interceptor 也能知道一点,想要读框架源码,需要一定的设计模式基础。如果对责任链、动态代理不清楚,那么就不能理解这一块的精髓

结言

文章通过图文并茂的方式帮助大家理解责任链设计模式,在两种类型示例代码以及举例实际业务场景下,相信小伙伴已经掌握了如何在合适的场景使用责任链设计模式

看完文章后可以结合 Mybatis、SpringMvc 拦截器更深入掌握责任链模式的应用场景以及使用手法。另外可以结合项目中实际业务场景灵活使用,相信真正使用后的你会对责任链模式产生更深入的了解

参考文章

  • 《设计模式之美:职责链模式》

微信搜索【源码兴趣圈】,关注公众号后回复 123 领取内容涵盖 GO、Netty、Seata、SpringCloud Alibaba、开发规范、面试宝典、数据结构等学习资料!

本文转载自: 掘金

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

手写邮箱获取验证码注册登录功能!!!

发表于 2021-02-22

找回密码的困扰

自己做了一个博客网站,有登录注册功能,但是没有找回密码功能,思考了许久,发现可以通过第三方来实现,第一想到的就是通过短信验证码,但是资金问题,一条0.1元,属是有点贵(多了就贵了),然后想到了用微信扫码,但是微信也收费,无奈之下用邮箱吧,感觉邮箱还不错,只需要开启STMP协议就行了,开启之后,会有一个密码,那个密码保存好,后面会用到。

在这里插入图片描述

开始创建

首先创建一个springboot项目,然后引入mail依赖,简单配置即可。

1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

配置yaml,密码为自己生成的

在这里插入图片描述

准备工作做好之后,下面来写一下获取验证码的方法,把获取到的验证码存到session中方便后续判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ini复制代码public String getCode(String email, HttpSession session) {
int code = (int) (Math.random() * 1000000);
String codeString = String.valueOf(code);
if (codeString.length() != 6) {
code = code + 100000;
}

SimpleMailMessage message = new SimpleMailMessage();
// 设置邮箱标题
message.setSubject("验证码");
// 设置邮箱内容
message.setText("您好!\n验证码为:"+ code);
// 发送者邮箱
message.setFrom(username);
message.setTo(email);
mailSender.send(message);
session.setAttribute("email", email);
session.setAttribute("code", code + "");
return "success";
}

获取到验证码之后,可以进行注册了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ini复制代码    public BaseResult register(String email, String password, String code, HttpSession session) {
User user = new User();
String myEmail = (String) session.getAttribute("email");
String myCode = (String) session.getAttribute("code");
if (!email.equals(myEmail) || !code.equals(myCode)) {
return BaseResult.error();
}

user.setUsername(UUID.randomUUID().toString());
user.setEmail(email);
user.setPassword(password);
user.setSalt("abc");
user.setHeadUrl("url");
userMapper.addUser(user);
return BaseResult.ok();
}

注册完之后,就可以进行登录了。

1
2
3
4
5
6
7
scss复制代码    public BaseResult Login(String email, String password) {
User user = userMapper.selectUserByEmail(email);
if (user.getPassword().equals(password)) {
return BaseResult.ok();
}
return BaseResult.error();
}

本文转载自: 掘金

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

Java类加载器 — classloader 的原理及应用

发表于 2021-02-22

什么是classloader

classloader顾名思义,即是类加载。虚拟机把描述类的数据从class字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。了解java的类加载机制,可以快速解决运行时的各种加载问题并快速定位其背后的本质原因,也是解决疑难杂症的利器。因此学好类加载原理也至关重要。

classloader的加载过程

类从被加载到虚拟机内存到被卸载,整个完整的生命周期包括:类加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证,准备,解析三个部分统称为连接。接下来我们可以详细了解下类加载的各个过程。

classloader的整个加载过程还是非常复杂的,具体的细节可以参考《深入理解java虚拟机》进行深入了解。为了方便记忆,我们可以使用一句话来表达其加载的整个过程,“家宴准备了西式菜”,即家(加载)宴(验证)准备(准备)了西(解析)式(初始化)菜。保证你以后能够很快的想起来。

虽然classloader的加载过程有复杂的5步,但事实上除了加载之外的四步,其它都是由JVM虚拟机控制的,我们除了适应它的规范进行开发外,能够干预的空间并不多。而加载则是我们控制classloader实现特殊目的最重要的手段了。也是接下来我们介绍的重点了。

classloader双亲委托机制

classloader的双亲委托机制是指多个类加载器之间存在父子关系的时候,某个class类具体由哪个加载器进行加载的问题。其具体的过程表现为:当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。使用双亲委派模型,Java类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。

整个java虚拟机的类加载层次关系如上图所示,启动类加载器(Bootstrap Classloader)负责将<JAVA_HOME>/lib目录下并且被虚拟机识别的类库加载到虚拟机内存中。我们常用基础库,例如java.util.,java.io.,java.lang.**等等都是由根加载器加载。

扩展类加载器(Extention Classloader)负责加载JVM扩展类,比如swing系列、内置的js引擎、xml解析器等,这些类库以javax开头,它们的jar包位于<JAVA_HOME>/lib/ext目录中。

应用程序加载器(Application Classloader)也叫系统类加载器,它负责加载用户路径(ClassPath)上所指定的类库。我们自己编写的代码以及使用的第三方的jar包都是由它来加载的。

自定义加载器(Custom Classloader)通常是我们为了某些特殊目的实现的自定义加载器,后面我们得会详细介绍到它的作用以及使用场景。

双亲委托机制看起来比较复杂,但是其本身的核心代码逻辑却是非常的清晰简单,我们着重抽取了类加载的双亲委托的核心代码如下,不过二十行左右。

classloader的应用场景

类加载器是java语言的一项创新,也是java语言流行的重要原因这一。通过灵活定义classloader的加载机制,我们可以完成很多事情,例如解决类冲突问题,实现热加载以及热部署,甚至可以实现jar包的加密保护。接下来,我们会针对这些特殊场景进行逐一介绍。

依赖冲突

做过多人协同开发的大型项目的同学可能深有感触。基于maven的pom进制可以方便的进行依赖管理,但是由于maven依赖的传递性,会导致我们的依赖错综复杂,这样就会导致引入类冲突的问题。最典型的就是NoSuchMethodException异常了。

在阿里平时的项目开发中是否也会遇到类似的问题吗,答案是肯定的。例如阿里内部也很多成熟的中间件,由不同的中间件团队来负责。那么当一个项目引入不同的中间件的时候,该如何避免依赖冲突的问题呢?首先我们用一个非常简单的场景来描述为什么会出现类冲突的问题。

某个业务引用了消息中间件(例如metaq)和微服务中间件(例如dubbo),这两个中间件也同时引用了fastjson-2.0和fastjson-3.0版本,而业务自己本身也引用了fastjson-1.0版本。这三个版本表现不同之处在于classA类中方法数目不相同,我们根据maven依赖处理的机制,引用路径最短的fastjson-1.0会真正作为应用最终的依赖,其它两个版本的fastjson则会被忽略,那么中间件在调用method2()方法的时候,则会抛出方法找不到异常。或许你会说,将所有依赖fastjson的版本都升级到3.0不是就能解解决问题吗?确实这样能够解决问题,但是在实际操作中不太现实,首先,中间件团队和业务团队之间并不是一个团队,并不能做到高效协同,其次是中间件的稳定性是需要保障的,不可能因为包冲突问题,就升级版本,更何况一个中间件依赖的包可能有上百个,如果纯粹依赖包升级来解决,不仅稳定性难以保障,排包耗费的时间恐怕就让人窒息了。

那如何解决包冲突的问题呢?答案就是pandora(潘多拉),通过自定义类加载器,为每个中间件自定义一个加载器,这些加载器之间的关系是平行的,彼此没有依赖关系。这样每个中间件的classloader就可以加载各自版本的fastjson。因为一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识,这也是阿里pandora实现依赖隔离的基础。

可能到这里,你又会有新的疑惑,根据双亲委托模型,App Classloader分别继承了Custom Classloader.那么业务包中的fastjson的class在加载的时候,会先委托到Custom ClassLoader。这样不就会导致自身依赖的fastjson版本被忽略吗?确实如此,所以潘多拉又是如何做的呢?

首先每个中间件对应的ModuleClassLoader在加载中间对应的class文件的同时,根据中间件配置的export.index负责将要需要透出的class(主要是提供api接口的相关类)索引到exportedClassHashMap中,然后应用程序的类加载器会持有这个exportedClassHashMap,因此应用程序代码在loadClass的时候,会优先判断exportedClassHashMap是否存在当前类,如果存在,则直接返回,如果不存在,则再使用传统的双亲委托机制来进行类加载。这样中间件MoudleClassloader不仅实现了中间件的加载,也实现了中间件关键服务类的透出。

我们可以大概看下应用程序类加载的过程:

热加载

在开发项目的时候,我们需要频繁的重启应用进行程序调试,但是java项目的启动少则几十秒,多则几分钟。如此慢的启动速度极大地影响了程序开发的效率,那是否可以快速的进行启动,进而能够快速的进行开发验证呢?答案也是肯定的,通过classloader我们可以完成对变更内容的加载,然后快速的启动。

常用的热加载方案有好几个,接下来我们介绍下spring官方推荐的热加载方案,即spring boot devtools。

首先我们需要思考下,为什么重新启动一个应用会比较慢,那是因为在启动应用的时候,JVM虚拟机需要将所有的应用程序重新装载到整个虚拟机。可想而知,一个复杂的应用程序所包含的jar包可能有上百兆,每次微小的改动都是全量加载,那自然是很慢了。那么我们是否可以做到,当我们修改了某个文件后,在JVM中替换到这个文件相关的部分而不全量的重新加载呢?而spring boot devtools正是基于这个思路进行处理的。

如上图所示,通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及我们自己编写的业务代码组成。上面的一排是我们通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。而实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。因此我们可以将业务代码单独通过一个自定义的加载器Custom Classloader来进行加载,当监控发现业务代码发生改变后,我们重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如下。有兴趣的同学可以去看下源码,会更加清楚。

RestartClassLoader为自定义的类加载器,其核心是loadClass的加载方式,我们发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从从parent进行加载。这样保证了业务代码可以优先被RestartClassLoader加载。进而通过重新加载RestartClassLoader即可完成应用代码部分的重新加载。

热部署

热部署本质其实与热加载并没有太大的区别,通常我们说热加载是指在开发环境中进行的classloader加载,而热部署则更多是指在线上环境使用classloader的加载机制完成业务的部署。所以这二者使用的技术并没有本质的区别。那热部署除了与热加载具有发布更快之外,还有更多的更大的优势就是具有更细的发布粒度。我们可以想像以下的一个业务场景。

假设某个营销投放平台涉及到4个业务方的开发,需要对会场业务进行投放。而这四个业务方的代码全部都在一个应用里面。因此某个业务方有代码变更则需要对整个应用进行发布,同时其它业务方也需要跟着回归。因此每个微小的发动,则需要走整个应用的全量发布。这种方式带来的稳定性风险估且不说,整个发布迭代的效率也可想而知了。这在整个互联网里,时间和效率就是金钱的理念下,显然是无法接受的。

那么我们完全可以通过类加载机制,将每个业务方通过一个classloader来加载。基于类的隔离机制,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。其实在移动客户端,每个应用模块也可以基于类加载,实现插件化发布。本质上也是一个原理。

在阿里内部像阿拉丁投放平台,以及crossbow容器化平台,本质都是使用classloader的热加载技术,实现业务细粒度的开发部署以及多应用的合并部署。

加密保护

众所周期,基于java开发编译产生的jar包是由.class字节码组成,由于字节码的文件格式是有明确规范的。因此对于字节码进行反编译,就很容易知道其源码实现了。因此大致会存在如下两个方面的诉求。例如在服务端,我们向别人提供三方包实现的时候,不希望别人知道核心代码实现,我们可以考虑对jar包进行加密,在客户端则会比较普遍,那就是我们打包好的apk的安装包,不希望被人家反编译而被人家翻个底朝天,我们也可以对apk进行加密。

jar包加密的本质,还是对字节码文件进行操作。但是JVM虚拟机加载class的规范是统一的,因此我们在最终加载class文件的时候,还是需要满足其class文件的格式规范,否则虚拟机是不能正常加载的。因此我们可以在打包的时候对class进行正向的加密操作,然后,在加载class文件之前通过自定义classloader先进行反向的解密操作,然后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现解密方法的classloader才能正常加载。

我们可以贴一下简单的实现方案:

这样整个jar包的安全性就有一定程度的提高,至于更高安全的保障则取决于加密算法的安全性了以及如何保障加密算法的密钥不被泄露的问题了。这有种套娃的感觉,所谓安全基本都是相对的。并且这些方法也不是绝对的,例如可以通过对classloader进行插码,对解密后的class文件进行存储;另外大多数JVM本身并不安全,还可以修改JVM,从ClassLoader之外获取解密后的代码并保存到磁盘,从而绕过上述加密所做的一切工作,当然这些操作的成本就比单纯的class反编译就高很多了。所以说安全保障只要做到使对方破解的成本高于收益即是安全,所以一定程度的安全性,足以减少很多低成本的攻击了。

总结

本文对classloader的加载过程和加载原理进行了介绍,并结合类加载机制的特征,介绍了其相应的使用场景。由于篇幅限制,并没有对每种场景的具体实现细节进行介绍,而只是阐述了其基本实现思路。或许大家觉得classloader的应用有些复杂,但事实上只要大家对class从哪里加载,搞清楚loadClass的机制,就已经成功了一大半。正所谓万变不离其宗,抓住了本质,其它问题也就迎刃而解了。

本文转载自: 掘金

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

你真的会用wordcloud制作词云图吗?

发表于 2021-02-22

前言

对于文本分析而言,大家都绕不开词云图,而python中制作词云图,又绕不开wordcloud,但我想说的是,你真的会用吗?你可能已经按照网上的教程,做出来了一张好看的词云图,但是我想今天这篇文章,绝对让你明白wordcloud背后的原理。

小试牛刀

首先你需要使用pip安装这个第三方库。接着我们简单看一下中英文制作词云有什么不同。

1
2
3
4
5
6
7
8
9
javascript复制代码from matplotlib import pyplot as plt
from wordcloud import WordCloud

text = 'my is luopan. he is zhangshan'

wc = WordCloud()
wc.generate(text)

plt.imshow(wc)

1
2
3
4
5
6
7
8
9
python复制代码from matplotlib import pyplot as plt
from wordcloud import WordCloud

text = '我叫罗攀,他叫张三,我叫罗攀'

wc = WordCloud(font_path = r'/System/Library/Fonts/Supplemental/Songti.ttc') #设置中文字体
wc.generate(text)

plt.imshow(wc)

聪明的你会发现,中文的词云图并不是我们想要的,那是因为wordcloud并不能成功为中文进行分词。通过下面wordcloud的源代码分析,我想你就应该能弄明白了。

WordCloud源码分析

我们主要是要看WordCloud类,这里我不会把全部源代码打上来,而是主要分析制作词云的整个流程。

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

def __init__(self,):
'''这个主要是初始化一些参数
'''
pass

def fit_words(self, frequencies):
return self.generate_from_frequencies(frequencies)

def generate_from_frequencies(self, frequencies, max_font_size=None):
'''词频归一化,创建绘图对象
'''
pass

def process_text(self, text):
"""对文本进行分词,预处理
"""

flags = (re.UNICODE if sys.version < '3' and type(text) is unicode # noqa: F821
else 0)
pattern = r"\w[\w']*" if self.min_word_length <= 1 else r"\w[\w']+"
regexp = self.regexp if self.regexp is not None else pattern

words = re.findall(regexp, text, flags)
# remove 's
words = [word[:-2] if word.lower().endswith("'s") else word
for word in words]
# remove numbers
if not self.include_numbers:
words = [word for word in words if not word.isdigit()]
# remove short words
if self.min_word_length:
words = [word for word in words if len(word) >= self.min_word_length]

stopwords = set([i.lower() for i in self.stopwords])
if self.collocations:
word_counts = unigrams_and_bigrams(words, stopwords, self.normalize_plurals, self.collocation_threshold)
else:
# remove stopwords
words = [word for word in words if word.lower() not in stopwords]
word_counts, _ = process_tokens(words, self.normalize_plurals)

return word_counts

def generate_from_text(self, text):
words = self.process_text(text)
self.generate_from_frequencies(words)
return self

def generate(self, text):
return self.generate_from_text(text)

当我们使用generate方法时,其调用顺序是:

1
2
3
bash复制代码generate_from_text
process_text #对文本预处理
generate_from_frequencies #词频归一化,创建绘图对象

备注:所以制作词云时,不管你使用generate还是generate_from_text方法,其实最终都是会调用generate_from_text方法。

所以,这里最重要的就是process_text 和generate_from_frequencies函数。接下来我们就来一一讲解。

process_text函数

process_text函数其实就是对文本进行分词,然后清洗,最好返回一个分词计数的字典。我们可以尝试使用一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码text = 'my is luopan. he is zhangshan'

wc = WordCloud()
cut_word = wc.process_text(text)
print(cut_word)
# {'luopan': 1, 'zhangshan': 1}

text = '我叫罗攀,他叫张三,我叫罗攀'

wc = WordCloud()
cut_word = wc.process_text(text)
print(cut_word)
# {'我叫罗攀': 2, '他叫张三': 1}

所以可以看出process_text函数是没法对中文进行好分词的。我们先不管process_text函数是怎么清洗分词的,我们就着重看看是怎么对文本进行分词的。

1
2
3
4
5
6
7
8
9
10
python复制代码def process_text(self, text):
"""对文本进行分词,预处理
"""

flags = (re.UNICODE if sys.version < '3' and type(text) is unicode # noqa: F821
else 0)
pattern = r"\w[\w']*" if self.min_word_length <= 1 else r"\w[\w']+"
regexp = self.regexp if self.regexp is not None else pattern

words = re.findall(regexp, text, flags)

这里的关键就在于使用的是正则表达式进行分词(”\w[\w’]+”),学过正则表达式的都知道,\w[\w]+代表的是匹配2个至多个字母,数字,中文,下划线(python正则表达式中\w可代表中文)。

所以中文没法切分,只会在各种标点符号中切分中文,这是不符合中文分词的逻辑的。但英文文本本身就是通过空格进行了分割,所以英文单词可以轻松的分词出来。

总结来说,wordcloud本身就是为了英文文本来做词云的,如果需要制作中文文本词云,就需要先对中文进行分词。

generate_from_frequencies函数

最后再简单说下这个函数,这个函数的功能就是词频归一化,创建绘图对象。

绘图这个代码很多,也不是我们今天要讲的重点,我们只需要了解到底是需要什么数据来绘制词云图,下面是词频归一化的代码,我想大家应该能看的懂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
css复制代码from operator import itemgetter

def generate_from_frequencies(frequencies):
frequencies = sorted(frequencies.items(), key=itemgetter(1), reverse=True)
if len(frequencies) <= 0:
raise ValueError("We need at least 1 word to plot a word cloud, "
"got %d." % len(frequencies))

max_frequency = float(frequencies[0][1])

frequencies = [(word, freq / max_frequency)
for word, freq in frequencies]
return frequencies

test = generate_from_frequencies({'我叫罗攀': 2, '他叫张三': 1})
test

# [('我叫罗攀', 1.0), ('他叫张三', 0.5)]

中文文本制作词云图的正确方式

我们先通过jieba分词,用空格拼接文本,这样process_text函数就能返回正确的分词计数的字典。

1
2
3
4
5
6
7
8
9
10
11
python复制代码from matplotlib import pyplot as plt
from wordcloud import WordCloud
import jieba

text = '我叫罗攀,他叫张三,我叫罗攀'
cut_word = " ".join(jieba.cut(text))

wc = WordCloud(font_path = r'/System/Library/Fonts/Supplemental/Songti.ttc')
wc.generate(cut_word)

plt.imshow(wc)

当然,如果你直接就有分词计数的字典,就不需要调用generate函数,而是直接调用generate_from_frequencies函数。

1
2
3
4
5
6
7
8
9
python复制代码text = {
'罗攀':2,
'张三':1
}

wc = WordCloud(font_path = r'/System/Library/Fonts/Supplemental/Songti.ttc')
wc.generate_from_frequencies(text)

plt.imshow(wc)

总结

(1)通过process_text函数分析,wordcloud本身是对英文文本进行词云制作的第三方库。

(2)如果需要制作中文词云,就需要先通过jieba等中文分词库把中文文本分割开。

最后,上述的中文词云也并不上我们最终理想的词云,例如我,他等不需要显示出来,还有就是让词云更美化,这些内容下期再告诉你~

本文转载自: 掘金

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

MySQL索引原理,一篇从头到尾讲清楚

发表于 2021-02-22

索引,可能让好很多人望而生畏,毕竟每次面试时候 MySQL 的索引一定是必问内容,哪怕先撇开面试,就在平常的开发中,对于 SQL 的优化也而是重中之重。

可以毫不夸张的说,系统中 SQL 的好坏,是能直接决定你系统的快慢的。但是在优化之前大家是否想过一个问题?那就是:我们优化的原则是什么?优化SQL的理论基础是什么?

虽然说实践出真知,但是我更相信理论是支撑实践的基础,因为我们不可能毫无目的的去盲目的实践,因为这样往往事倍功半。

所以说了这么多只想告诉大家,在真正的开始索引优化之前,我们需要彻底搞明白索引的原理。这样再谈优化你将觉得更丝滑~

image-20210129141204613

1、索引的本质

索引的本质是一种排好序的数据结构。这个我相信其实大家并不陌生,因为谈到索引很多人自然而然的就会联想到字典中的目录。

没错,这样的类比是很形象的,但是如果再往深处说,恐怕很多小伙伴就有点张口结舌了,那既然你已经知道了索引的本质,那么您就已经有了看这篇文章的基础,相信读文本文的你,一定会对索引的原理有一个全新的了解。

2、索引的分类

在数据库中,索引是分很多种类的(千万不要狭隘的认为索引只有 B+ 树,那是因为我们平时使用的基本都是 MySQL)。而不同的种类很显然是为了应付不同的场合,那索引到底有那些种类呢?下面就让我们来大致的了解下。

2.1、Hash 索引

Hash 索引是比较常见的一种索引,他的单条记录查询的效率很高,时间复杂度为1。但是,Hash索引并不是最常用的数据库索引类型,尤其是我们常用的Mysql Innodb引擎就是不支持hash索引的。主要有以下原因:

  • Hash索引适合精确查找,但是范围查找不适合
    • 因为存储引擎都会为每一行计算一个hash码,hash码都是比较小的,并且不同键值行的hash码通常是不一样的,hash索引中存储的就是Hash码,hash 码彼此之间是没有规律的,且 Hash 操作并不能保证顺序性,所以值相近的两个数据,Hash值相差很远,被分到不同的桶中。这就是为什么hash索引只能进行全职匹配的查询,因为只有这样,hash码才能够匹配到数据。

对于 hash 索引,小伙伴们只需要了解到这里就可以了。

2.2、二叉树

另外,常见的索引使用的数据结构是树结构,首先我们来介绍下最经典的二叉树。

先来介绍下二叉树的特点:

    1. 二叉树的时间复杂度为 O(n)
    1. 一个节点只能有两个子节点。即度不超过2
    1. 左子节点 小于 本节点,右子节点 大于 本节点

首先来看一下二叉树的样子

image-20210130084707827

但是在极端情况下会出现链化的情况,即节点一直在某一边增加。如下图

image-20210130084839287

二叉树中,有一种特殊的结构——平衡二叉树,平衡二叉树的特点:

    1. 根节点会随着数据的改变而变更
    1. 数据量越多,遍历次数越多,IO次数就越多,就越慢(磁盘的IO由树高决定)

2.4、B树(二三树)

了解了二叉树之后,可以进一步谈一下什么是B树了。B 树大概是这样子的:

image-20210130090629198

从B树的结构图中可以看到每个节点中不仅包含数据的 key 值,还有 data 值。

而每页的存储空间是有限的,如果 data 比较大,会导致每个节点的 key 存储的较少,当数据量较大的时候,同样会导致B树很深,从而增加了磁盘 IO 的次数,进而影响查询效率。

好了,说到这里,常见的索引的种类也说完了,上面的内容仅仅是作为一个铺垫,下面我们正式开始 MySQL 的 B+ 树。

image-20210130090803072

2.5、B+树

MySQL 中最常用的索引的数据结构是 B+ 树,他有以下特点:

  1. 在 B+ 树中,所有数据记录节点都是按照键值的大小存放在同一层的叶子节点上,而非叶子结点只存储key的信息,这样可以大大减少每个节点的存储的key的数量,降低B+ 树的高度
  2. B+ 树叶子节点的关键字从小到大有序排列,左边结尾数据都会保存右边节点开始数据的指针。
  3. B+ 树的层级更少:相较于 B 树 B+ 每个非叶子节点存储的关键字数更多,树的层级更少所以查询数据更快
  4. B+ 树查询速度更稳定:B+ 所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同所以查询速度要比B树更稳定;
  5. B+ 树天然具备排序功能:B+ 树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便,数据紧密性很高,缓存的命中率也会比B树高。
  6. B+ 树全节点遍历更快:B+ 树遍历整棵树只需要遍历所有的叶子节点即可,,而不需要像 B 树一样需要对每一层进行遍历,这有利于数据库做全表扫描。

好了说了这么多的 B+ 树的特点,我们来张图看看 B+ 树到底长什么样子(如果看不懂,也没有关系,下文会一步一步解释说明的)

image-20210130101238224

上面的数据页就是实际存放数据页的地方,且数据页之间是通过双向链表进行连接的,好了到这里我们就将各个索引的类型快速了解了下,下面我们就开始正式B+树的分析。

3、主键目录

我们将上图中的数据页拿出来再细化下,就成了下面的这张图

img

我们都知道 MySQL 在存储数据的时候是以数据页为最小单位的,且数据在数据页中的存储是连续的,数据页中的数据是按照主键排序的(没有主键是由 MySQL自己维护的 ROW_ID 来排序的),数据页和数据页之间是通过双向链表来关联的,数据与数据时间是通过单向链表来关联的。

也就是说有一个在每个数据页中,他必然就有一个最小的主键,然后每个数据页的页号和最小的主键会组成一个主键目录(就像上图中的左边部分),假设现在要查找主键为 2 的数据,通过二分查找法最后确定下主键为 2 的记录在数据页 1 中,此时就会定位到数据页 1 接着再去定位主键为 2 的记录,我们先知道大致的流程,细节先不要深究,先从宏观看结构原理,再到微观看实现原理。

刚刚上面是说的其实可以理解为是主键索引,主键索引也是最简单的最基础的索引。这个时候大家应该知道为什么你建立了主键查询就能变快了吧?

4、索引页

但是现在假设有很多很多的是数据页,那是不是对应的主键目录会很大很大呢?

那假设有1000万条记录、5000万条记录呢?是不是就算是二分法查找,其效率也依旧是很低的,所以为了解决这种问题 MySQL 又设计出了一种新的存储结构—索引页。例如有下面这样情况,

img

假设上面的主键目录中的记录是非常非常多的,此时上面的结构是演变成这样子的,MySQL 会将里面的记录拆分到不同的索引页中,也就是下面这样子的

img

索引页中记录的是每页数据页的页号和该数据页中最小的主键的记录,也就是说最小主键和数据页号不是单纯的维护在主键目录中了,而是演变成了索引页,索引页和数据页类似,一张不够存就分裂到下一张。

假如现在要查找 id=20 的这条记录,咦?那我应该到哪个索引页中查找该条记录呢?所以这个时候肯定是需要去维护索引页的。

没错,MySQL 也是这么设计的,也就是说 MySQL 同时也设计出了用于维护索引页的数据结构,其实也还叫索引页,只不过他们是在不同的层级,类似下面这样子的:

image-20210129144230657

也就是说维护索引页的索引页是在真正存储记录和数据页的索引页的上一层,现在如果你想查找 id=20 的这条记录,那就是从最上层的索引页开始查找,通过二分法查找,很快就能够定位到 id=20 s这条记录是在索引页 2 上,然后到就索引页 2 上面查找,接着就是和之前一样了(注意,索引页中的记录也是通过单向链表连接的),根据各个最小的主键能够定位到 id=20 是在数据页5上,假设数据页5是这样子的

img

那这个时候你是不是能够想明白数据是怎么定位的了呢?

5、索引页的分层

好,既然你已经知道到索引页太多会往上一层扩散,那现在假设上一层的索引页记录也太多了,那该怎么办?很简单,继续分裂,再往上一层继续,不废话,我来画图帮助大家理解

image-20210129144541354

我看明白了,你看明白了吗?我们来模拟一个查找的过程,假设你要查找 37 这条记录,说实话我根本不知道这条记录在哪里。好,现在我们就来模拟 MySQL 的查找过程,首先从最顶层的索引页开始查找,因为 id=37,因此定位到了索引页16,然后到索引页 16 中继续查找,此时同样能够定位到 id=37 在索引页 3 中,然后继续查找,最终能够定位到数据实在数据页 8 中,假设数据页 8 是这样子的

img

是不是很完美?如果非要我把上面的图画完整,那….小弟义不容辞(图太大了,索引页中数据的链表结构就不画出来了)

img

这个时候机智的你是不是已经发现了什么小秘密?他是不是很像一颗二叉树?实际上这就是一颗 B+ 树的结构,这也是数据在磁盘中真正存储的物理结构。B+树的特性是什么呢?B+树,也是二叉搜索树的一种,但是他的数据仅仅存储在叶子节点(在这里就是数据页),像这种索引页+数据页组成的组成的B+树就是聚簇索引(这句话很重要)。

聚簇索引是 MySQL 基于主键索引结构创建的

6、非主键索引

但是现在问题又来了,既然这里强调的是主键索引,那我们平时开发中除了主键索引其他的索引也用的不少,这时候该怎么办?假设你现在对name、age建立索引。现在回顾下主键索引,是不是在插入数据的时候基于主键的顺序去维护一个 B+ 树的?

而实际上非主键索引其原理是一样的,MySQL 都是去维护一颗 B+ 树,说白了,你建立多少个索引,MySQL 就会帮你维护多少的B+树(这下是不是也突然想明白了为什么索引不能建立太多了?以前就知道不能建立太多索引,因为索引也会占用空间,实际上这就是根本原因)

假如现在真的对name+age建立索引,那此时是存放的呢?此时 MySQL 根据会 name+age 维护一个单独的 B+ 树结构,数据依旧是存放在数据页中的,只不过是原来数据中的每条记录写的是 id=xx,现在写的是name=xx,age=xx,id=xx,不管怎么样,主键肯定会存放的,先来张图压压惊

img

在插入数据的时候,MySQL 首先会根据 name 进行排序,如果 name 一样,就根据联合索引中的 age 去排序,如果还一样,那么就会根据 主键 字段去排序。插入的原理就是这样子的。

此时每个数据页中的记录存放的实际是索引字段和主键字段,而其他字段是不存的(为什么不存放?一样的数据到处存放很浪费空间的,也没必要,所以才会有下面的索引优化),至于查找,原理和过程跟聚簇索引一样,这里就不再赘述,但是,下面说的内容却是至关重要的:假设现在执行这样的SQL:

1
sql复制代码SELECT name FROM student WHERE name='wx'

那么此时的查询是完美的,使用到了索引且不需要回表

7.回表

是这样子的,现在要根据 name 查找到该条记录,且查询的字段(即 select 后面的查询字段)也仅仅有 name(只要是在 name,age,id 这三个字段中都可以)这个时候是能够直接获取到最终的记录的

换句话说,因为联合索引中的记录也仅仅有 name,age,id,所以在查询的如果也仅仅查询这三个字段,那么在该B+树中就能够查询到想要的结果了。

那现在假设查询的 SQL 是这样子的(我们假设 student 中还有除了name,age,id 其他的字段 )

1
sql复制代码SELECT * FROM student WHERE name='wx'

那这下子就完蛋了,因为你现在虽然根据 name 很快的定位到了该条记录,但是因为 name+age 不是聚簇索引,此时的 B+ 树的数据页中存放的仅仅是自己关联的索引和主键索引字段,并不会存其他的字段,所以这个时候其他的属性值是获取不到的,这时候该怎么办?

这种情况下,MySQL 就需要进行回表查询了。此时 MySQL 就会根据定位到的某条记录中的 id 再次进行聚簇索引查找,也就是说会根据 id 去维护 id 的那么 B+ 树中查找。因为聚簇索引中数据页记录的是一条记录的完整的记录,这个过程就叫回表。

再强调下回表的含义:根据非主键索引查询到的结果并没有查找的字段值,此时就需要再次根据主键从聚簇索引的根节点开始查找,这样再次查找到的记录才是完成的。

最后,让我一起看下 MySQL 对于非主键索引的维护过程:

对于非主键索引(一般都是联合索引),在维护 B+ 树的时候,会根据联合索引的字段依次去判断,假设联合索引为:name + address + age,那么 MySQL 在维护该索引的 B+ 树的时候,首先会根据 name 进行排序,name 相同的话会根据第二个 address 排序,如果 address 也一样,那么就会根据 age 去排序,如果 age 也一样,那么就会根据主键字段值去排序,且对于非主键索引,MySQL 在维护 B+ 树的时候,仅仅是维护索引字段和主键字段。

image-20210130103008706

本文转载自: 掘金

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

史上最全MySQL各种锁详解 二、全局锁、表级锁、页级锁、行

发表于 2021-02-22

一、前言

锁是计算机在执行多线程或线程时用于并发访问同一共享资源时的同步机制,MySQL中的锁是在服务器层或者存储引擎层实现的,保证了数据访问的一致性与有效性。

MySQL锁可以按模式分类为:乐观锁与悲观锁。按粒度分可以分为全局锁、表级锁、页级锁、行级锁。按属性可以分为:共享锁、排它锁。按状态分为:意向共享锁、意向排它锁。按算法分为:间隙锁、临键锁、记录锁。

下面将会按照上图进行一一讲解。

二、全局锁、表级锁、页级锁、行级锁

  1. 全局锁

(1) 概念

全局锁就是对整个数据库实例加锁。

(2) 应用场景

全库逻辑备份(mysqldump)

(3) 实现方式

MySQL 提供了一个加全局读锁的方法,命令是Flush tables with read lock (FTWRL)。

当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

风险点:

如果在主库上备份,那么在备份期间都不能执行更新,业务基本上就能停止。

如果在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。

解决办法:

mysqldump使用参数–single-transaction,启动一个事务,确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。

  1. 表级锁

(1) 概念

当前操作的整张表加锁,最常使用的 MyISAM 与 InnoDB 都支持表级锁定。

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

(2) 实现方式

表锁:lock tables … read/write;

例如lock tables t1 read, t2 write; 命令,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能在unlock tables之前访问其他表。

元数据锁:MDL 不需要显式使用,在访问一个表的时候会被自动加上,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

(3) 风险点

参考于:www.cnblogs.com/keme/p/1106…

给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。

  1. sessionA:

begin;

select* from t limit 1;

  1. sessionB:

select* from t limit 1;

  1. sessionC:

altertable t add f int;

#会mdl锁住

  1. sessionD:

select* from t limit 1;

我们可以看到 session A 先启动,这时候会对表 t 加一个 MDL 读锁。由于session B 需要的也是 MDL 读锁,因此可以正常执行。

之后 session C 会被 blocked,是因为 session A 的 MDL 读锁还没有释放,而 sessionC 需要MDL 写锁,因此只能被阻塞。

如果只有 session C 自己被阻塞还没什么关系,但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被session C 阻塞。前面说了,所有对表的增删改查操作都需要先申请MDL 读锁,而这时读锁没有释放,对表alter ,产生了mdl写锁,把表t锁住了,这时候就对表t完全不可读写了。

如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新session 再请求的话,这个库的线程很快就会爆满。

事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

**注 :一般行锁都有锁超时时间。但是MDL锁没有超时时间的限制,只要事务没有提交就会一直锁注。**

(4) 解决办法

首先我们要解决长事务,事务不提交,就会一直占着 MDL 锁。在 MySQL 的information_schema 库的 innodb_trx 表中,你可以查到当前执行中的事务。如果你要做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。这也是为什么需要在低峰期做ddl 变更。

  1. 页级锁

(1) 概念

页级锁是 MySQL 中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。因此,采取了折衷的页级锁,一次锁定相邻的一组记录。BDB 引擎支持页级锁。

4.行级锁

(1) 概念

行级锁是粒度最低的锁,发生锁冲突的概率也最低、并发度最高。但是加锁慢、开销大,容易发生死锁现象。

MySQL中只有InnoDB支持行级锁,行级锁分为共享锁和排他锁。

(2) 实现方式

在MySQL中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。在UPDATE、DELETE操作时,MySQL不仅锁定WHERE条件扫描过的所有索引记录,而且会锁定相邻的键值,即所谓的next-key locking。

(3) 实战

我们演示一下行锁的表现,分别在session1和session2中执行update操作,看会不会被锁定。

可以看到由于session1迟迟未提交事务,session2在等待session1释放锁时出现了超过锁定超时的警告了。

那么如果session2执行id=2的操作会不会成功呢?

执行id=2的操作是可以成功的。

三、乐观锁和悲观锁

  1. 乐观锁

(1) 概念

乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。

(2) 应用场景

适用于读多写少,因为如果出现大量的写操作,写冲突的可能性就会增大,业务层需要不断重试,会大大降低系统性能。

(3) 实现方式

一般使用数据版本(Version)记录机制实现,在数据库表中增加一个数字类型的“version”字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

(4) 实战

订单order表中id,status,version分别代表订单ID,订单状态,版本号。

1.查询订单信息

select id,status,versionfrom order where id=#{id};

2.用户支付成功

3.修改订单状态

update set status=**支付成功**,version=version+1 where id=#{id} and version=#{ version};

  1. 悲观锁

(1) 概念

悲观锁,正如其名,具有强烈的独占和排他特性,每次去拿数据的时候都认为别人会修改,对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。

(2) 应用场景

适用于并发量不大、写入操作比较频繁、数据一致性比较高的场景。

(3) 实现方式

在MySQL中使用悲观锁,必须关闭MySQL的自动提交,set autocommit=0。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。

(4) 实战

商品goods表中id,name,number分别代表商品ID,商品名称,商品库存。

1.开启事务并关闭自动提交

setautocommit=0;

2.查询商品信息

selectid,name,number from goods where id=1 for update;

3.用户下单,生成订单

4.修改商品库存

updateset number= number-1 where id=1;

5.提交事务

commit;

说明:select…for update是MySQL提供的实现悲观锁的方式,属于排它锁,在goods表中,id为1的那条数据就被当前事务锁定了,其它的要执行select id,name,number from goods where id=1for update;的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。

注意:此时MySQL InnoDB默认行级锁。行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住。

四、共享锁和排它锁

  1. 共享锁

(1) 概念

共享锁,又称之为读锁,简称S锁,当事务A对数据加上读锁后,其他事务只能对该数据加读锁,不能做任何修改操作,也就是不能添加写锁。只有当事务A上的读锁被释放后,其他事务才能对其添加写锁。

(2) 应用场景

共享锁主要是为了支持并发的读取数据而出现的,读取数据时,不允许其他事务对当前数据进行修改操作,从而避免”不可重读”的问题的出现。

适合于两张表存在关系时的写操作,拿mysql官方文档的例子来说,一个表是child表,一个是parent表,假设child表的某一列child_id映射到parent表的c_child_id列,那么从业务角度讲,此时我直接insert一条child_id=100记录到child表是存在风险的,因为刚insert的时候可能在parent表里删除了这条c_child_id=100的记录,那么业务数据就存在不一致的风险。正确的方法是再插入时执行select * from parent where c_child_id=100 lock in share mode,锁定了parent表的这条记录,然后执行insert into child(child_id)values (100)就不会存在这种问题了。

(3) 实现方式

select …lock in share mode

(4) 实战

session1持有共享锁,未提交。session2的查询不受影响,但是update操作会被一直阻塞,直到超时。

  1. 排它锁

(1) 概念

排它锁,又称之为写锁,简称X锁,当事务对数据加上写锁后,其他事务既不能对该数据添加读写,也不能对该数据添加写锁,写锁与其他锁都是互斥的。只有当前数据写锁被释放后,其他事务才能对其添加写锁或者是读锁。

MySQL InnoDB引擎默认update,delete,insert都会自动给涉及到的数据加上排他锁,select语句默认不会加任何锁类型。

(2) 应用场景

写锁主要是为了解决在修改数据时,不允许其他事务对当前数据进行修改和读取操作,从而可以有效避免”脏读”问题的产生。

(3) 实现方式

select …for update

(4) 实战

session1排它锁查询。session2也做排它锁查询会被阻塞。

五、意向共享锁和意向排它锁

  1. 概念

意向锁是表锁,为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。

  1. 作用

当有事务A有行锁时,MySQL会自动为该表添加意向锁,事务B如果想申请整个表的写锁,那么不需要遍历每一行判断是否存在行锁,而直接判断是否存在意向锁,增强性能。

  1. 意向锁的兼容互斥性

4. 实战注意:这里的排它 / 共享锁指的都是表锁!!!意向锁不会与行级的共享 / 排它锁互斥!!!

session1获取了某一行的排他锁,并未提交:

select*from goods where id=1 for update;

此时 goods 表存在两把锁:goods 表上的意向排它锁与 id 为 1 的数据行上的排它锁。

session2 想要获取 goods 表的共享锁:

LOCK TABLES goods READ;

此时session2 检测session1 持有goods 表的意向排他锁,就可以得知session1必然持有该表中某些数据行的排他锁,那么session2 对 goods 表的加锁请求就会被排斥(阻塞),而无需去检测表中的每一行数据是否存在排它锁。

六、间隙锁、临键锁、记录锁

  • 概念

–

记录锁、间隙锁、临键锁都是排它锁,而记录锁的使用方法跟排它锁介绍一致。

  • 记录锁

记录锁是封锁记录,记录锁也叫行锁,例如:

select *from goods where ****id**=**1 for update;

它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行。

  • 间隙锁

间隙锁基于非唯一索引,它锁定一段范围内的索引记录。使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。

select* from goods where id between 1 and 10 for update;

即所有在(1,10)区间内的记录行都会被锁住,所有id 为 2、3、4、5、6、7、8、9 的数据行的插入会被阻塞,但是 1和 10 两条记录行并不会被锁住。

  • 临键锁

临键锁,是记录锁与间隙锁的组合,它的封锁范围,既包含索引记录,又包含索引区间,是一个左开右闭区间。临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。

每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB 中行级锁是基于索引实现的,临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁。

goods表中隐藏的临键锁有:(-∞, 96],(96, 99],(99, +∞]

session1 在对 number 为 96 的列进行 update 操作的同时,也获取了(-∞, 96],(96, 99]这两个区间内的临键锁。

最终我们就可以得知,在根据非唯一索引对记录行进行 UPDATE \ FOR UPDATE \LOCK IN SHARE MODE 操作时,InnoDB 会获取该记录行的临键锁,公式为:左gap lock + record lock + 右gap lock。

即session1在执行了上述的 SQL 后,最终被锁住的记录区间为 (-∞, 99)。

欢迎大家关注微信公众号:CodingTao

本文转载自: 掘金

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

1…717718719…956

开发者博客

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