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

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


  • 首页

  • 归档

  • 搜索

工作三年,为什么你还不会排查堆外内存泄漏?(上)(文末附赠一

发表于 2024-02-23

image.png

前言

本文章是系列文章「Java经典故障案例分享」的第二篇(第一篇:<<# Java故障案例分析第一期:父子任务使用不当线程池死锁>>),本次分享一个完整的排查堆外内存泄漏的过程,你将在本文章中了解到:

  • Native Memory Track是什么以及如何发现可能存在的内存泄漏问题
  • 如何使用pmap命令查看Java进程的内存映射状态
  • 如何结合NMT和pmap排查内存泄漏

本文的主要内容:

  1. 故障描述
  2. 故障排查过程
  3. 提供一个小工具代码帮助快速分析

本文分为上下两篇,上篇主要讲述一般的内存泄漏排查过程,下篇讲述Netty堆外内存泄漏排查:juejin.cn/post/734012…

接下来就让我们开始吧!

故障描述

线上服务机器内存逐渐降低,最终触发内存不足告警,经过初步排查,进程所占用内存(通过ps的RSS查看)和JVM通过Native Memory Track显示的内存相差较大,达到2G以上。

故障排查过程

PS查看结果:
image.png
NMT查看结果:

image.png

ps结果 - nmt结果 = 2098913 Kbytes(进程总占用 - JVM占用) 约等于 2G,这2G内存到底是什么占用的呢?

简单介绍下我们这个应用:该应用是一个监控系统后端,是基于美团开源的CAT魔改的,它直接使用Netty从网络上读取业务应用上传的埋点数据,经过聚合计算后将结果存储到磁盘和数据库里。所以我们第一时间怀疑是不是Netty的问题,因为CAT是直接使用的Netty没有其他封装。

是不是Netty?

为了排查是不是Netty造成的,我们从Netty暴露的PooledByteBufAllocatorMetric中抓取使用的直接内存大小并配置监控,发现并没有一直增长:

image.png

而且通过应用日志,也没有Netty的泄漏日志。

通过调研,Netty的直接内存使用是在NMT track的一部分,在Internal项里:

1
2
3
4
5
ini复制代码...
- Internal (reserved=1100166KB, committed=1100166KB)
(malloc=1100134KB #68063)
(mmap: reserved=32KB, committed=32KB)
...

既然不是Netty造成的,那还有其他可能吗?

有的,比如:使用Native方法分配内存没有正确释放(即没有使用DirectByteBuffer,但是自己使用JNI等方式分配了不受JVM管理的内存) ,而我们极有这种情况。

那该如何排查呢?

有两种方式:

  • 直接点,找谁在分配内存,即找调用分配内存的函数的堆栈
  • 从泄漏内存的数据发现一些特点,顺藤摸瓜找到原因

第一种方法可以用perf工具找(如果是较新版本的内核可以用eBPF相关的工具如bcc、bpftrace排查,但我们线上内核版本是3.10)。但内存分配是非常频繁的操作,这种方法很难从大量调用中找到那个存在问题的,而且可能导致性能问题。所以我们使用第二种方法。

下面我们介绍这种情况的排查方式,即如何利用NMT + pmap分析泄漏的内存中的数据。

首先第一步要找到泄漏内存,第二步根据其中的数据找到原因


image.png

使用NMT & pmap找泄漏内存

什么是Native Memory Track?

首先介绍下NMT,全称是Native Memory Tracking (NMT) ,它是Hotspot VM用来分析VM内部内存使用情况的一个功能。我们可以利用jcmd(jdk自带)这个工具来访问NMT的数据。

开启方法:在启动参数中加上:

-XX:NativeMemoryTracking=summary或者-XX:NativeMemoryTracking=detail,在我们的排查中需要使用detail,但是detail有一定性能损失,切记不要长时间开启。detail相比summary包含了虚拟内存映射的信息和造成内存使用的调用栈信息。

查看方式:

jcmd $(pid) VM.native_memory summary 或 jcmd $(pid) VM.native_memory detail

我们使用NMT查看各个JVM内存区域占用的大小和虚拟内存预设情况,大致输出如下:

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
ini复制代码31748:

Native Memory Tracking:

Total: reserved=29167352KB, committed=18409052KB
- Java Heap (reserved=25165824KB, committed=15990784KB)
(mmap: reserved=25165824KB, committed=15990784KB)

- Class (reserved=1138901KB, committed=101205KB)
(classes #14554)
(malloc=2261KB #32680)
(mmap: reserved=1136640KB, committed=98944KB)

- Thread (reserved=249421KB, committed=249421KB)
(thread #434)
(stack: reserved=247492KB, committed=247492KB)
(malloc=1421KB #2185)
(arena=507KB #854)

- Code (reserved=260320KB, committed=104388KB)
(malloc=10720KB #13161)
(mmap: reserved=249600KB, committed=93668KB)

- GC (reserved=1161029KB, committed=820549KB)
(malloc=194373KB #712117)
(mmap: reserved=966656KB, committed=626176KB)

- Compiler (reserved=1045KB, committed=1045KB)
(malloc=915KB #1843)
(arena=131KB #15)

- Internal (reserved=1100166KB, committed=1100166KB)
(malloc=1100134KB #68063)
(mmap: reserved=32KB, committed=32KB)

- Symbol (reserved=19431KB, committed=19431KB)
(malloc=16770KB #169790)
(arena=2661KB #1)

- Native Memory Tracking (reserved=16399KB, committed=16399KB)
(malloc=622KB #8758)
(tracking overhead=15777KB)

- Arena Chunk (reserved=5664KB, committed=5664KB)
(malloc=5664KB)

- Unknown (reserved=49152KB, committed=0KB)
(mmap: reserved=49152KB, committed=0KB)
Virtual memory map:

[0x00000001f0000000 - 0x00000007f0000000] reserved 25165824KB for Java Heap from
[0x00007fce85c50c84] ReservedHeapSpace::ReservedHeapSpace(unsigned long, unsigned long, bool, char*)+0xb4
[0x00007fce85c1896f] Universe::reserve_heap(unsigned long, unsigned long)+0x3bf
[0x00007fce8571773d] G1CollectedHeap::initialize()+0x15d
[0x00007fce85c18fca] Universe::initialize_heap()+0x16a

[0x00000001f0000000 - 0x00000005c0000000] committed 15990784KB from
[0x00007fce857317c4] G1PageBasedVirtualSpace::commit_internal(unsigned long, unsigned long)+0x224
[0x00007fce8573184e] G1PageBasedVirtualSpace::commit(unsigned long, unsigned long)+0x7e
[0x00007fce85734345] G1RegionsLargerThanCommitSizeMapper::commit_regions(unsigned int, unsigned long)+0x35
[0x00007fce8579f636] HeapRegionManager::commit_regions(unsigned int, unsigned long)+0x76

[0x00000007f0000000 - 0x0000000830000000] reserved 1048576KB for Class from
[0x00007fce85c50021] ReservedSpace::ReservedSpace(unsigned long, unsigned long, bool, char*, unsigned long)+0x91
[0x00007fce85a0e1bf] Metaspace::allocate_metaspace_compressed_klass_ptrs(char*, unsigned char*)+0x4f
[0x00007fce85a0ec74] Metaspace::global_initialize()+0x574
[0x00007fce85c192e1] universe_init()+0x61

这里列出了JVM内部各个区域如堆、Class、线程等占用的内存。

而在 Virtual memory map: 下方的就是虚拟内存每个地址区域是用来做什么的,比如0x00000001f0000000 - 0x00000007f0000000这24GB的地址空间是用来映射堆内存的,而我们应用的最大堆内存就是24GB。

但是这里均是JVM管理下的内存,我们没办法从这里找出为什么RSS占用的内存大小比NMT中的多的原因,如果我们能够知道Java进程所有的内存映射然后和这里的比较,找出在NMT中没有的就能够知道是什么在占用我们的内存,那么如何知道一个进程所有的内存映射呢?

接下来介绍pmap命令。

pmap命令

PMAP命令用于展示一个或多个进程的内存映射,命令格式如下:

pmap [options] pid [...]

下面是一个例子:

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
lua复制代码pmap 25693

000055bde4835000      8K r-x-- gedit

000055bde4a36000      4K r---- gedit

000055bde4a37000      4K rw--- gedit

000055bde5d32000  13944K rw---   \[ anon ]

00007fc910000000    132K rw---   \[ anon ]

00007fc910021000  65404K -----   \[ anon ]

00007fc918000000    896K rw---   \[ anon ]

00007fc9180e0000  64640K -----   \[ anon ]

00007fc91c750000    204K r---- UbuntuMono-R.ttf

00007fc91c783000    644K r-x-- libaspell.so.15.2.0

00007fc91c824000   2048K ----- libaspell.so.15.2.0

00007fc91ca24000     20K r---- libaspell.so.15.2.0

00007fc91ca29000      4K rw--- libaspell.so.15.2.0

00007fc91ca2a000      8K r-x-- libenchant\_aspell.so

00007fc91ca2c000   2044K ----- libenchant\_aspell.so

00007fc91cc2b000      4K r---- libenchant\_aspell.so

00007fc91cc2c000      4K rw--- libenchant\_aspell.so

00007fc91cc2d000     44K r-x-- libenchant\_hspell.so

00007fc91cc38000   2044K ----- libenchant\_hspell.so

00007fc91ce37000      4K r---- libenchant\_hspell.so

00007fc91ce38000     12K rw--- libenchant\_hspell.so

00007fc91ce3b000    428K r-x-- libhunspell-1.6.so.0.0.1

00007fc91cea6000   2044K ----- libhunspell-1.6.so.0.0.1

00007fc91d0a5000      4K r---- libhunspell-1.6.so.0.0.1

00007fc91d0a6000     16K rw--- libhunspell-1.6.so.0.0.1

00007fc91d0aa000     16K r-x-- libenchant\_myspell.so

00007fc91d0ae000   2048K ----- libenchant\_myspell.so

00007fc91d2ae000      4K r---- libenchant\_myspell.so

...

...

...

可以看到每个映射的起始地址和大小,而如果和NMT下面的输出一起来看是不是能够分析出哪些映射是在NMT中没有的?

结合pmap和NMT

我们可以按照这个思路手动在pmap的输出中找哪些没有在nmt输出中出现的,或者写一个shell脚本来匹配。

这里我写了一个脚本在github上:github.com/hengyoush/J…,或者直接下载,使用方法:./memleak.sh show pid。

发现几个64MB的块很可疑,如:

image.png

但是该怎么查看这个地址块的内容呢?

泄漏内存分析

这些地址块可以通过下面的脚本查看:

1
2
3
4
5
6
bash复制代码 cat /proc/$1/maps | grep -Fv ".so" | grep " 0 " | awk '{print $1}' | grep $2 | ( IFS="-"
while read a b; do
dd if=/proc/$1/mem bs=$( getconf PAGESIZE ) iflag=skip_bytes,count_bytes \
skip=$(( 0x$a )) count=$(( 0x$b - 0x$a )) of="$1_mem_$a.bin"
color_text "$GREEN$BOLD" "Dump文件已输出至: ./$1_mem_$a.bin"
done )

将上述脚本保存为dump.sh, 然后执行:./dump.sh ${pid} ${address}

比如:./dump.sh 31748 7fcbfb622000

可以得到如下的文件:31748_mem_7fcbfb622000.bin

这个文件中包含的就是该地址块的内容,但是是二进制的,可以使用如下命令转为字符串方便查看:strings 31748_mem_7fcbfb622000.bin > 31748_mem_7fcbfb622000.bin.txt

查看转换后的内容:

image.png

我们发现这应该是一个接口返回的监控报表数据(我们的这个服务是一个监控服务,会有客户端拉取监控数据),查找了相关代码后终于水落石出了!!原来在使用servlet输出流写响应时没有正确使用try-with-resource,当客户端应用主动关闭连接时(发布或者重启),写入失败,但是流没有关闭,相关代码大致如下:

image.png
修复上线之后,内存不再诡异减少了,RSS和NMT输出也不会越来越大了。

总结

我帮你总结了一下排查步骤:
当你怀疑线上出现内存泄漏时,首先确定是堆外内存造成的还是堆内,堆外内存造成的有如下特点:

  1. JVM本身没有大量FGC等情况
  2. 机器内存不足
  3. RSS - NMT统计的内存 差距较大

如果确定是堆外内存造成,按照如下方式排查:

  1. 确定是否是Netty内存泄漏,排查方式有:应用日志有没有Netty的泄漏日志,Netty的直接内存指标有没有一直上升。
  2. 如果不是Netty内存泄漏,按照如下流程:
1. 使用`pmap -x {pid} > ./pmap.txt` 获取内存映射信息到文件里。
2. 使用`jcmd ${pid} VM.native_memory detail > nmt.txt`获取NMT统计的JVM内存详细信息。
3. 找nmt中没有但是在pmap中有的内存块地址(tips1:最好找64MB的内存块)
4. 使用上面的`dump.sh:./dump.sh {pid} {addr}` dump对应内存块的内容到磁盘上。
5. 使用 `strings {dump文件名} > dumped.txt`转换为可读的文本,然后根据文本内容进一步寻找问题的原因
  1. 如果是Netty内存泄漏,可以看我的这一篇文章:juejin.cn/post/734012…

是不是还挺复杂的,没关系,使用我写的一个小工具JavaMemLeak(Github地址)可以帮你自动化上述流程,只要执行:./memleak.sh show pid列出可能存在泄漏的内存映射地址:

1
2
3
4
5
css复制代码00007f2824000000   19396   19396   19396 rw---   [ anon ]
00007f28252f1000 46140 0 0 ----- [ anon ]
00007f2830000000 9752 9672 9672 rw--- [ anon ]
00007f2830986000 55784 0 0 ----- [ anon ]
00007f2834000000 11624 11624 11624 rw--- [ anon ]

命令行执行:./memleak.sh dump pid addr 得到内存块里的内容进一步分析,输出如下:

1
makefile复制代码Dump文件已输出至: ./11983_mem_7f2964000000.bin

最终会产出一个泄漏内存块的文件,你只需要从第五步开始就可以啦!

如果有帮到你的话欢迎star:github.com/hengyoush/J…

image.png

本文转载自: 掘金

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

Jetpack Compose -> 重组作用域和remem

发表于 2024-02-22

前言


上一章我们讲解了 MutableState 和 mutableStateOf() 本章我们讲解下 remember 这个关键方法;

ReCompose Scope(重组作用域)


我们先来看一段代码

infoflow 2024-02-20 15-12-01.png

当我们将

1
2
3
csharp复制代码var name by mutableStateOf("老A")

lifecycleScope.launch{}

这两行代码放到 setContent 中的时候,它提示红色了,显然这样写,Compose 是不允许的(这里其实是可以运行的,但是运行之后没有任何的效果,也就是 name 的值在3s后并没有变成 “Mars”),那么这是为什么呢?

这里面,我们写的协程是没有任何问题的,它也正常的执行了,但是真正影响我们效果的是 mutableStateOf 提示的那行代码;

我们前面有说到,Compose 会在使用到这些变量的值改变的时候得到通知,从而进行重组(recompose) ,例如 Text(name) 它会在 name 变化的时候得到通知,进行重组,但是我们来具体想一下,它怎么被执行的呢?比如说:我们现在需要写一段代码,需要在运行的时候,从界面上指定一段代码去重复执行,例如重复执行 Text(name) 这行代码,那么应该怎么实现呢?基本思路就是拿到这行代码,然后执行它,但是这好像不太能实现,在 java 也好 kotlin 中也罢,几乎是不太能实现的;

但是在 Compose 中,它替我们进行了实现,Compose 的编译器插件会修改逻辑,它会把这些可能会被重新调用的代码块给包了起来,然后在这个被包起来的代码块执行完成之后,它会把这个代码块保存下来,并标记到当前它被执行的这个位置,去做个标记,当重新执行的条件达成的时候,比如说:当 name 的值发生改变的时候,Text(name) 所在的代码块就会发生 Recompose 具体来说这个所在代码块它会重新执行,并且执行的时候它所依赖的变量值就是最新的值,从而就能基于最新值组合出最新的界面;如果组合的结果和上一次的结果不同,那么布局和绘制流程也会重新再走一遍,从而有更新界面的显示,如果组合的结果和上次的结果相同,说明界面的实质内容没有改变,则不会重新绘制;

Compose 并不是包裹所有的代码,而是包裹需要包裹的代码,什么是需要包裹的代码?就是可能会发生变化的代码,例如 Text(name) 这个 name 是一个变量,这个 name 改变了,这个 Text 就需要重新执行,那么这个就是需要被包裹的代码;

也就是说 ReCompose 并不是全局的,而是哪里需要做才做,这种被包裹起来在 ReCompose 的时候一起执行的代码范围就被 Compose 称作 ReCompose Scope(重组作用域)

所以代码执行的问题就在

1
csharp复制代码var name by mutableStateOf("老A")

这行代码上,它重新执行的时候,并不是 Text(name) 这一行了,而是一个范围了,这个范围内的代码都会被重新执行了,自然也就包含了 name 的初始化过程,那么这就意味着当 Text(name) 执行的时候,它所用到的值已经不是刚才被协程修改过的值了(”Mars”),而是一个被重新初始化的值了,也就是 “老A” 这个字符串,本质上不是赋值失败,而是赋值之后触发了 ReCompose,并且 ReCompose 的范围波及到了更多的代码,导致重新创建了一个新的 name 对象,并且 Text(name) 重新调用的时候,采用的是这个新的值;而那个旧的 name 确实被改成了 “Mars” 但是 Text 用的不是这个旧的 name 了,而是一个新的 name;

那么怎么解决呢?把 Text(name) 包裹起来就可以了

1
2
3
4
5
6
7
8
9
scss复制代码var name by mutableStateOf("老A")
// 这样包裹一下
Button(onClick = { /*TODO*/ }) {
Text(text = name)
}
lifecycleScope.launch {
delay(3000)
name = "Mars"
}

remember


但是,我们在业务开发的时候,肯定是不能这么写的(业务场景也不允许嘛),那么我们还有其他的方法来规避吗?我们来看下 IDE 有没有给提供解决方案,我们把鼠标放到警告的地方看下:

infoflow 2024-02-22 14-47-56.png

Creating a state object during composition without using remember

我们没有使用 remember 来创建一个 state object,也就是需要我们使用 remember 来包一下这个 state object

1
csharp复制代码var name by remember { mutableStateOf("老A") }

可以看到红线警告消失了;

remember 所做的事情就是:在它第一次执行的时候,它会执行 lambda 中的代码 {mutableStateOf(“老A”)} 同时呢,它也会保存这个结果,再次调用这个 remember 的时候,它就会直接返回这个保存的结果,remember 在这里起到了一个缓存的作用;

我们把 Button 包裹去掉看下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码setContent {
Android_VMTheme {
Surface {
var name by remember { mutableStateOf("老A") }
Text(text = name)
lifecycleScope.launch {
delay(3000)
name = "Mars"
}
// Ui()
}
}
}

可以看到,3s 后变成了『Mars』,也就是那个缓存的值被改成了 Mars,二次初始化拿到的是老的值;

所以说这个 remember 在 Compose 中是很有用的,它可以防止由于 ReCompose 而导致的预期之外的某些变量的反复初始化,这些反复初始化的变量可能会带来意料之外的结果,但是加一个 remember 这些问题就可以解决了;

但是我们在实际业务开发的时候,怎么去判断哪些变量会被 ReCompose 呢?我们是无法判断的,那怕这段逻辑开发这很清楚,但是一旦被外部调用的时候,它就不可控了;

所以 Compose 针对所有的 mutableStateOf() 都加上 remember 包裹;

这里有一点要注意:我们用 remember 包裹是为了防止被 Compose 包裹的代码执行 ReCompose 从而导致变量的反复初始化带来的意料之外的结果;

如果变量的初始化没有被 Compose 包裹,也就是,我们把变量的初始化放到了外面

infoflow 2024-02-22 15-06-45.png

可以看到,remember 上进行了红色警告

@Composable invocations can only happen from the context of a @Composable function

也就是说我们不能使用 remember 关键字,所以说,无论 Text(name) 怎样 ReCompose 都不会导致 name 变量的重新初始化;

因为 remember 是一个 Composable 函数,它只能在另一个 Composable 函数中被调用

infoflow 2024-02-22 18-29-52.png

这个还会导致编译失败;

带参数的 remember

1
2
3
4
5
6
7
kotlin复制代码@Composable
fun Request(value: String) {
val length = remember {
value.length
}
Text(text = "长度为$length")
}

如果我们每次调用这个 Request 方法的时候,传递的 value 值都是一样的,那么这段代码执行起来就没有问题,一旦我们传入的值不一样的时候,那么这个段代码执行就有问题了;

例如:第一次 mars

第二次 mars

第三次 old a

这个时候,如果使用了 remember 那么 length 的长度就不对了,

怎么解决这个问题呢? remember 提供了带参数的方法,这个带参数的意思是:虽然我可以缓存,但是我要给缓存加上一个或者几个 key,如果 key 和上次一样,我就用缓存的,如果不一样,我就不使用缓存了;

1
2
3
4
5
6
7
kotlin复制代码@Composable
fun Request(value: String) {
val length = remember(value) {
value.length
}
Text(text = "长度为$length")
}

这个意思就是 value 会影响 lambda 中的计算结果,如果 value 没有变就不计算了,直接返回结果,变了就执行计算逻辑;

好了,今天的内容就到这里吧~~

下一章预告


无状态,状态提升,单向数据流

欢迎三连


来都来了,点个关注点个赞吧,你的支持是我最大的动力

本文转载自: 掘金

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

谈谈普通人做副业的正确顺序

发表于 2024-02-22

普通人做副业的正确顺序.jpg

本文首发于公众号:嘟爷创业日记 。 我已经坚持日更120天+,欢迎过来追剧~

最近掘金的文章流量特别大,那篇2023年总结的点赞评论数据都不错,然后就有一些执行力高的人主动来链接。

然后和大家聊了一下,发现都有一个问题,就是大部分人以前是没怎么接触过副业的,只有一个主业收入,其实在前几年的时候,主业如果发展得好,是不用考虑做副业的,一个工作干到退休就好了。

但是现在好像不行了,大家都对工作的稳定性抱有怀疑,害怕突然工作没了,身上又背负着各种债务,上有老下有小,想想就觉得很焦虑压力很大,所以想尝试人生第二曲线的想法越来越强烈。

说到底还是不确定性引发的焦虑,面对未来的不确定性,大家想做点什么来对抗这个不确定性或者说降低不确定性的可能。说来我很有感触啊,我这次因为公司发不起工资而离职,如果我没有多年积累的副业底子在,那我肯定也焦虑得要死,也要继续投入找工作的大军里面。所以我要感谢几年前的自己做出的决定。

下面这个是大家投票的一些结果,看上去大部分人都是卡在无从下手阶段。

image.png

回头我再根据大家投票结果来输出一篇分析,这里我对新人开始做副业的顺序谈谈我的想法。

1:要有强烈的做副业的意愿,没有这个的话你无法坚持,遇到一点小挫折就会停止

2:满足条件1了,那就是开眼界阶段,你要去了解各种副业,去拆解,他们怎么赚到钱的,这个项目核心赚钱点在哪里,先要观世界,才有世界观,你要看的多了懂得多了, 可选择的才会变多。

开眼界这个阶段,就是就是建立高质量信息输入管道,你可以自己找资料,免费和付费都行,免费花时间省钱,付费花钱省时间,看自己取舍,我自己每年用来知识付费大部分钱都是用来了解副业信息,我自己很少加训练营。

3:实操小项目,看得再多,都不如自己躬身入局把手弄脏,你学到不等于是你掌握,只有不断去试错去迭代,你才能掌握一些技能和能力,这些技能和能力才是你需要花时间去沉淀,这样,等下一次机会来临的时候,你的起点才会更高。关于如何挑选项目,这个以前也介绍过一次,回头我专门再写一篇谈谈我的看法。

4:选择项目的时候尽量先选择成本小的,因为你们刚开始更多的是关注技能的学习,先从赚到第一块钱开始,想知识付费参加训练营啥的,我建议等你后期已经实践过几个副业项目后,沉淀了一些底层能力了再去。那样你成功的概率才会更大。

做副业,无非就是人货场这个商业逻辑,都满足了,自然就能成交。

5:长期项目还是短期项目

短期项目用来练手,长期项目来吃时间杠杆复利。

新人的话可以尝试做短期的项目来练手,先从赚到第一块钱开始,形成正反馈。说到正反馈,这个其实很重要的,我相信也有很多人去尝试做过副业,但是鼓捣了好久发现没有正反馈,1毛钱都赚不到,很沮丧,然后就放弃了,这种情况我也发生过,别看我总结文章里面提到的副业都是我赚到钱了,还有很多我尝试了放弃的项目,确实是因为没赚到钱放弃的。

但是如果是现在的话,我就不会因为没赚到钱而放弃,我会考虑额外的东西,就是能力的学习,我现在尝试做一个项目的时候,会综合考虑,做这个项目除了赚钱之外是否能学到一些东西,如果能我就继续,如果技能都学会了还是不能赚到钱,我再考虑放弃。

今天先说这些,其实还有很多要展开的地方,不同的阶段做法其实不一样,很多我自己也没理顺,不过没关系,大家如果想看这种系列,我也会去归纳和总结,写一些我的感悟。

靠近拿到过结果的人,听听对方的经验分享,这也是一种快速进步的方式。

本文转载自: 掘金

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

AI副业最赚钱的应该就是卖课了吧

发表于 2024-02-22

这一年AI相关的话题一直没停过,国内外各大平台也卷来卷去,当然最强的还是Open AI。平台互卷对于普通平民百姓来说是好事,能白嫖嘛~

这波AI热潮除了AI相关的产品层出不穷之外,另一个热门话题应该就是AI相关的副业了。

比如用AI写作赚稿费,用AI写小红书推文恰饭。还有用AI绘画,帮别人做头像、做壁纸赚钱等等。这些法子我没试过,不确定是否真的能赚到钱。但有一个副业有点刺到我眼睛,那就是卖课程。

各大平台都有这类课程,慕课网、B站、极客时间、知识星球等平台都有。AI课程主要分2类,一类是真的讲原理、讲搭建;另一类是讲怎么使用AI产品。前一类课程含金量是比较大的,而讲解如何使用AI产品这类付费课程真的是镰刀高手。注意,我说的是讲解如何使用AI产品的付费课程。比如让你花钱学AI写作、AI绘画,然后你就能自己做副业的这类课程,大概率都是那老师的副业,他们是真的在做自己副业,在赚你的钱。

举个例子,在B站课堂找个付费课程,看看目录,能给你试看的都是无关痛痒的章节,通常先给你制造点焦虑,比如其他人都掌握了AI工具转化成生产力了,你呢?其他人都在用AI做副业赚钱了,你呢?

而后面的课程呢,可以说都是换汤不换药的,比如你知道“如何用AI做私人家教”,那你“把AI变成考试神器”也可以说掌握了。所以通常不会给你试看后面的内容。

01.png

类似这种课程市面上有很多,大概率是打开个AI工具,然后演示给你看怎么输入提示词。说白了就是如何更好的和AI对话。

这类课程通常都没啥含金量,但销量却不错。比如我截图的这个课程,在B站排名很前,而且评论很好。

02.png

原因很简单,和你点外卖好评返现一样。这类课程通常会送AI工具给你使用,如果给好评的话会送多一些东西给你。都是些成熟套路了。

为什么我说这类课程没啥含金量呢?

如果你真的想用AI工具,你稍微百度一下就能找到一堆。比如你百度搜“AI工具导航”,只要没有广告标识的,点进去大概率都会给你列一堆出AI工具出来。

03.png

04.png

这些教你如何使用AI工具的课程,和你说你只要掌握了XXX工具就能用AI绘画,就能去做副业赚钱。说实话,你本身不是绘画专业的,你本身对绘画一点知识都不懂,不知道高光应该打在哪,不知道阴影出现在哪里是合适的,不知道画派,你本身不具备一定的艺术审美能力,只知道“好看”和“不好看”,你真的不一定能通过AI绘画赚到钱。

包括最近 OpenAI 发布的 Sora,可以生成视频。这对普通人来说,你用它生成的视频大概率是没电影专业的同学用它生成的视频那么好看。

作家通过AI辅助写作,他们写出来的小说肯定比素人用AI写的小说畅销。

这类AI工具暂时对特定专业的人群是有辅助作用的,但远没有到“人人都”的程度。所以不要被课程宣传片骗到,AI暂时还只是打辅助,要替代你的工作还需要一段时间。不是说你现在不学如何使用AI工具就会立刻被淘汰。这都是卖课程的销售技巧而已。

那些通过AI搞副业赚到钱的(初卖课外)通常在自己的领域有一定技术基础。当下我们要做的是要找到自己喜欢的领域去深耕,遇到合适的AI工具就用它来辅助自己。

本文转载自: 掘金

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

SQL中为什么不要使用1=1

发表于 2024-02-20

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。

编写SQL语句就像炒菜,每一种调料的使用都可能会影响菜品的最终味道,每一个SQL条件的加入也可能会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢?

为什么会使用 1=1?

在动态构建SQL查询时,查询条件往往都是动态的,最终执行时可能会使用不同的条件。这时候,他们就会使用“1=1”作为一个始终为真的条件,让接下来的所有条件都可以方便地用“AND”连接起来,就像是搭积木的时候先放一个基座,其他的积木块就都可以在这个基座上叠加。

就像下边这样:

1
2
3
4
5
6
7
ini复制代码SELECT * FROM table WHERE 1=1
<if test="username != null">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>

这样就不用在增加每个条件之前先判断是否需要添加“AND”。

1=1 带来的问题

性能问题?

我们先来了解一下数据库查询优化器的工作原理。查询优化器就像是一个聪明的图书管理员,它知道如何最快地找到你需要的书籍。当你告诉它所需书籍的特征时,它会根据这些信息选择最快的检索路径。比如你要查询作者是“谭浩强”的书籍,它就选择先通过作者索引找到书籍索引,再通过书籍索引找到对应的书籍,而不是费力的把所有的书籍遍历一遍。

但是,如果我们告诉它一些无关紧要的信息,比如“我要一本书,它是一本书”,这并不会帮助管理员更快地找到书,反而可能会让他觉得困惑。一个带有“1=1”的查询可能会让数据库去检查每一条记录是否满足这个始终为真的条件,这就像是图书管理员不得不检查每一本书来确认它们都是书一样,显然是一种浪费。

你可能会说:数据库没有这么傻吧?

确实,这实际上可能不会产生问题,因为现代数据库的查询优化器已经非常智能,它们通常能够识别出像 1=1 这样的恒真条件,并在执行查询计划时优化掉它们。在许多情况下,即使查询中包含了1=1,数据库的性能也不会受到太大影响,优化器会在实际执行查询时将其忽略。

但是优化器并不是万能的。在某些复杂的查询场景中,即使是简单的 1=1 也可能对优化器的决策造成不必要的影响,比如导致全表扫描。

代码质量

另外从代码质量的角度,我们也需要避免在查询中包含 1=1,有以下几点考虑:

  1. 代码清晰性:即使数据库可以优化掉这样的条件,但对于阅读SQL代码的人来说,1=1可能会造成困惑。代码的可读性和清晰性非常重要,特别是在团队协作的环境中。
  2. 习惯养成:即使在当前的数据库系统中1=1不会带来性能问题,习惯了写不必要的代码可能会在其他情况下引入实际的性能问题。比如,更复杂的无用条件可能不会那么容易被优化掉。
  3. 跨数据库兼容性:不同的数据库管理系统(DBMS)可能有不同的优化器能力。一个系统可能轻松优化掉1=1,而另一个系统则可能不那么高效。编写不依赖于特定优化器行为的SQL语句是一个好习惯。

编写尽可能高效、清晰和准确的SQL语句,不仅有助于保持代码的质量,也让代码具有更好的可维护性和可扩展性。

替代 1=1 的更佳做法

现在开发者普遍使用ORM框架来操作数据库了,还在完全手写拼SQL的同学可能需要反思下了,这里给两个不同ORM框架下替代1=1的方法。

假设我们有一个用户信息表 user,并希望根据传入的参数动态地过滤用户。

首先是Mybatis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xml复制代码<!-- MyBatis映射文件片段 -->
<select id="selectUsersByConditions" parameterType="map" resultType="com.example.User">
SELECT * FROM user
<where>
<!-- 使用if标签动态添加条件 -->
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>
<!-- 更多条件... -->
</where>
</select>

在 MyBatis 中,避免使用 1=1 的典型方法是利用动态SQL标签(如 <if>)来构建条件查询。<where> 标签会自动处理首条条件前的 AND 或 OR。当没有满足条件的 <if> 或其他条件标签时,<where> 标签内部的所有内容都会被忽略,从而不会生成多余的 AND 或 WHERE 子句。

再看看 Entity Framework 的方法:

1
2
3
4
5
6
7
8
9
10
ini复制代码var query = context.User.AsQueryable();
if (!string.IsNullOrEmpty(username))
{
query = query.Where(b => b.UserName.Contains(username));
}
if (age>0)
{
query = query.Where(b => b.Age = age);
}
var users = query.ToList();

这是一种函数式编程的写法,最终生成SQL时,框架会决定是否在条件前增加AND,而不需要人为的增加 1=1。

总结

“1=1”在SQL语句中可能看起来无害,但实际上它是一种不良的编程习惯,可能会导致性能下降。就像在做饭时不会无缘无故地多加调料一样,我们在编写SQL语句时也应该避免添加无意义的条件。

每一行代码都应该有它存在的理由,不要让人和数据库浪费时间在不必要的事情上。

本文转载自: 掘金

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

分享 9 个开源 Rust 项目

发表于 2024-02-20

未标题-1.jpg

Rust 是一种优雅而强大的语言,配备了用于应用程序开发的全面工具,从格式化到文档创建。然而,作为一种编译语言,它需要额外的努力来确保跨不同架构的兼容性。幸运的是,Rust 为开发人员简化了这个过程。

本文分享 9 个开源 Rust 项目,涵盖区块链、去中心化、WEB开发、Docker 容器 和 Rust 开发框架,可以将其用于应用程序和系统,同样是学习 Rust 不错的资源。

Oxc :Web 开发人员的 Rust 工具

image.png

Oxidation 编译器正在为 JavaScript 和 TypeScript 创建一组高性能工具。

Oxc 构建一个解析器、linter、格式化器、转译器、压缩器、解析器…都是用 Rust 实现的,是学习 Rust 不错的项目。

  • 项目地址:github.com/oxc-project…

Kata Containers :容器和虚拟机

image.png

Kata Containers 是介于虚拟机和容器之间。Kata Containers 是一个开源项目和社区,致力于构建轻量级虚拟机 VMs 的标准实现,这些虚拟机的形式和性能类似于容器,但提供了虚拟机的工作负载隔离和安全优势。

  • 项目地址:github.com/kata-contai…

Mise: 开发环境版本切换器

Mise 的前身是 rtx,更名是为了避免与 Nvidia 的图形卡系列混淆。Mise 是一个开发环境设置工具,安装和管理开发工具/运行时,如 Node、Python 或 terraform,简化了这些工具的安装,又允许指定在不同项目中使用这些工具的版本。

demo.gif

项目地址:github.com/jdx/mise

Tokio :Rust 的异步运行时

在不影响速度的情况下构建可靠的网络应用程序,是 Rust 编程语言的异步运行时。提供了编写网络应用程序所需的构建模块。可以灵活地针对各种系统,从具有数十个内核的大型服务器到小型嵌入式设备。

Tokio 提供了一些主要组件,这些组件提供构建异步应用程序所需的运行时组件:

  • 一个多线程、基于 work-stealing 的任务调度程序。
  • 由操作系统的事件队列(epoll、kqueue、IOCP 等)支持的反应器。
  • 异步 TCP 和 UDP sockets。
  • 项目地址:github.com/tokio-rs/to…

Anoma :去中心化的

区块链和 web3 有很多项目使用 Rust 作为开发语言。Anoma 以意图为中心的架构,用于去中心化交易对手发现、解决、信息流控制和多链原子结算。

项目地址:github.com/anoma

Loco :用于业余项目和初创公司的 Rust 框架

image.png

Loco 的灵感来自 Ruby on Rails,允许开发人员使用 Rust 编写 MVC 风格的 web 应用程序。Rust的语言特性,如并发性、安全性、强类型和性能,是与Rails或其衍生品相比的一些优势。Loco 的创建者专注于轻松构建 MVC 风格应用程序的 Rust 开发人员,而无需在其他地方寻找熟悉的开发人员体验。

  • 项目地址:loco.rs/

Raratui :终端应用程序框架

Raratui 一个用于构建终端用户界面的 Rust 工具包,是一个轻量级库,提供了一组小部件和实用程序来构建复杂的 Rust TUI。Ratatui 基于使用中间缓冲区立即渲染的原理,意味着对于每一帧,应用程序必须渲染所有应该属于 UI 一部分的小部件。

demo2.gif

  • 项目地址:ratatui.rs/

Tokei :代码统计

Tokei 提供有关项目的统计信息,包括代码行数。它可以处理多种语言并将数据布局到一个漂亮的表格。

image.png

  • 项目地址:github.com/XAMPPRocky/…

Leo :一种函数式静态类型编程语言

Leo 是一种函数式静态类型编程语言,专为编写私有应用程序而构建,是通向以隐私为中心的应用程序新时代的桥梁。。 Leo 是一种高级编程语言,可编译为低级 Aleo 指令。

  • 项目地址:leo-lang.org/

结束,谢谢

本文转载自: 掘金

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

ArraysasList() 都有什么隐藏的陷阱?

发表于 2024-02-19

Arrays.asList() 方法我们平时开发中一定经常使用,它是将数组转换为List的一种便捷方式,但它有一些潜在的陷阱需要注意。使用的时候需要多多注意呦。

1.不可变性

Arrays.asList() 返回的List是固定大小的,这意味着它不支持对元素的增删操作。任何试图修改大小的操作都会导致UnsupportedOperationException。

因为它返回的是 java.util.Arrays.ArrayList 的实例,而不是 java.util.ArrayList。Arrays.ArrayList 是一个内部类,它基于一个固定大小的数组,并将其包装为 List 接口的实现。因此,它不支持添加或删除元素,只能对现有元素进行修改。

源码贴张图吧,能看到size属性是按照入参的长度给定的:

image.png

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码import java.util.Arrays;
import java.util.List;

public class Main {
public static void main(String[] args) {
// 创建一个固定大小的List
List<String> list = Arrays.asList("a", "b", "c");

// 尝试添加元素
// list.add("d"); // 这会抛出 UnsupportedOperationException

// 尝试删除元素
// list.remove(0); // 这会抛出 UnsupportedOperationException

// 尝试清空列表
// list.clear(); // 这会抛出 UnsupportedOperationException

// 可以修改现有元素
list.set(0, "A");

System.out.println(list); // 输出 [A, b, c]
}
}

2.返回的是java.util.Arrays$ArrayList

返回的列表是 java.util.Arrays 的一个私有静态类,而不是标准的 ArrayList 类。这意味着一些 ArrayList 的方法和特性可能不可用。

Arrays.asList() 返回的 ArrayList 和真正的 ArrayList 在某些方面是相似的,但也有一些重要的区别。

相似之处:

  1. 元素访问:两者都支持通过索引访问元素。
  2. 迭代:可以使用迭代器或增强型 for 循环来遍历它们。
  3. 尺寸:都具有 size() 方法来获取列表的大小。

区别:

  1. 可修改性:
    • Arrays.asList() 返回的列表是固定大小的,不能添加或删除元素,也不能调整大小。
    • 真正的 ArrayList 具有添加、删除和调整大小的方法,因此它是可修改的。
  2. 添加/删除元素方法:
    • Arrays.asList() 返回的列表不支持添加或删除元素的方法(例如 add()、remove() 等),尝试调用这些方法将会抛出 UnsupportedOperationException 异常。
    • 真正的 ArrayList 具有添加、删除和替换元素的方法。
  3. 修改元素:
    • Arrays.asList() 返回的列表允许修改元素,但不允许改变列表的大小。
    • 真正的 ArrayList 则允许修改元素,并且也可以添加或删除元素。
  4. toArray() 方法:
    • Arrays.asList() 返回的列表的 toArray() 方法返回的数组是底层数组的视图,对该数组的修改将反映在列表中。
    • 真正的 ArrayList 的 toArray() 方法则返回一个全新的数组副本,对该数组的修改不会影响到列表。
  5. clear() 方法:
    • 真正的 ArrayList 有一个 clear() 方法,用于清空列表中的所有元素,而 Arrays.asList() 返回的列表不支持此方法。

3.数组元素类型必须是引用类型

Arrays.asList() 方法的参数是可变参数(varargs),并且泛型参数类型被推断为数组的类型。因此,如果传递的是基本数据类型的数组,它将被看作是一个对象,而不是数组。因为它的签名是 asList(T... a),其中 T 是泛型类型。这意味着我们可以传递零个或多个参数给这个方法,而不需要显式地创建一个数组来传递。

对于参数类型的推断,Java 中的自动装箱和拆箱会影响到这一点。自动装箱是指基本数据类型可以自动转换为对应的包装类型,而自动拆箱则是指包装类型可以自动转换为基本数据类型。因此,如果传递的是基本数据类型的数组,Java 会将其自动装箱为对应的包装类型的数组。

这也是为什么如果传递的是基本数据类型的数组,它将被看作是一个对象,而不是数组。这里的数组实际上是对象数组,而不是基本数据类型的数组。

示例:

1
2
3
java复制代码int[] array = {1, 2, 3};
List<int[]> list = Arrays.asList(array); // 注意这里的泛型参数是 int[]
System.out.println(list.size()); // 输出 1,因为传递了一个对象,即整个数组作为一个元素

相反,如果传递的是包装类型的数组,它将会被识别为数组的数组元素:

1
2
3
java复制代码Integer[] array = {1, 2, 3};
List<Integer> list = Arrays.asList(array); // 注意这里的泛型参数是 Integer
System.out.println(list.size()); // 输出 3,因为传递了三个元素,即数组的每个元素都是列表的一个元素

4.数组内容修改影响List

因为Arrays.asList() 返回的List是基于原始数组的,如果对原始数组进行修改,会影响到返回的List,反之亦然。

示例:

1
2
3
4
5
6
7
java复制代码String[] array = {"one", "two", "three"};
List<String> list = Arrays.asList(array);
System.out.println(list);//输出 [one, two, three]
array[0] = "four";
System.out.println(list); // 输出 [four, two, three]
list.set(1, "five");
System.out.println(Arrays.toString(array)); // 输出 [four, five, three]

image.png

5.使用ArrayList构造新的可变List

如果需要一个可变的List,最好使用new ArrayList<>(Arrays.asList(array)),这样就可以避免固定大小和不可变性的问题。

示例:

1
2
3
java复制代码String[] array = {"one", "two", "three"};
List<String> list = new ArrayList<>(Arrays.asList(array));
list.add("four"); // 不会抛出 UnsupportedOperationException

总体而言,使用Arrays.asList()时,要注意其返回的List的特性,以避免潜在的问题。在需要可变性、对List进行增删操作或处理包含null元素的数组时,建议使用new ArrayList<>(Arrays.asList(array))等方式。

本文转载自: 掘金

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

面试官:如何实现10亿数据判重?

发表于 2024-02-19

当数据量比较大时,使用常规的方式来判重就不行了。

例如,使用 MySQL 数据库判重,或使用 List.contains() 或 Set.contains() 判重就不可行,因为 MySQL 在数据量大时查询就会非常慢,而数据库又是及其珍贵的全局数据库资源。

《阿里巴巴Java开发手册》上也说了,如果单表数据量超过 500 万或 2GB 时就建议分库分表了,如下图所示:

所以数据库去重显然是不行的。而使用集合也是不合适的,因为数据量太大,使用集合会导致内存不够用或内存溢出和 Full GC 频繁等问题,所以此时我们的解决方案通常是采用布隆过滤器来实现判重,布隆过滤器的详情请访问:如何实现布隆过滤器?

知识扩展

除了布隆过滤器之外,我们还可以使用 BitMap(位图)的数据类型来实现判重。

位图(BitMap)是一种数据结构,用于表示一个特定范围内的元素是否存在或者某种状态,通常用二进制位来表示。在位图中,每一个位只能是 0 或 1,分别表示元素不存在或存在。位图通常用一个 bit 数组来实现,每个 bit 位对应一个元素,如下图所示:

其中,上图中的 1 表示有值,上面 BitMap 描述的值是 1,3,5。

BitMap 优点分析

位图的优势包括:

  1. 空间效率优势:位图极大地节省了存储空间。对于大量稀疏数据,特别是当元素数量远大于实际存在的项时,相比于使用传统的列表、集合等数据结构,位图占用的空间极小。
  2. 查询速度:由于内存访问是按字节或字进行的,因此对单个元素的存在性检查时间复杂度为 O(1),即常量时间,非常快速。
  3. 批量操作高效:对于批量插入、删除和查询操作,尤其是统计某一范围内元素的数量,位图表现出优秀的性能。

BitMap VS int

以 Java 中的 int 为例,来对比观察 BitMap 的优势,在 Java 中,int 类型通常需要 32 位(4 字节*8),而 BitMap 使用 1 位就可以来标识此元素是否存在,所以可以认为 BitMap 占用的空间大小,只有 int 类型的 1/32,所以有大数据量判重时,使用 BitMap 也可以实现。

PS:布隆过滤器的底层就是基于 BitMap 数据结构实现的。

BitMap 在 Java 中的使用

BitMap 在 Java 中的具体实现是 java.util 中的 BitSet,BitSet 是一个可变大小的位向量,能够动态增长以容纳更多的位数据,以下是 BitSet 基本使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码import java.util.BitSet;

public class BitmapExample {
public static void main(String[] args) {
// 创建一个BitSet实例
BitSet bitmap = new BitSet();

// 设置第5个位置为1,表示第5个元素存在
bitmap.set(5);

// 检查第5个位置是否已设置
boolean exists = bitmap.get(5);
System.out.println("Element at position 5 exists: " + exists); // 输出: Element at position 5 exists: true

// 设置从索引10到20的所有位置为1
bitmap.set(10, 21); // 参数是包含起始点和不包含终点的区间

// 计算bitset中所有值为1的位的数量,相当于计算设置了的元素个数
int count = bitmap.cardinality();
System.out.println("Number of set bits: " + count);

// 清除第5个位置
bitmap.clear(5);

// 判断位图是否为空
boolean isEmpty = bitmap.isEmpty();
System.out.println("Is the bitset empty after clearing some bits? " + isEmpty);
}
}

课后思考

除了布隆过滤器和 BitMap 之外,还有哪些大数据量判重的实现方案呢?布隆过滤器实现判重的原理又是啥呢?

本文已收录到我的面试小站 www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

本文转载自: 掘金

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

程序员金三银四跳槽指南:时间线&经典面试16问 秘籍 又出成

发表于 2024-02-19

祝大家成功上岸,升职加薪,冲鸭 🎉

金三银四

今天复工,就要开始准备啦💼✨

把握好打工人跳槽的金三银四,获得满意的新工作 🎉

时间线

年后跳槽时间线,过完年刚好开始准备:

1️⃣ 准备简历

  • 2.12-2.25⏰
  • 梳理过往工作内容
  • 根据JD要求,针对性写简历
  • 准备面试题库

2️⃣ 集中投递

  • 2.21-3月上旬⏰
  • 投递时间:周一到周四9:00-11:00,下午2:00-4:00
  • 投递渠道:企业网站/求职网站/公众号/💡内推等等

3️⃣ 面试练手

  • 2月底-3月上旬⏰
  • 选择低意向度公司3~4家,每周2~3场面试
  • 总结面试问题,提高面试技巧
  • 目标:适应面试节奏,提高面试通过率

4️⃣ 正式面试

  • 3月上-3月底⏰
  • 中意向度公司2~3家,高意向度公司2~3家
  • offer越多越好,利用已拿到的offer谈价
  • 目标:薪资涨幅越高越好,发展方向越符合越好

5️⃣ 接offer 入职

  • 4月⏰
  • 2~3轮谈薪资
  • 绩效考核/福利发展/五险一金
  • offer越多越好,利用已拿到的offer谈价

经典面试16问

秘籍

又出成绩啦

我们又出成绩啦!大厂Offer集锦!遥遥领先!

这些朋友赢麻了!

这是一个专注程序员升职加薪の知识星球

早日上岸

需要「简历优化」、「就业辅导」、「职业规划」的朋友,欢迎 在掘金私信我

关注我的同名公众号:王中阳Go

或者直接加我微信:wangzhongyang1993

本文转载自: 掘金

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

一个redis库存问题发愁了三天,过年还在想,最后发现自己想

发表于 2024-02-19

大家好,我是五阳,最近我找了出版社的编辑和几位大佬聊了一件事————出本关于电商业务架构的书,分享一下 商品、营销、订单、会员、支付各细分业务系统的建设经验。最后因法律风险这个计划将长期搁置。为什么出本书会涉及法律问题呢?

因为业务系统架构不可避免的要说明实际的业务背景和痛点,这一定会涉及公司的商业机密。即使做了大量脱敏工作,也无法保证公司不找茬。这也很容易理解,你凭什么把公司的商业机密和业务背景作为代价,去宣传、出书和赚钱呢?

换句话说现有的出版物中很少涉及业务背景和业务逻辑,这类被阉割的内容千篇一律大同小异。之所以你看完了,觉得没学到啥,因为本来就没啥干货。真正有价值的内容,大家不敢在书里发布。

所以接下来,我将继续在掘金认真分享在互联网大厂的业务系统建设经验,分享更多的干货和经验,也尽可能做到业务脱敏,不损害公司的利益。 大家可以关注我,随时看到不注水的经验分享。言归正传。

库存系统的一个痛点

库存系统使用 MySQL做存储能满足一般场景的写入性能,无需牺牲一致性和复杂度而使用 Redis,例如 TPS 低于 500 的业务,MySQL 应付如此量级的写入是绰绰有余的。

然而电商库存业务的查询量往往远大于写入数量,例如写入TPS在 500 的业务,QPS 查询数量级往往可以达到 3000,例如访问量极高的商品详情页面会展示商品库存,这将带来极高的查询并发,也给数据库带来极大的压力。

使用MySQL数据库应对如此高数量级的查询是很危险的,即使勉强接受,也给本就脆弱的系统稳定性埋下了一颗超长保质期的地雷,一旦库存查询出问题,将立即影响商品展示,进而阻碍用户下单,最终导致后果十分严重的的线上故障。虽不足以要命,但足以消灭一个团队的年终奖。

将商品的库存数据从 MySQL异构到 Redis 中,是解决库存查询高并发的必然方案。

五阳长期从事电商业务开发,对于库存系统建设较为熟悉,可以点此了解库存系统如何建设! 可以关注我,随时可以看到我的实战经验分享。

库存一般是指商品的库存,但是扩展来看,活动库存、渠道库存等等也是有库存的,例如某个营销活动有资金预算,活动只有 10000 个名额,命中此营销活动订单的数量不得超过 10000,此时就需要活动库存能力,如果库存系统扩展一个目标类型,就可以同时支持商品库存、活动库存、渠道库存等等,极大提高了扩展性和适用场景。

所以库存在 Redis 的结构包含:目标类型、目标 id和库存数量。

即 Key: 目标类型_目标 id

Value: 库存数量

可以使用 incrBy decrBy 操作库存数量,使用 get获取库存数量。

当前问题在于:如何尽可能保证数据库和 Redis 中的数据一致性?

例如我按照扣减数量进行同步,例如刚刚库存扣减成功 2 个,则同时 Redis 执行 decrBy 操作,如何保证这个过程数据的准确性呢? Redis 操作可能会超时,超时了是否应该重试呢?不重试可能导致漏扣减 Redis 库存,重试可能导致多扣减 Redis 库存,这是一个问题。

加减法扣减 Redis 库存

使用加减法扣减 Redis 库存的时机在 MySQL 库存被更新后。一般情况下各个公司都有 MySQL binlog 的订阅消费能力,所以通过监听库存表的binlog 变更即可。当出现一次更新时,获取当前扣减的数量,则扣减相应的 Redis 库存。

这个方案要解决的问题有两个。其一是重试的幂等性,其二是长期的库存不一致问题。

先说第一个,当 Redis 扣减出现超时,超时重试的时候如何保证幂等性呢?应该在 incrBy 时,同时新增一个幂等记录,例如订单 id。

更新库存时同步新增幂等记录

在操作之前,先查询幂等记录是否存在,如果存在,则不进行更新。如果不存在幂等记录,则使用 pipeline 同步更新库存和新增幂等记录。

FlowRedis查询幂等记录是否存在?不存在,则使用 redis pipeline 同步扣减库存和新增记录存在,则说明已更新成功,跳过这条FlowRedis
这个过程共有两次 Redis 操作。也可以使用更复杂的 Lua脚本方案,将以上操作统一放在 lua 脚本中执行。

但是要注意一个问题: pipeline 和 lua 脚本都只能使用单机版的 redis,不能使用 Redis 集群模式。因为 Redis 集群模式将全量数据 hash 到多个子节点, pipeline 和 lua 中操作的两个 key 无法保证在同一个节点上,也就无法保证操作的原子性。

设置合理过期时间

因为幂等记录只是为了保证超时重试带来的一致性问题,不需要永久保留在 Redis 中,所以应该设置超时时间,例如 1 个小时。这最大程度减少了 Redis 内存占用。

长期的库存不一致问题。

使用加减法扣减库存,无法保证长期上完全一致。虽然引入幂等记录解决了超时重试带来的一致性问题,但是系统运行的长期时间里,难以保证两者是完全一致的。在某些异常场景,例如binlog 消息存在丢失,重复消费(间隔很短时)导致的重复扣减,系统宕机等等预料之外的问题,都有可能导致两者的数据不一致。 使用加减法扣减的方式,无法保证系统长期能一致,如果要做到这一点,就需要额外的校正极值,定期校正两者是否一致。

定期校正 Redis和 MySQL 的一致性

最简单的方案是在系统的业务低峰期执行一个定时任务,对全量库存进行扫描和处理。例如,通过查询数据库和Redis中的库存A是否一致来判断Redis库存是否准确,若不一致,则强制更新Redis库存。

然而,这个方案存在一个痛点,即是否存在绝对的业务低峰期。理论上,在查询Redis和MySQL库存并更新正确库存的窗口期中,可能会发生库存变更,从而导致数据不准确。

不过,这种情况发生的概率较低。通常情况下,业务低峰期的QPS不超过个位数,甚至无法达到个位数,因此很难出现上述问题。如果非要完全避免这种情况,也有办法。例如,在查询数据库库存时带上当前的版本号,若出现库存不一致,更新Redis库存后,再次查询当前MySQL库存的版本号,若版本号不一致,则说明在这个窗口期间库存已被修改,两者可能仍处于不一致状态。此时可以再次尝试同步两者的库存。通过引入版本号比较和重试,将此问题发生的概率降至最低。

我自己不喜欢使用加减法扣减的方案,这个方案要更复杂。同步扣减法就简单多了!

同步更新法扣减

同步扣减法和加减法扣减 有类似的地方,即借助于 MySQL binlog,通过消费 binlog 触发数据同步,这是当前业界较为通用的方案。基本上使用 MySQL 的各大互联网公司都这么做数据异构和数据同步。 如果公司内部没有 binlog 的相关基建能力,可以使用 Kafka消息,即库存扣减成功后,异步或同步发送一条 Kafka 消息,在消费逻辑中做数据异构工作。

同步扣减法即每次库存更新都 使用 set 命令覆盖 Redis 库存,不管是扣减库存还是回滚库存,统统使用 set 命令强制更新。

这种方式有一个好处,不用担心幂等问题。因为 Redis set 超时后,可以直接重试。不同于 incrBy 操作,执行多次会有不同的结果,set 命令不需要担心重试和幂等问题。

但是 同步更新法也有一致性问题,因为在库存扣减并发度非常高的时候,很难保证扣减的顺序性。

顺序消费带来的一致性问题

举个例子库存 A 的当前库存是 5,经过五次扣减后,分别变为 4,3,2,1,0。 很难保证这五条 binlog是顺序投递到 Kafka,也无法保证 Kafka 能顺序消费 5 个消息。如果没有顺序消费,5 次扣减完成后,Redis 最终的库存数据可能不是 0,而是其他 4 个值。

库存实际上已经没有了,Redis 库存还是非零值,用户能看到有库存,但是无法购买成功,这种用户体验很差。

所以要解决顺序扣减库存的难题。

生产端到消费端保证顺序

如果可以保证库存更新的 binlog 从发送到 Kafka 到消费能完全顺序,就可以保证Redis 库存更新也是有序的。可以通过创建一个分片,保证 Kafka 消费时顺序消费。然而如何保证 binlog 到 Kafka 是有序的呢? 这比较难,我们无法苛求 binlog 消费中间件(例如canal)顺序投递到 Kafka。

要想完全的顺序生产和消费是非常困难的,所以一般情况下我们采用方案 2,即版本号机制。

版本号控制顺序消费

一般情况下 mysql 库存更新的 SQL 会同步新增版本号,例如下面这个 SQL。

update inventory set cnt = cnt + #{buyCnt}, version=version+1 WHERE productId = #{productId1} AND cnt + #{buyCnt} <= totalCnt

这样每条 binlog 都带有对应的版本号,当更新库存时,先尝试检查当前版本号是否落后于 Redis 版本号,如果落后,那么说明无需更新,如果超前,则尝试更新 Redis 库存值和版本号。

这个过程是先检查再更新,需要保证一致性,可以使用 Lua 脚本。版本号和库存值需要存在 Redis 中,可以使用 redis hash 结构存储两个子 key。

接下来请看 lua 脚本

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
ini复制代码if(redis.call('exists', KEYS[1]) == false) then
redis.call('HSET', KEYS[1], 'version', KEYS[3]);

redis.call('HSET', KEYS[1], 'val', KEYS[2]);
return 1;
end;


local redisVersion = redis.call('HGET', KEYS[1] ,'version');
local currVersion = KEYS[3];

if(redisVersion == false or currVersion > redisVersion) then
redis.call('HSET', KEYS[1], 'version', KEYS[3]);

redis.call('HSET', KEYS[1], 'val', KEYS[2]);

return 1;
end;

if(currVersion== redisVersion) then
return 0;

end;

return -1;

入参为 Key 库存值 版本号。例如如下的命令,eval xxxx 代表执行哪一个脚本,3 代表参数个数,总共 3 个。3_110 为商品库存的 key,4 为库存值, 1 为版本号。

evalsha ee67de65b07b8124e14db5a0c6e03440c705194e 3 1_110 4 1

返回值 1 代表 更新库存成功;返回值为 0 代表重复设置;返回值为-1为非法制,代表更新历史库存。

解释下上述代码,1-6 行 判断库存 key 是否存在,不存在则立即更新。

如果存在,则校验 redis 版本号和当前传参的版本号,如果超过 redis 版本号,则立即更新库存。如果落后于 redis 库存,则不更新,返回-1 非法值。

比较一下两个方案,两者孰优孰劣主要在于方案的一致性和复杂度。两者的优势基本相同即能保证高性能的库存查询能力!但缺陷不相同,解决缺陷的复杂度也不同,这最终导致 同步更新法的复杂度更低,更加易于实现。

方案 加减法扣减 同步更新法
缺点 需要保证重试和幂等、无法保证长期一致 难以保证同时修改版本号和库存值的原子性
补充方案 新增幂等记录保证幂等 2、定时任务保证长期一致 使用 lua 脚本保证同时成功和失败

总结

  • 在库存场景,查询的数量级要远高于写入的数量级。
  • 库存查询能力基于 Redis 实现可以提供更强的查询性能,速度更快,扛并发能力更强
  • 将MySQL数据库 库存同步到 Redis 中,可以使用同步更新法,即每当库存更新后,即将新库存值和版本号更新到 Redis 中,使用版本号和lua脚本控制顺序,保证Redis库存值是最新的,而不是历史值。

本文转载自: 掘金

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

1…565758…956

开发者博客

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