杂谈-下一步的方向

立个flag,我需要在9月份之前完成这些事情:

  1. 论文idea和实验:学习pytorch和图神经网络,可能还得学一些信号处理。(ddl8月份)
  2. i茅台复现:主体自己实现一下,tidb可以先不用,用sharding-jdbc分片即可(ddl5月份)
  3. go语言相关:做一个轮子类项目(分布式kv或者MIT6.多少多少的raft),以及一个业务类项目(im即时通讯或者流媒体),这部分其实看情况,也不是一定需要转go(ddl6月份)
  4. 大模型相关:复现gpt2(ddl5月份),继续学习rag的相关工程,主要放在并发上(ddl8月份)
  5. unity:不是主要工作,只给五一假期几天去尝试一下

ProjectMygo!!!!!-go的基本语法

实习了一段时间深感java已经不太行了,转go才是出路

go

变量,结构与函数

变量

与java不同,go可以不显示声明变量数据类型。一般来说有:

  • 整数型int,int8,int16,int32,int64,无符号整型uint,uint8,uint16,uint32,uint64
  • 浮点型:float32,float64,相当于单精度浮点型float和双精度浮点型double
  • 布尔:true和false,值得一提的是bool赋初始值的时候默认false
  • 字符串:string直接就是一个数据类型了,还有一个rune,后续再去了解

很重要的一点是go语言的变量声明了就必须得用,而且最后不用加分号。下面是声明数据类型的例子,有:=可以代替var进行类型推断,可以同时推断多个类型(但我觉得还是显示声明类型比较好,否则一个函数返回来怎么判断类型)

1
2
3
4
5
6
7
8
9
10
11
12
13
var age_1 uint8 = 31
var age_2 = 32
age_3 := 33
fmt.Println(age_1, age_2, age_3)

var age_4, age_5, age_6 int = 31, 32, 33
fmt.Println(age_4, age_5, age_6)

var name_1, age_7 = "Tom", 30
fmt.Println(name_1, age_7)

name_2, is_boy, height := "Jay", true, 180.66
fmt.Println(name_2, is_boy, height)

常量也是类似,可以进行类型推断,但是必须赋初始值,且一旦定义了就不能改变了,类似java中的private static final int = 1;这种

函数与判断结构

go的函数与主流编程语言类似,但是估计不分static和非静态,也是给出参数列表和返回值。但是这里可以返回多个变量,这一点应该会比java好

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var numerator int = 11
var denominator int = 2
var result, remainder int = intDivision(numerator, denominator)
fmt.Println(result, remainder)

}
func intDivision(num1, num2 int) (int, int) {
var result int = num1 / num2
var remainder int = num1 % num2
return result, remainder
}

注意这里的异常处理,与java中try-catch的思想不同,函数在返回的时候也会给一个error返回值,外部调用通过error是否为nil来判断函数执行是否出错。这是一个广泛使用的设计思想,后续可能需要遵守。

例如这里的除数为0的例子,当除数为0相当于要抛出异常,用errors包下的一个函数throw new RunTimeException(),再在主函数去判断这个error是否为空,为空说明没有抛出异常。

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
package main

import (
"errors"
"fmt"
)

func main() {
var numerator int = 11
var denominator int = 0
result, remainder, err := intDivision(numerator, denominator)
//执行正常
if err == nil {
fmt.Println(result, remainder)
} else {
fmt.Println(err)
}

}
func intDivision(num1, num2 int) (int, int, error) {
var err error
if num2 == 0 {
err = errors.New("num1 is zero")
return 0, 0, err
}
var result int = num1 / num2
var remainder int = num1 % num2
return result, remainder, err
}

最后提一下go的判断结构,if后面的括号必须贴着同一行,else的哪一行必须写成”} else {“,否则编译器会报错,此外switch语句不需要写break了

数组,切片和哈希表

数组

go中的数组跟java的数组很像,但是go的数组可以操作指针。数组的大小在声明的时候就已经固定,如果想用跟ArrayList那样的动态数组,请使用slice切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var intArr [10]int32
for i := 0; i < len(intArr); i++ {
intArr[i] = int32(i + 1)
}
//左闭右开,这个就是下标为456的三个元素
fmt.Println(intArr[4:7])

//地址
for i := 0; i < len(intArr); i++ {
fmt.Println(&intArr[i])
}
//这里输出结果是连续的4B,说明经典数组是连续分布的
}

值得注意的是这里数组如果传入的是形式变量,需要传地址,跟c语言一样,否则就只会改变形参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var arr = [5]int{1, 2, 3, 4, 5}
withAddress(&arr)
fmt.Println(arr)//[1 20 3 4 5]
noAddress(arr)
fmt.Println(arr)//[1 20 3 4 5]
}

func withAddress(a *[5]int) {
a[1] = 20
}
func noAddress(a [5]int) {
a[3] = 20
fmt.Println(a)//[1 20 3 20 5]
}

切片slice

基本上跟ArrayList的机制一样,长度和容量,如果到了设定阈值就会动态扩容。如果能够预估业务数据量,在构造slice的时候直接指定容量可以免去动态扩容的开销。

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
package main

import "fmt"

func main() {
sliceDynamic()
}

// 静态方法创建
func sliceStatic() {

var slice []int32 = []int32{1, 2, 3}
//length is 3, with capacity is 3
fmt.Printf("length is %v, with capacity is %v\n", len(slice), cap(slice))
slice = append(slice, 4)
//length is 4, with capacity is 6
fmt.Printf("length is %v, with capacity is %v\n", len(slice), cap(slice))
fmt.Println(slice)
}

// 动态方法创建
func sliceDynamic() {
//可以指定构造长度和容量,这里构造了一个长度为3,容量为20的slice
var intSlice []int32 = make([]int32, 3, 20)
fmt.Printf("length is %v, with capacity is %v\n", len(intSlice), cap(intSlice))
//前三个元素是初始化了的,后面没有
for i := 0; i < len(intSlice); i++ {
fmt.Println(intSlice[i])
}
//就跟ArrayList一样,如果业务能够预估动态数组的长度,最好还是构造的时候就提前设定好
//否则会频繁进行扩容,影响效率
}
//取数逻辑。左闭右开,跟java中subString类似
func slicePartition() {
sli := []int{1, 2, 3, 4, 5, 6}
fmt.Printf("len=%d cap=%d slice=%v\n", len(sli), cap(sli), sli)

fmt.Println("sli[1] ==", sli[1])
fmt.Println("sli[:] ==", sli[:])
//sli[1]->sli[len-1]
fmt.Println("sli[1:] ==", sli[1:])
//sli[0]->sli[4-1]
fmt.Println("sli[:4] ==", sli[:4])
//sli[0]->sli[3-1]
fmt.Println("sli[0:3] ==", sli[0:3])
fmt.Printf("len=%d cap=%d slice=%v\n", len(sli[0:3]), cap(sli[0:3]), sli[0:3])
}

循环

go语言中没有while循环(反正我也没经常用),对于slice和map需要特别注意,range关键字会在遍历这两个数据结构的时候进行处理。例如slice通过range关键字的时候会有index和value两个值,不需要index则直接”_”,跟python相似;同理map会遍历出key和value

这里就顺带把map提一下,map[key]value,这样的结构,查询一个元素直接括号里面找,注意找不到也会返回0这个默认值,所以以后用到的时候可能需要判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func sliceWithClass() {
var teachers []Teacher = make([]Teacher, 0)
teachers = append(teachers, Teacher{"yangyifan", 12})
fmt.Println(teachers)

var teacherMap map[string]Teacher = make(map[string]Teacher)
teacherMap["yangyifan"] = Teacher{"yangyifan", 12}
fmt.Println(teacherMap["yangyifan"])
teacherMap["xuxuanyan"] = Teacher{"xuxuanyan", 12}
fmt.Println(teacherMap["xuxuanyan"])
delete(teacherMap, "xuxuanyan")
fmt.Println(teacherMap["xuxuanyan"])

//遍历数组的时候返回两个值,一个是index一个是值
//不需要的就直接_
for _, teacher := range teachers {
fmt.Println(teacher)
}
//遍历map的时候每次都会得到两个值,一个是key一个是value
//这里只希望返回所有的value前面的key就用_代替
for _, teacher := range teacherMap {
fmt.Println(teacher)
}
}

string与rune

在go中string的底层是一个字节数组,采用utf8编码,由于utf8是不固定长度的,一般来说汉字都会占3B。所以直接去用len一个string数组长度返回的是字节数量,有两种遍历方式,一种是直接遍历len,这样会返回每一个未解码的utf8字节,比如一个汉字“大”,占三位,用普通遍历就会返回这三个字节的初始值;但是如果用range关键字,他会帮我们做一些处理,把这三个字节解码拼成一块,就会返回真实的字符,但这样前面的index仍然不准确。

如果采用rune就是我们直觉上的遍历字符数组了,例子如下:

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
package main

import "fmt"
import "strings"

func main() {
//stringByte()
runeSlice()
stringBuilder()
}
func stringByte() {
var str1 = "大连理工大学"
char := str1[0]
fmt.Println(char)
//大连理工大学 has 18 character
fmt.Printf("%v has %v character\n", str1, len(str1))

//index: 0, char:å
//index: 1, char:¤
//index: 2, char:§
//index: 3, char:è
//index: 4, char:¿
//index: 5, char:ž
//index: 6, char:ç
//index: 7, char:
//index: 8, char:†
//index: 9, char:å
//index: 10, char:·
//index: 11, char:¥
//index: 12, char:å
//index: 13, char:¤
//index: 14, char:§
//index: 15, char:å
//index: 16, char:­
//index: 17, char:¦
//这里出现乱码的原因是字符串底层是一个字节数组结构,
//而一个汉字在utf8中占3个字节,他把每一个字节的内容都输出,就不会组成一个完整的汉字
for i := 0; i < len(str1); i++ {
fmt.Printf("index: %d, char:%c\n", i, str1[i])
}

//如果用range关键字就会帮我们把字符串的utf8解码
//index: 0, char:大
//index: 3, char:连
//index: 6, char:理
//index: 9, char:工
//index: 12, char:大
//index: 15, char:学
//这里也可以看到跳过了一些index
for index, v := range str1 {
fmt.Printf("index: %d, char:%c\n", index, v)
}

}
func runeSlice() {
var runeSlice = []rune("大连理工大学")
//index: 0, char:大
//index: 1, char:连
//index: 2, char:理
//index: 3, char:工
//index: 4, char:大
//index: 5, char:学
//这里的index就是顺序的了
for i := 0; i < len(runeSlice); i++ {
fmt.Printf("index: %d, char:%c\n", i, runeSlice[i])
}

}

此外一些string操作都在strings这个包下面,例如stringBuilder和其他的一些字符串操作,需要的时候导入。

结构体与接口

go应该是一个面向过程的语言,这里采用的还是结构体,但是类似的也有接口和类方法,在实现go中的接口时,不需要有java那种implements,编译器搜索所有有该方法签名的结构体自动绑定;在实现类方法时,需要在方法名前绑定结构体。例子如下所示,有一个engine接口,两个实现类

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
package main

import "fmt"

// Engine 接口
type Engine interface {
milesLeft() uint8
}

type GasEngine struct {
mpg uint8
gallons uint8
}

func (ge GasEngine) milesLeft() uint8 {
return ge.mpg * ge.gallons
}

type ElectricEngine struct {
mpkwh uint8
kwh uint8
}

func (ee ElectricEngine) milesLeft() uint8 {
return ee.mpkwh * ee.kwh
}

func canMakeIt(engine Engine, remainMiles uint8) bool {
if engine.milesLeft() < remainMiles {
return false
} else {
return true
}
}
func main() {
var milesLeft uint8 = 60
var ge GasEngine = GasEngine{10, 20}
fmt.Println(canMakeIt(ge, milesLeft))
var ee ElectricEngine = ElectricEngine{10, 5}
fmt.Println(canMakeIt(ee, milesLeft))
}

网易KM社区分享-小P老师服务GC卡顿定位解决

小P老师作为有道AI大模型的重点服务,稳定性与低延迟至关重要。但随着开学季到来,接口流量增加,服务偶现请求延迟升高和GC卡顿异常重启现象。
本文总结上述问题的排查思路及定位过程,相信对遇到内存泄漏、CPU升高、JVM GC异常等问题的小伙伴,有借鉴意义。

一、背景和现象

小P老师服务(java服务容器部署、jdk17、G1回收器)核心任务是提供教育场景下的大模型对话式问答。随着开学季到来,流量也逐渐上升,保障服务稳定性是比较重要的任务之一。

大模型对话式问答通常是一个流式过程,模型回答是一段一段输出给用户的,为了观察到整个模型的延时情况,大模型回答完毕的时间(total time)以及大模型每一段回答的时间(interval time)都添加了监控。

近期发现,小P老师服务里子曰大模型interval time的监控总是超时告警,但是子曰大模型自身的interval time监控确实正常的,同时很奇怪的是只有一个或者部分容器pod出问题。

这两个监控有什么区别呢?简单来说一个是A使用B时对B的监控,另外一个是B对自身的监控,所以理论来说他两监控应该基本一致才是符合预期的(抛去网络延时)。

从这一现象看,说明小P老师本身代码逻辑存在耗时情况或者网络有问题。

另外之前小P也出现过类似情况,我们使用了Huggingface去做大模型token计算,这个组件cpu占用率很多,所以按照之前惯例会查看cpu是否够用。
img.png

img_1.png

图1 容器cpu使用图

img_2.png

图2 jvm监控图

于是发现了图1这样的现象,在容器cpu监控图中发现在服务告警期间cpu usages(使用量)和cpu cfs throttled(抢占)有尖刺。同时也是机缘巧合,想看看jvm里cpu使用占用率多少,于是在图2(黄色线是分配的内存、绿色线是使用的内存)发现了比较重要的一个信息,jvm在这期间eden区分配降低,old区使用、分配激增,维持了一段时间后就自行恢复了。于是我便去查看了一段时间内的GC日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[2024-10-23T20:23:56.727+0800] GC(77) Pause Young (Normal) (G1 Evacuation Pause)
[2024-10-23T20:23:56.727+0800] GC(77) Using 1 workers of 1 for evacuation
[2024-10-23T20:24:02.147+0800] GC(77) MMU target violated: 201.0ms (200.0ms/201.0ms)
[2024-10-23T20:24:02.147+0800] GC(77) Pre Evacuate Collection Set: 12.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Merge Heap Roots: 56.7ms
[2024-10-23T20:24:02.147+0800] GC(77) Evacuate Collection Set: 5334.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Post Evacuate Collection Set: 15.4ms
[2024-10-23T20:24:02.147+0800] GC(77) Other: 0.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Eden regions: 482->0(63)
[2024-10-23T20:24:02.147+0800] GC(77) Survivor regions: 17->13(63)
[2024-10-23T20:24:02.147+0800] GC(77) Old regions: 32->32
[2024-10-23T20:24:02.147+0800] GC(77) Archive regions: 2->2
[2024-10-23T20:24:02.147+0800] GC(77) Humongous regions: 473->456 // 标记1
[2024-10-23T20:24:02.147+0800] GC(77) Metaspace: 156861K(158080K)->156861K(158080K) NonClass: 139277K(139840K)->139277K(139840K) Class: 17583K(18240K)->17583K(18240K)
[2024-10-23T20:24:02.147+0800] GC(77) Pause Young (Normal) (G1 Evacuation Pause) 4018M->2007M(6144M) 5419.626ms // 标记2
[2024-10-23T20:24:02.147+0800] GC(77) User=5.08s Sys=0.20s Real=5.42s // 标记3

确实发现了异常的点,标记1可以看出Humongous regions数量非常多且这一次GC并没有回收该区域的内容。(Normal GC是会回收Humongous区域的)

标记2可以看出整个GC耗时大概5.4s,当然从标记3可以更清楚的看出GC耗时,所以我们猜测子曰大模型interval time告警可能和GC耗时过久有关系。

至此我们整合一下问题现象:

  • 小P老师服务对子曰大模型的延时监控发生告警,且与子曰大模型自身监控不一致
  • 只有一部分pod有问题
  • 告警期间服务cpu使用率激增
  • 告警期间jvm内存eden区域分配减少,old区域使用、分配激增,一段时间后恢复

Humongous regions回收不明显,GC停顿过长

根据上述现象,我们可以判断出服务延时告警时和GC有关系,也就是需要从内存的角度来分析为什么GC会停顿这么久,可以算是一个切入点。

分析内存有一个得力工具MemeoryAnayzer(MAT),接下来会先重点介绍一下这个工具,同时也会介绍在jdk17中的G1垃圾回收器。当然如果对此熟悉的可以直接跳过看定位过程

二、Garbage-First (G1) 垃圾回收器

引用文章

Garbage-First (G1) 垃圾收集器针主要对大内存多核的服务,目的是实现应用程序和环境在延迟和吞吐量之间的最佳平衡。

特点:

  • 服务堆大小大于10GB。
  • 对象分配和对象移动的速度可能会随着时间的推移而发生很大变化。

    Rates of object allocation and promotion that can vary significantly over time.

  • 堆中存在大量碎片。
  • 可预测的暂停时间目标不超过几百毫秒,避免长时间的垃圾收集暂停。

2.1 基本概念

G1 将堆分为年轻代(young)和老年代(gen)。空间回收工作集中在最高效的年轻代上,偶尔也会在老年代进行空间回收。

G1 首先回收最高效区域的空间(即大部分被垃圾填充的区域,因此得名)。

G1 主要通过撤离(evacuation)来回收空间:在选定的内存区域找到存活对象复制到新的内存区域,并在过程中对其进行压缩。撤离完成后,先前的空间可用来重新分配。

G1不是实时收集器。尝试尽可能在设定的暂时时间下完成回收,但对于给定的暂停,不能保证绝对满足。

2.2 堆布局

img_3.png

图3 G1垃圾回收器

年轻代包含eden区域(红色)和survivor区域(红色,带“S”)。这些区域内部是连续的,但在G1中这些区域通常以非连续模式排列在内存中。old区域(浅蓝色)构成老生代。对于跨多个区域的对象,会有一个非常大的old区域(浅蓝色,带“H”),叫做Humongous区域 。

应用程序总是分配到年轻代,即eden区域,巨大对象被分配到old区域。

2.3 垃圾回收周期

G1 收集器在两个阶段之间交替。young-only阶段包括垃圾回收(garbage collections),这个阶段会逐渐填满当前可用的内存

空间回收阶段是 G1 除了处理年轻代之外,还会逐步回收老生代中的空间。然后,循环从年轻代阶段重新开始。

img_4.png

图4 垃圾回收周期预览

以下列表详细描述了 G1 垃圾收集周期的各个阶段、暂停以及阶段之间的转换:

  1. 仅年轻代阶段(Young-only phase):此阶段以Normal young collections收集开始,会将对象提升到老年代。当老年代占用率达到某个阈值时,Young-only phase和Space-reclamation phase之间的过渡就开始了。此时,G1 会执行Concurrent Start young collection,而不是Normalyoung collections。

    • Concurrent Start:这种类型的收集除了执行常规Normalyoung collections,还启动标记过程。并发标记确定old区域中的是否可以被回收。在收集标记尚未完全完成时,可能会发生Normalyoung collections。

    • Remark:此这段会完成重新标记。

    • Cleanup:这个阶段决定是否进行Space-reclamation phase。如果确定进行Space-reclamation phase,那么Young-only phase就会进行一次Prepare Mixed young collection.

  2. 空间回收阶段(Space-reclamation phase):此阶段会进行Mixed collections,除了young区域外,还会撤离old区域中的存活对象。当 G1 确定撤离更多老生代区域不会产生足够的可用空间时,空间回收阶段结束。

  1. Young-only phase: This phase starts with a few Normal young collections that promote objects into the old generation. The transition between the young-only phase and the space-reclamation phase starts when the old generation occupancy reaches a certain threshold, the Initiating Heap Occupancy threshold. At this time, G1 schedules a Concurrent Start young collection instead of a Normal young collection.
  • Concurrent Start : This type of collection starts the marking process in addition to performing a Normal young collection. Concurrent marking determines all currently reachable (live) objects in the old generation regions to be kept for the following space-reclamation phase. While collection marking hasn’t completely finished, Normal young collections may occur. Marking finishes with two special stop-the-world pauses: Remark and Cleanup.
  • Remark: This pause finalizes the marking itself, performs global reference processing and class unloading, reclaims completely empty regions and cleans up internal data structures. Between Remark and Cleanup G1 calculates information to later be able to reclaim free space in selected old generation regions concurrently, which will be finalized in the Cleanup pause.
  • Cleanup: This pause determines whether a space-reclamation phase will actually follow. If a space-reclamation phase follows, the young-only phase completes with a single Prepare Mixed young collection.
  1. Space-reclamation phase: This phase consists of multiple Mixed collections that in addition to young generation regions, also evacuate live objects of sets of old generation regions. The space-reclamation phase ends when G1 determines that evacuating more old generation regions wouldn’t yield enough free space worth the effort.

空间回收后,收集周期将以另一个年轻阶段重新启动。作为兜底,如果应用程序在收集活跃度信息时内存不足,G1 将像其他收集器一样执行就会执行Full

2.4 垃圾回收阶段和回收集

garbage Collection Pauses and Collection Set

G1执行垃圾收集和空间回收是在stop-the-world pauses时间内完成的,存活的对象会从堆的一个区域移动到另一个区域,并且对这些对象的引用也会调整。

对于non-humongous的移动:

  • 年轻一代(eden和survivor)的对象被复制到survivor区域或old区域,取决于它们的年龄。
  • 来自old的对象被复制到其他old

对于大对象来说,除非被回收不然永远不会被移动。

对于回收集(collection set):

  • 在 Young-Only ,回收集仅由年轻一代的区域以及可能被回收的巨大区域组成。
  • 在空间回收(Space-reclamation)阶段,回收集由年轻代中的区域、包含可能被回收的对象的巨大区域、以及来自收集集合候选区域的一些老生代区域组成。

G1 在并发周期(concurrent cycle)内准备回收集候选区域。在Remark pause,G1 选择大量闲置空间的低利用率区域。然后在 Remark 和Cleanup pause之间并发准备这些区域以供以后收集使用。Cleanup pause根据效率对准备的结果进行排序。更高效的区域是说,有更多的空间并且回收的时间更少。mixedcollections会更喜欢这些区域。

三、MemeoryAnayzer(MAT)

https://eclipse.dev/mat/

3.1 重要概念

3.1.1 可达性

可达

这个对象仍然有地方引用着他

不可达

这个对象没有任何对象被引用

3.1.2 Shallow 与Retained Heap的区别

Shallow 是一个对象所消耗的内存。对象每个引用需要32或64位(取决于操作系统体系结构),每个Integer需要4字节,每个Long需要8字节,等等。

Shallow heap is the memory consumed by one object. An object needs 32 or 64 bits (depending on the OS architecture) per reference, 4 bytes per Integer, 8 bytes per Long, etc. Depending on the heap dump format the size may be adjusted (e.g. aligned to 8, etc…) to model better the real consumption of the VM.

X的Retained set表示当X被GC垃圾回收后需要移除的对象列表

Retained set of X is the set of objects which would be removed by GC when X is garbage collected.

X的Retained heap是Retained set里所有对象的Shallow大小

Retained heap of X is the sum of shallow sizes of all objects in the retained set of X, i.e. memory kept alive by X.

通俗的来说,Shallow 是这个对象的大小,Retained heap是这个对象被回收之后内存释放的大小

img_5.png

图5 对象引用图以及Retained Set

3.1.3 Dominator Tree

MAT提供了对象图的Dominator Tree,将对象引用图转化为Dominator Tree能够轻松识别保留内存的最大块以及对象之间的依赖关系,下面是一些定义

  • X dominates Y,表示在对象图中,每一个去Y的路径上都需要经过X。
  • X是Y的immediate dominator ,表示X是距离Y最近的支配者
  • dominator tree 是由对象图直接构建而来,能够展现一个对象的immediate dominator

图6是将对象图(左侧)构建为dominator tree (右侧)

img_6.png

图6 对象引用图以及Retained Set

通俗的来说,X dominates Y表示,如果X被回收那么Y一定被回收。但我们常说的引用,如果X引用Y,那么Y是不一定会被回收的,因为Y有可能被Z引用。这就是为什么MAT引入 Dominator这个概念。

3.2 常用功能

3.2.1 Histogram

Histogram列举出每一个class的对象数量以及他的shallow size和retained size,可以快速找出大的对象类

img_7.png

图7 Histogram列表

默认情况下retained size展示的是估算值,也可通过计算才获取他的准确值。

img_11.png

图8 Histogram计算准确retained size

可以查看对象被谁引用或者他又引用了谁

img_10.png

图9 Histogram查看引用关系

img_9.png

图10 Histogram查看引用关系结果

Histogram默认是通过class是分组的,也可以根据包或者加载器

img_8.png

图11 Histogram通过其他类型分组

3.2.2 Dominator Tree

Dominator tree展示了在堆中最大的对象列表。X对象的下一级表示,X被回收之后需要被垃圾回收的对象列表。(也就是X直接支配的对象)同样也可以按类加载器、包进行分组。

The next level of the tree lists those objects that would be garbage collected if all incoming references to the parent node were removed.

img_12.png

图12 Dominator Tree

以上图为例,占用堆内存最大的是TaskThread的http-no-8080-exec-2线程,其本身大小是Shallow Heap是120字节,Retained Heap是2417669960字节,占用整个堆内存94.90%。图中将AspectJExpressionPointcut展开,表示当AspectJExpressionPointcut被内存回收之后,展开列表里的所有对象都会被回收,也就是他的retained set

3.2.3 Immediate Dominators

可以快速找出当前这组(类/对象)的所有immediate dominator(直接支配者)

img_13.png

图13 Histogram找某个类的immediate dominator

下列展现支配Object[]的类列表

img_14.png

图14 Object[]类的immediate dominator

其中所选的那一行表示,TaskThread一共有37个对象,其中支配了133个Object[],并且TaskThread的本身对象大小(shallow size)是4440bytes,他支配的Object[]是2147491680bytes的大小

3.2.4 Leak report

Leak report会列举出可能存在内存泄漏的点,以及发生的栈信息位置

img_15.png

图15 Leak report

四、定位过程

根据在第一节所观察到的问题现象,我们从内存角度来分析GC停顿之间为何这么久?按照惯例,通常都会看一下内存中的大对象,因为大对象一般是造成内存出现问题的罪魁祸首,并且大对象也是最容易发现的。

4.1 查看大对象

jmap -hsito [pid] | head -n [num]

img_16.png

图16 小P老师服务某时刻大对象

大部分服务大对象前列就是byte、int等基本类型(不同的jdk版本可能会不同),也看不出什么门道。

通常先重点关注项目自己的包,再看一些引用的包。图16已经圈出了一些比较可疑的对象,但类比了同类稳定服务,第10行对象也是存在且现象一致的,于是就暂时排除他的嫌疑。

接下来就是12、13行这两个对象,他们用来做流式场景下线程之间上下文的自动传递,在github上看有人也提出了使用该组件的内存问题,我们把他列为可疑对象。

再接着就是20行这个对象,他是之前讲到的Huggingface组件,用来做大模型token计算。这个组件cpu占用率很高(之前性能自测过,图17)。那有没有可能在某个时刻计算量很大导致cpu激增,而容器分配的cpu不够用(而我们也确实发生了cpu抢占的情况),导致长期持有jvm对象而无法回收带来的GC卡顿,所以我也把他列为了可疑对象。

接下来我们来验证猜想。

img_17.png

图17 Huggingface组件性能测试cpu、内存使用情况

4.1.1 验证猜想

我们将图16中,12、13行对象涉及的组件以及20行对象涉及的组件,分别打开/关闭来做性能测试,看 GC和jvm是否有明显变化,但当时并没有发现带来明显的jvm变化以及GC卡顿问题。那么问题可能出现在其他大对象上,这时候就需要把堆内存dump下来做分析了。

4.2 内存dump

根据我们之前观察的现象,old区域激增,一段时间后回落,这不太符合内存泄漏的现象,可能就是大对象被长期持有无法释放,于是在dump内存时,选择将堆里的对象全部dump而不仅仅是存活的对象。

jmap -dump:live,format=b,file=dump.hprof [pid]

4.3 使用MAT工具分析

下载地址

注意一般堆文件多大,MAT内存就需要分配多大,修改方式参考

MAT工具通常我们可以使用他从这几个角度分析:

  • 堆内存中的大对象有些什么?
  • 这些大对象为什么没被回收?看他的支配者:immediate Dominators,看他的GC root
  • 这些大对象为什么这么大?看他支配了谁:retained set

4.3.1 导入堆文件

img_18.png

图18 堆文件导入示意图

4.3.2 查看大对象

使用Histogram查看大的对象(类),根据Retained Heap来排序(点击Retained Heap按钮就可以排序)

img_19.png

图19 堆文件大的对象(类)列表

发现最大的类是java.lang.object[],是一个数组,于是按照刚才思路我们先看他为什么没被回收?就看他的支配者。

4.3.3 查看大对象支配者

尝试看下这个大对象的支配者,看看是不是因为这个支配者应该被回收但是没被回收。

图20发现java.lang.object[]最大的支配者是TaskThread这个类,一共有37个对象实例,支配了133个java.lang.object[],TaskThread类本身大小是4440bytes,支配的对象java.lang.object[]大小是2147491680bytes。

其实看到这里已经没有意义了,因为他是处理http请求的线程,是不可能被回收的,但我们看一下这个TaskThread的GC Root ,看是否是被不小心创建出来的而没释放。

img_20.png

图20 java.lang.object[]的支配者

4.3.4 查看GC root

一般来说查看Gc root时都会选择 exclude weak/soft references,因为这两个引用肯会被GC掉,这是用来查内存泄漏的,但我们场景是对象是被长时间持有段时间无法回收,而不是一直无法回收。所以这里选择展现了所有的references。

img_21.png

图21 查看TaskThreadGC root示意图

从图22来,TaskThread都是tomcat创建的线程用来处理http请求的,http-nio-8080-exec-2支配了很大的对象,那就是刚才java.lang.object[],这种被线程支配的对象,大概率是临时变量,也就是方法栈里创建出来的变量,http-nio-8080-exec-2是不可能被回收的。

img_22.png

图22 TaskThreadGC root

但是临时变量的回收,会在方法执行完,对他引用没有了之后进行。因为我们dump某一个时刻的堆栈信息,可能线程没有执行完,没被回收也是正常的。但是在http所有的线程中,只有这个线程持有很大的对象明显是不合理。于是我接着看 java.lang.object[]对象为什么这么大?

4.3.5 查看retained set

img_23.png

图23 查看java.lang.object[]Retained Set示意图

查看java.lang.object[]Retained Set可以看出他支配了哪些对象/类,就可以知道他为什么这么大(retained set是包含本身的)

img_24.png图22.png

图24 java.lang.object[]Retained Set

从图24可以看出,在其所有支配的对象中,其本身是最大的,到这里好像陷入了死结。

这个对象被谁支配?是一个线程。这个对象为什么这么大?是因为他本身就很大。

但回想起刚才说的,这个对象被http线程支配,因为线程没有执行完,引用没消失所以一直存在,于是我就想能不能看一下这个线程的栈信息,正好MAT中也有这样的功能。

4.3.6 查看栈信息

img_25.png

图25 所有线程的栈信息

从图25来看,http-nio-8080-exec-2占用了很大的retained heap,就接着点开来看就是整个线程的堆栈情况(不排序的话默认就是执行路径)

img_26.png

图26 http-nio-8080-exec-2堆栈信息

看堆栈信息,一般来说是从上到下找到首个业务代码进行分析,从图26可以看出从业务代码ChatManagerImpl.java:300处添加一个元素到列表,最后触发了容器扩容,最终导致OutOfMemoryError。并且这个线程在执行copyOf时持有很大的内存大小Max Local Retained Heap(本地变量保留大小),已经定位到业务代码了,接下来就根据业务代码去看看原因。

4.3.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
public List<ChatInfoDO> getChatInfoHistory(String userId, String taskId, Long parentChatId,
Integer groupLevelCount) throws LlmBusinessException {
// 根据chat_group_level粗筛(只取最近的chatCount个level)
List<ChatInfoDO> chatInfoDOList = chatInfoDOMapper.selectChatHistory(userId, taskId, parentChatId,
groupLevelCount);
if (chatInfoDOList == null || chatInfoDOList.size() == 0) {
throw new LlmBusinessException(ErrorCode.USER_WRONG_CHAT_HISTORY);
}
// 根据 parentChatId 串起 chatHistory 返回,此时是逆序的
Map<Long, ChatInfoDO> chatIdMap = new HashMap<>();
for (ChatInfoDO chatInfoDO : chatInfoDOList) {
chatIdMap.put(chatInfoDO.getChatId(), chatInfoDO);
}
List<ChatInfoDO> history = new ArrayList<>();
Long chatId = parentChatId;
// 逆序查找,从最后一条对话chatId开始,继续条件:chatId=当前parentChatChatId(子节点找父节点)
ChatInfoDO chatInfoDO;
while ((chatInfoDO = chatIdMap.get(chatId)) != null) {
history.add(chatInfoDO); // 标记1 问题代码处
chatId = chatInfoDO.getParentChatId();
}
Collections.reverse(history);
return history;
}

在分析可疑点之前,我先简单描述下这段代码所做的事情。

在小P老师对话场景中,是采用一问一答的形式,例如下方图27所示,蓝色表示用户,淡红色表示系统回答。

img_27.png

图27 大模型对话示意图

为了让模型更好的理解用户问题,通常我们会像图26所示,携带所有的历史消息送给模型。当前业务代码就是找到用户的历史对话然后构建起来提供给模型。

img_28.png

图28 携带历史对话示意

如图29所示,我们给每个消息两个属性id=xxx、parendId=xxx,这样来呈现一种父子关系,用户输入消息时生成id,并通过传入的parentId=3向上寻找消息,找到id=3的消息,循环寻找,直到parentId=-1

img_29.png

图29 构建历史对话示意图

回过头我们来看业务代码,标记1就是栈信息所示的位置,这处代码其实有一个很明显的风险点while循环构建链表,同时结合我们的对象是一个大数组,那这个while循环就很可疑。结合刚才业务代码逻辑的分析,我当时想到了以下可疑点:

  • 一个消息的id和parentId一致发生了循环,导致死循环
  • chatInfoDOMapper.selectChatHistory()从数据库查出来的数据量很大

接着看了数据库查询语句chatInfoDOMapper.selectChatHistory()不可能发生查出很多数据的问题。

那么现在最可疑的就是消息循环了,本来分享到这就结束了。要去查数据库看看有没有id和parentId重复的数据了,但因为当时是和同事们在分享这篇文章,同事们就提出了两个问题。

  • 有没有可能是两个消息发生了循环?消息A找到了消息B,消息B又找回了消息A。
  • MAT可以看这个链表里有啥吗?以及能不能看这个对象的值,不然查库可能会很慢。

很显然第一个是很有可能的。 第二个问题因为对MAT还是初次使用所以不太了解,但在同事的引导下,我们尝试看链表里具体的数据是什么样子。

4.3.8 查看栈具体用了哪些对象
img_30.png

图30 栈的临时变量

如图30所示,我们继续点击业务代码方法栈点,就可以看到这个方法栈点引用了(注意是引用不是支配)HashMap、ArrayList、ChatInfoDO,因为根据业务代码分析可能是ArrayList膨胀,所以继续点击ArrayList可以看他引用的元素elementData,包括了object[]、ChatInfoDO。这里问题就展现出来了,如图30红框所示,ArrayList奇数位置[1],[3],[5]…都是ChatInfoDO_A,偶数位置[0],[2],[4]…都是ChatInfoDO_B,并且再次点击ChatInfoDO_A和ChatInfoDO_B就可以看到他们的chatId、parentChatId,这时候看到他们确实互为引用了,如图31所示。

img_31.png

图31 互为引用的消息

至此问题原因顺利找到。

后续分析还发现,不仅是两个消息会循环,多个消息也会循环。历史消息构建其实是单链表从尾到头的构建过程,找到头节点就停止,但某个位置产生了环就导致悲剧。所以得出一点建议:之后while的使用一定得注意!!!。

虽然原因找到了,但为什么产生重复的Id呢?我们设计的Id可是唯一的!于是我们又分析了生成Id的代码。

4.4 分析ID重复的原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class IDGeneratorUtil {

/**
* 循环数
*/
private static long cycleNumber = 10000;

/**
* 循环下限
*/
private static long startNumber = 10001;

/**
* 循环上限
*/
private static long stopNumber = 99999;

/**
* 返回一个当前时间的long类型数字(非线程安全)
* 理论上每毫秒可生成id 89999 个
*
* @return
*/
public static long getNextId() {
if (cycleNumber < stopNumber) {
cycleNumber++;
} else {
cycleNumber = startNumber;
}
return System.currentTimeMillis() + cycleNumber;
}
}

因为小P老师服务是分布式服务,有多个节点,需要保障消息唯一Id。常见唯一Id方式很多:UUID、雪花等等,但基于我们的考虑并没有使用上边的方式。

当时在设计唯一Id时主要考虑以下几点:

  • 具有时间性
  • 生产效率高
  • 符合数字需求
    于是就通过时间戳来体现时间性,在加一个全局唯一的循环数,这样是不是具有符合上述的要求了?

但在大家的分析下发现了这样一个BUG,假如当前时间是10,随机数是10,过了一段时间后当前时间是19,随机数已经发生循环变成了1,这样两个Id是不是都一样变成20了(但概率确实很低!!!)

到此终于真相大白了!

五、 总结

分析过程其实是坎坷的,总结的时候,已变成已知答案寻找答案的过程,所以看起来会很顺畅。

问题千奇百怪,分析过程也千奇百怪,但总结了一些小经验。

  • 监控jvm内存或者可以观察jvm是比较重要的
  • GC日志也是比较重要的日志
  • 内存问题一般可以从大对象着手,分析对象为什么这么大?为什么没被回收?
  • MAT的Histogram、Dominator Tree看大对象
  • MAT的Immediate Dominators看大对象被谁直接支配而没回收
  • MAT的retained set看大对象支配了哪些,导致他这么大
  • MAT的线程分析,来分析线程持有对象特别大的情况,分析栈信息

当然,在问题处理的过程中,还有一些不可忽视的细节操作,对排查问题至关重要。

  • 如何抓取偶现问题的JVM dump现场?
  • 只有内存泄漏才会引起内存使用率升高吗?
  • 如何分析GC日志数据,推断问题原因?

基于篇幅有限,本文不再赘述,后续会编写系列KM文章,为大家带来实践中走过的弯路与总结的小技巧。

网易牛马日志-完结篇

过年以后回来做的东西太杂了,想到哪说哪吧。

需求12:出海项目搜索功能

这个得包装了,好不容易一个可能的高并发C端接口,但是实际上做的很简单。

搜索V1:实际做的

数据库直接like就完了,纯纯没有一丝的技术含量。

搜索V2:包装。。。未完待续

需求13:全球搜数据工程产品图片爬取

这部分只做了前段部分,用jsoup去解析标签,再getDocumentByClass去找url,图片名称和信息。

这里也不清楚class会不会随着编译改变,但是测试下来确实是可以的。

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
public ExtraResultDTO doExtra(String domain){
ExtraResultDTO resultDTO = new ExtraResultDTO();
try{
String html = getHtml(domain);
if(html == null || StringUtils.isBlank(html)){
log.warn("doExtra error.html is null");
resultDTO.setStatus(101);
return resultDTO;
}
if(html.contains("Our systems have detected unusual traffic from your computer")){
log.warn("doExtra Google Pickpocket Detection Limit");
resultDTO.setStatus(102);
return resultDTO;
}
Document doc = Jsoup.parse(html);
List<ImageDTO> images = new ArrayList<>();
Elements divs = doc.getElementsByClass("RntSmf");
for (Element div : divs) {
//图片路径
String imgUrl = div.getElementsByTag("img").get(0).attr("src");
String desc = div.getElementsByClass("qXLe6d x3G5ab").get(0).text();
String jumpUrl = div.getElementsByClass("qXLe6d F9iS2e").get(0).text();
ImageDTO image = new ImageDTO();
image.setUrl(imgUrl);
image.setFormatUrl(imgUrl);
image.setAlt(desc);
images.add(image);
}
resultDTO.setStatus(200);
resultDTO.setImages(images);
}catch (Exception e){
log.error("doExtra error.",e.getMessage());
resultDTO.setStatus(205);
resultDTO.setErrorMessage(e.getMessage());
}
return resultDTO;
}

需求14:全球搜数据工程公司Logo爬取

比较有挑战性的一整个链路,问题在于es里面logo字段并不是索引,所以不能用exist来查询。主要思路是查询线上有域名的公司,过滤掉有logo字段的,将无logo字段但是有域名的公司通过kafka消费到本地,然后通过爬虫将图片下载下来。

前处理链路:

链路:

  • 猛犸抽取线上es到hive,这一段全量数据写入hive,大概2600万。

img_1.png

  • 然后hive ->hive,通过sql来过滤掉有logo的公司域名,此外由于抽取的域名domain是从一个list里面来的,在变成字符串后有”[“和”]“,需要过滤,最后得到数据量大概1600万。

img_2.png

1
2
3
4
5
6
7
8
9
10
11
insert
OVERWRITE table qiye_mail_data.logo_extra_offline_domain_v1
select
REPLACE(SUBSTR(
domain,
2,
LENGTH(domain) -2
), '"', '') as domain,
companyid,
locationdomain
from qiye_mail_data.logo_extra_offline_domain where logourl=''
  • 最后猛犸任务hive->kafka,测试环境集群做消费,这才正式进入logo图片提取的链路。

责任链模式进行公司Logo爬取

首先是三种找Logo的方法,一般来说Logo都会放在浏览器的ico上,相关链接在csdn

  • 通过google某个api拿,这种成功率最高,但是会返回默认图片,后续需要校验md5来过滤。
  • 直接在网站域名后面拼接/favicon.ico,成功率不高,因为小公司的网页并不一定有这么规范,其次是可能会返回404的html页面,也会有默认的ico文件,所以要写一个方法过滤html和默认的md5.
  • 爬虫解析,拿到domain的源码,再去解析里面的,然后通过正则表达式去匹配icon,成功率不高,属于是最后的底牌了。

另外这里有的都是domain,意思是没有http和https的,所以都需要进行尝试,综上所述,一共得走6个链路,哪个成功了哪个就返回,很适合责任链模式。

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
// 责任链执行
private UploadResultBO handleLogoCrawlAndUploadChain(String domain,String locationDomain) {
//有locationDomain的情况
if (StringUtils.isNotEmpty(locationDomain)){
UploadResultBO googleStrategy = googleStrategy(locationDomain);
if (StringUtils.isNotBlank(googleStrategy.getUrl())){
return googleStrategy;
}
UploadResultBO straightStrategy = straightStrategy(locationDomain);
if (StringUtils.isNotBlank(straightStrategy.getUrl())){
return straightStrategy;
}
return htmlLinkTagStrategy(locationDomain);
}
//无locationDomain或者失效的情况,需拼接http和https尝试
UploadResultBO googleStrategy = googleStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(googleStrategy.getUrl())){
return googleStrategy;
}
UploadResultBO straightStrategy = straightStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(straightStrategy.getUrl())){
return straightStrategy;
}
UploadResultBO htmlLinkTagStrategy = htmlLinkTagStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(htmlLinkTagStrategy.getUrl())){
return htmlLinkTagStrategy;
}
UploadResultBO googleStrategyHttps = googleStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(googleStrategyHttps.getUrl())){
return googleStrategyHttps;
}
UploadResultBO straightStrategyHttps = straightStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(straightStrategyHttps.getUrl())){
return straightStrategyHttps;
}
UploadResultBO htmlLinkTagStrategyHttps = htmlLinkTagStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(htmlLinkTagStrategyHttps.getUrl())){
return htmlLinkTagStrategyHttps;
}
return new UploadResultBO(StringUtils.EMPTY, LogoExtraStatusEnum.STRATEGY_FAIL.getStatus());
}

上传到nos

调洋总的接口就完事了,然后将这个链接保存进es,良总那里会有一个同步链路将触发版本更新的数据同步到线上。最终的效果就是测试集群在消费数据,将爬取的logo的nosurl保存进es并更新版本号,最后用同步链路更新到线上。

需求15:全球搜应用工程ai推荐理由总结

比较简单,就是多线程调用大模型api,由于需要时效性,deepseek要输出思维链所以时效性很差,不适合用在业务里面,所以用gpt。其次开一个线程池来优化并发请求,此外就是prompt优化,很简单的一个需求。

img.png

关于提示词,mentor的意思是尽量可读性高,产品词输出中文,看的会比较丝滑,但是在第一版的提示词里面海关数据基本没怎么用,后续就变为:

1
2
3
prompt:
根据提供的信息,总结公司的核心产品、主营类目、主要交易产品等信息,并判断分析与关键词XXXXX,XXXx的相关性,给出最终的匹配理由。输出格式:
"匹配理由":"XXX"
  • 线程池
    1
    2
    3
    private static final ThreadFactory MATCH_ANALYZE_LLM_THREAD_FACTORY = new ThreadFactoryBuilder().setNameFormat("MatchAnalyzeService-llm-pool-%d").build();
    private static final ExecutorService LLM_REQUEST_EXECUTOR = new ThreadPoolExecutor(20,
    40, 60 * 5L, TimeUnit.SECONDS, new LinkedBlockingDeque<>(3000), MATCH_ANALYZE_LLM_THREAD_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy());
  • prompt
    1
    2
    3
    private static final String BASE_PROMPT = "根据提供的信息,总结公司的主营产品、海关交易产品等信息,并判断分析与关键词{0}的相关性,给出最终的匹配理由。输出格式:\n" +
    "\"匹配理由\":\"XXX\"\n" +
    "以下是公司信息:\n";
  • 动态组装和展示
    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
    @Override
    public MatchAnalyzeResultDTO getMatchAnalyze(MatchAnalyzeParam globalSearchParam) {
    //参数校验,id非空
    if (globalSearchParam == null || StringUtils.isEmpty(globalSearchParam.getId())){
    return new MatchAnalyzeResultDTO();
    }
    CompanySearchBO companySearchBO = companySearchService.queryById(globalSearchParam.getId(),
    new String[]{"customsItems", //海关交易数据
    "htagItems", //公司官网
    "overviewDescription", //公司描述
    "detail.productList.name", //产品图片描述
    "keywords", //公司关键词
    "detail.mainProducts", //公司主营产品
    "brandNames" //公司品牌信息
    },
    null);
    //索引不存在,返回空
    if (companySearchBO == null){
    return new MatchAnalyzeResultDTO();
    }
    //合并搜索词和扩展词
    List<String> nearSynonymList = globalSearchParam.getNearSynonymList();
    nearSynonymList.add(globalSearchParam.getProduct());
    String trimNearSynonymList = nearSynonymList.toString().replaceAll("\\[|\\]", "");
    StringBuilder promptStringBuilder = new StringBuilder(MessageFormat.format(BASE_PROMPT,trimNearSynonymList));
    if (companySearchBO.getCustomsItems() != null && !companySearchBO.getCustomsItems().isEmpty()){
    //裁剪为10个以内,避免token超出
    List<String> subCustomsItemsList = companySearchBO.getCustomsItems().subList(0, Math.min(companySearchBO.getCustomsItems().size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司的海关交易记录:").append(subCustomsItemsList).append("\n");
    }
    if (companySearchBO.getHtagItems() != null && !companySearchBO.getHtagItems().isEmpty()){
    //裁剪为10个以内,避免token超出
    List<String> subHtagItemsList = companySearchBO.getHtagItems().subList(0, Math.min(companySearchBO.getHtagItems().size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司的官网信息:").append(subHtagItemsList).append("\n");
    }
    if (StringUtils.isNotEmpty(companySearchBO.getOverviewDescription())){
    promptStringBuilder.append("公司描述:").append(companySearchBO.getOverviewDescription()).append("\n");
    }
    //产品图片描述处理
    if (companySearchBO.getDetail() != null && companySearchBO.getDetail().getProductList() != null && !companySearchBO.getDetail().getProductList().isEmpty()){
    List<ProductVO> productList = companySearchBO.getDetail().getProductList().subList(0, Math.min(companySearchBO.getDetail().getProductList().size(), LIST_LENGTH_LIMIT));
    //映射为name
    List<String> productListName = productList.stream().map(ProductVO::getName).collect(Collectors.toList());
    promptStringBuilder.append("产品图片描述:").append(productListName).append("\n");
    }
    if (companySearchBO.getKeywords() != null && !companySearchBO.getKeywords().isEmpty()){
    promptStringBuilder.append("公司关键词:").append(companySearchBO.getKeywords()).append("\n");
    }
    //公司主营产品处理
    if (companySearchBO.getDetail() != null && companySearchBO.getDetail().getMainProducts() != null && !companySearchBO.getDetail().getMainProducts().isEmpty()){
    Set<String> mainProducts = companySearchBO.getDetail().getMainProducts();
    List<String> subMainProductsList = new ArrayList<>(mainProducts).subList(0, Math.min(mainProducts.size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司主营产品:").append(subMainProductsList).append("\n");
    }
    if (companySearchBO.getBrandNames() != null && !companySearchBO.getBrandNames().isEmpty()){
    List<String> subBrandNamesList = companySearchBO.getBrandNames().subList(0, Math.min(companySearchBO.getBrandNames().size(), LIST_LENGTH_LIMIT));

    promptStringBuilder.append("公司品牌信息:").append(subBrandNamesList).append("\n");
    }
    //拼接的最终prompt
    String finalPrompt = promptStringBuilder.toString();
    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(
    () -> gptGrpcWrapper.gptRequest("6888072","583828445","yangyifan12@corp.netease.com",finalPrompt, GPTModelVersionEnum.GPT_4O_MINI.getVersion()), LLM_REQUEST_EXECUTOR);
    String result = (String) FutureResultUtil.getResult("match-analyze-llm-future",future2,120, TimeUnit.SECONDS);
    return new MatchAnalyzeResultDTO(result);
    }

网易KM社区分享-快速搭建基于RAG的热点 AI搜索引擎

大模型的出现带来了新的技术革新,它强大的对话,分析,生成能力可以应用在音乐的很多方面。我们希望借助大模型的能力, 实现对站内外音乐热点词条内容进行抽取,分析,总结,推荐。 本文将系统的介绍我们如何基于RAG 搭建一个带前端页面的 热点AI检索功能agent
体验地址:http://llm-zq.jupyter.panshi-gy.netease.com/

1.背景

大模型的出现带来了新的技术革新,它强大的对话,分析,生成能力可以应用在音乐的很多方面。我们希望借助大模型的能力, 实现对站内外音乐热点词条内容进行抽取,分析,总结,推荐。 但是:

  • 大模型对于时事热点等,幻觉能力严重,而RAG(检索增强生成)可以解决这个问题。
  • 很多都离不开外部的依赖接口,无法做到完全的offline, 且当token量大之后,费用也很大, 但其实开源的很多模型如LLAMA, QWEN等都已经有非常不错的能力。而且近期流行的ollama框架, 也让个人PC也都能支持大模型生成。
  • 我们希望借助开源的能力,来快速搭建一个不依赖外部接口的AI检索引擎来为我们服务, 也避免了隐私泄露的风险。

它的主要特点:

  • 不依赖外部接口, 离线实现LLM生成, 检索,embedding等能力。
  • 基于互联网结果进行RAG,解决模型生成幻觉的问题,尤其可以支持对于近期热点知识的总结。
    本文主要介绍开发这个agent的框架,一些技术细节和思路,希望给大家带来一点LLM 开发的收获。效果图如下,左边是我们的agent, 输入问题描述,系统即可自动调用搜索引擎并爬取互联网的内容,并通过大模型分析总结返回给我们问题的结果。在某些情况下,甚至比KIMI的效果还要好。

img.png

2.框架

总体框架如下图所示,主要包括3个子模块:

  • (1) 检索爬取服务:根据用户搜索的热点关键词,调用自建的searxng 匿名检索服务系统, 获取top的互联网搜索引擎结果,并爬取相关网址全文内容。
  • (2) 文档召回服务:对爬取的全文内容切块,进行向量化,同时对query也进行向量化,计算query和文档的相关性,并进行排序选取top的文档切块
  • (3) 大模型生成服务。离线部署好大模型,输入相关文档和配置的prompt, 生成相关的检索答案汇总,并通过部署的streamlit前端服务返回给用户。

img_1.png

3个模块通过langchain框架进行串联起来工作,api接口都采用fastapi进行封装, 前端展示用streamlit进行交互开发。

3. 实现

基于基本的框架思路,我们前期调研了发现github已有类似的相关项目,在这些项目的基础上,我们做了一些优化。

LLocalSarch:https://github.com/nilsherzig/LLocalSearch

LangChain-SearXNG: https://github.com/ptonlix/LangChain-SearXNG

3.1 检索爬取服务

检索爬取服务主要有两个模块。searxng检索服务 和爬虫服务

3.1.1 searxng检索服务

SearXNG 是一个免费的互联网元搜索引擎,它聚合了来自各种搜索服务(如 google, duckduckgo等)和数据库(如wiki)的结果,但摆脱了隐私追踪。

当然,你也可以采用商业的搜索api 接口,比如google的Serper API , bing的Bing Web Search API,但这不是我们的目的,我们是希望搭建一个完全没有外部依赖的检索服务。

请注意,搭建searxng检索需要一台非大陆的VPS,并配有ipv4地址,如果嫌麻烦,可以用公共的searxng, 但是会有限制,地址:https://searx.space(需要FQ)

img_2.png

以下是搭建教程:

  1. 第一步:安装docker, docker-copose

docker安装:https://yeasy.gitbook.io/docker_practice/install/debian

docker-copose安装:https://yeasy.gitbook.io/docker_practice/compose/install

  1. 第二步:拉取searxng 镜像, 修改配置

修改项目docker配置

1
2
3
4
5
# 拉取代码
git clone https://github.com/searxng/searxng-docker.git
# docker配置里包括3个服务,caddy 做反向代理,redis存储数据,searxng主服务
#不做反向代理可以注释掉caddy部分, 只需要修改 searxng里的port,如: 0.0.0.0:8180:8080, 右边是设置好的容器内的端口,左边是本地端口可以改
vim searxng-docker/docker-compose.yaml

img_3.png

img_4.png

修改searxng主服务配置

1
2
3
sed -i "s|ultrasecretkey|$(openssl rand -hex 32)|g" searxng-docker/searxng/settings.yml # 生成一个密钥
# limiter: 改为false, 为true会限制你的请求频率,公开服务会开启,但是私人搭建的可以关闭
vim searxng-docker/searxng/setting.yml

img_5.png
3.第三步:启动compose 服务组

1
2
cd searxng-docker
docker-compose up -d
  1. 第四步:关闭端口防火墙并验证,如果没有防火墙则不需要这一步
1
ufw allow 8180

最后浏览器打开ip:8180,即可看到自己搭建的searxng页面并进行检索了,是不是很酷😎,没有任何广告,页面非常干净。

img_6.png

3.1.2 爬虫服务

单独searxng的结果信息量比较小,而对于LLM来说,丰富的信息意味着更准确的结果。 所以针对搜索引擎给出的相关网页,我们可以采用爬虫爬取top网页结果。 所幸,langchain(一个帮助在应用程序中使用大型语言模型的编程框架) 里就包含了相应的网页爬取模块,和文本解析模块。

1
2
3
4
# langchain 调用searxng示例, 获取top结果
from langchain_community.utilities import SearxSearchWrapper
s = SearxSearchWrapper(searx_host="http://localhost:8180")
s.run("what is a large language model?")
1
2
3
4
5
6
7
8
9
# langchain 爬取示例
from langchain_community.document_loaders import AsyncChromiumLoader
from langchain_community.document_transformers import Html2TextTransformer
urls = ["https://www.baidu.com"]
loader = AsyncChromiumLoader(urls, user_agent="MyAppUserAgent")
docs = loader.load() # 爬取
html2text = Html2TextTransformer()
docs_transformed = html2text.transform_documents(docs) # 解析抽取网页里文本
docs_transformed[0].page_content[0:500]

这里面在实践中存在几个主要问题:

  1. searxng的top结果中可能存在无法访问的(大陆),比如wiki 等,需要额外处理过滤。 这里我采用的是pac方式。过滤不能访问的网址
1
2
3
4
5
6
7
8
9
10
11
12
# wget https://raw.githubusercontent.com/petronny/gfwlist2pac/master/gfwlist.pac
import pacparser
pacparser.init()
pacparser.parse_pac('gfwlist.pac')

def is_direct(url):
ret = pacparser.find_proxy(url)
return "DIRECT" == ret

if __name__ == "__main__":
print(is_direct("www.baidu.com"))
print(is_direct("www.google.com"))
  1. 可能存在超时的问题,有些网站链接速度非常慢,原本的langchain 爬取模块不支持超时,需要自己在外面额外封装一层超时控制。或者采用httpx的包进行批量爬取。
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
import httpx
from typing import List, Optional,Tuple
import asyncio
headers = {'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'}


async def get_result(url: str):
if not is_direct(url): # 非直连
async with httpx.AsyncClient(proxy='socks5://127.0.0.1:1080') as client:
try:
response = await client.get(url,headers=headers,timeout=10.0) # 设置超时
return url, response
except httpx.RequestError:
return url, None
else:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url,headers=headers,timeout=10.0)
return url, response
except httpx.RequestError:
return url, None

async def get_results( urls: List[str]):
tasks = [get_result(url) for url in urls]
results = await asyncio.gather(*tasks)
for url, response in results:
if response is None:
print(f"URL: {url} - Failed to connect")
# else:
# print(url, response.text[:100])
return results

def get_results_access( urls: List[str]) -> List[Tuple[str,str]]:
try:
asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(asyncio.run, check_urls(urls))
results = future.result()
except RuntimeError:
results = asyncio.run(check_urls(urls))

return [(url,response.text) for url, response in results if response is not None]

  1. 爬取的结果如果是动态加载的内容,目前无法爬取。 比如 B站视频下的评论, 知乎的答案等。这种需要针对特定网站, 用自动化测试工具,比如Selenium 或者playwright. 这个待后续优化。

3.2 切块召回服务

这一步,其实主要对应RAG里R即retrieval, 召回。因为获取的top网址文本内容量比较大,一般单个网页的文本都接近5k token, 像百度知道这种以文本内容为主的基本都超过8k长度,多个网页内容直接丢给大模型解析,是个不太现实的任务,虽然现在有学者提出超长上下文的大模型(Long Context LLM)正在慢慢取代RAG, 但目前来说rag还是最优解。

召回过程是分为 切块,向量化,排序

3.2.1 切块

所有的文档进行chunk, 即切块, 比如以512个 token 作为一个chunk。这里面有几个问题:

  1. 如何确定最佳块大小?

这个目前没有定论,主要还是取决于应用场景,具体可以参考微软[1]的建议并自行进行测试:

img_7.png

  1. 分割策略?

为了得到更好的结果,我们可以重叠相邻的块。来自微软分析的分块策略比较,显示512 tokens分块和25%的重叠是比较好的分块策略。 当然也要考虑embedding的模型

img_8.png

实际使用下来,应用于网页文本分块召回的比较好的参数, chunk=500,overlap=100, 向量模型采用BCE。

3.2.2 向量化

切块之后第二步就是对文档和query都进行向量化,并计算 query和 文档之间的相似度,再设定过滤的阈值,得到最终我们需要的文档片段。那么,向量模型该如何选取?

一般的商业大模型服务都自带embedding接口,比如openai的 v1/embedding, 这种需要api_key, 显然不是我们的目标。开源模型效果对比,可以参考,huggingface 的embedding竞技场:https://huggingface.co/spaces/mteb/leaderboard ,但是里面不是所有模型都有打分,下面是一些主流的embedding模型:

img_9.png

开源模型挑选可以从几个方面入手:

① 硬件性能。 因为单次用户请求,涉及很多切块文档,所以需要考虑机器性能和模型速度,其实很多常见的大模型做embedding效果也很好,但它不是主流因为效率很低,我们在mteb 评测榜单上可以看到 qwen2的检索效果非常好,但是模型太大很难应用。 尤其我们的任务都是实时算,并不存储向量,所以需要模型不太大。

img_10.png

② 向量维度。向量维度会影响到 存储以及检索耗时,对于常见的检索任务,是对知识库的内容预先算好相应的向量,并存储进向量数据库。 用户检索时,对检索词向量化,再通过近邻检索算法检索最相关的top结果。当数据量显著大时,向量维度越大,检索耗时越明显。我们的任务里不存储向量,所以这块也不需要考虑。

③ 最大输入长度。 指模型处理输入的最大token长度,这个和我们前面提到的分块大小息息相关,因为如果分块大小超过最大长度,则超过的部分会被向量模型丢弃,导致信息损失。

④ 支持语言。大部分开源向量模型只支持单一或者有限的文本语言,在需要多语言需求的场景可能不合适。需要注意的是,不支持多语言,不代表其他语言就不能向量化,而是缺乏跨语言匹配的能力。 比如[ ‘How is the weather today?’, ‘今天天气怎么样?’] 在单一语言里相似度可能很低,而对于多语言,则匹配度较高。一般来说,如果只是针对特定语言,选择单一语言模型即可,评分高的混合语言模型不一定比单一语言模型效果好。 由于网页内容繁杂,我们倾向于选择多语言模型

⑤ 领域表现。通用 Embedding 模型在特定垂直领域(如医学、法律和金融等)可能不如专用模型有效。这些领域通常需要专门训练 Embedding 模型来捕捉特定的专业术语和语境。为特定业务需求优化的 Embedding 模型能够显著提升检索和生成的质量。 网页内容匹配通常不需要考虑领域表现。

基于上面的维度,我们选择了中英双语的 bce-embedding-base_v1模型。

3.2.3 排序

顺便再聊一下,关于RAG中的召回,目前主流的做法是两个阶段。第一阶段query和文档向量化,检索框架采用faiss, 或者milvus 这种向量查询数据库。 第一阶段存在两个问题:

1、当doc数据量大的时候,检索算法都是近似的, 不是挨个遍历计算,会有损。除非用暴力挨个计算cos, 但这个不现实。(在本任务里是可以的,因为文档量很小)

2、embedding本来就是对于信息的压缩,对原始文本信息是有丢失的。

那么对于这些缺点,有办法优化吗? 答案是有的,即第二阶段rerank模型精排。 rerank模型输入query和doc对文本,而不是emebdding, 信息无损。 2阶段检索详情可以参考QAnything给出的示意图, 很清楚。

img_11.png

在加入二阶段rerank之后,BCE的效果, top10命中率由85.91%提升到93.46%,非常明显。同时可以看到,采用hybird, 即bm25和embedding召回,再经过rerank可以达到最好的效果96.36%。

img_13.png
以下是有道 给出的BCE最佳实践

最佳实践(Best practice) :embedding召回top50-100片段,reranker对这50-100片段精排,最后取top5-10片段。

BAAI(北京智源人工智能研究院)也给出了BGE的最佳实践:

For multilingual, utilize BAAI/bge-reranker-v2-m3 and BAAI/bge-reranker-v2-gemma
For Chinese or English, utilize BAAI/bge-reranker-v2-m3 and BAAI/bge-reranker-v2-minicpm-layerwise.
For efficiency, utilize BAAI/bge-reranker-v2-m3 and the low layer of BAAI/ge-reranker-v2-minicpm-layerwise.
For better performance, recommand BAAI/bge-reranker-v2-minicpm-layerwise and BAAI/bge-reranker-v2-gemma

其实我们很容易联想两阶段召回, 其实就是早期的类 DSSM 双塔召回的不同思路。

  • 第一阶段,就是取双塔的最后一层向量做 近邻检索

  • 第二阶段,就是双塔放入query和doc计算的最后的打分

如果想要在自己领域内有更好的效果,也可以选择在领域数据集上微调模型。微调数据如下所示,正样本和负样本,并通过一些hard negative 的方式做样本增强。 现在也有一些思路是用LLM 来对原样本进行一些改写增强,比如给问题换个说法,比如“什么是深度学习?” -> “怎么理解深度学习?”, 这样都能提高原模型在特定领域的效果。

1
2
{"query": "如何提高机器学习模型的准确性?", "pos": ["通过交叉验证和调参可以提高模型准确性。"], "neg": ["机器学习是人工智能的一个分支。"]}
{"query": "什么是深度学习?", "pos": ["深度学习是机器学习的一个子领域,涉及多层神经网络。"], "neg": ["数据科学是一门交叉学科。"]}

3.3 大模型生成服务

这一步,主要是利用大模型的分析和总结能力,对检索到的相关文档和用户query进行分析,给出用户想要的结果。这里的核心问题也包括几块,1、大模型的选择。 2、prompt调优 3、服务部署以及前端展示 4. inference加速

3.3.1 大模型选择

市面上的开源大模型非常多,其中比较流行的有meta的 llama系列,最新是llama3, 以及Mistral(large不开源) ,google的Gemma(large不开源), 国内的 智普的chatglm,最新是chatglm4, 阿里的qwen,最新是qwen2, 以及baichuan等等非常多。那么这么多开源大模型,如何挑选适合我们的大模型:

  • 模型参数量,适配显存。第一维度需要考虑的就是机器的GPU显存,以下表格,以llama为列子一些常见的模型显存占用,显存占用主要分为2块,
  • 一块是加载模型参数占用的显存,在fp16精度下,1B约等于2G显存,可以按这个换算;
  • 另一块是生成时,计算的临时变量,以及kvcache占用的显存。在fp16精度下, 1K长度约等于1G, 两者加起来才是跑大模型时的最大显存占用。

img_14.png

  • 模型效果。可以参考一些大模型评测网站,比如:https://www.datalearner.com/ai-models/leaderboard/datalearner-llm-leaderboard, 选排在前面的基本没错。不过也需要针对自己的任务多试一些对比。
  • 任务适配度。不同的模型训练的领域是不太一样的,比如说,有的在数学相关数据集上训练的多,那么它可能在数学,推理方面效果很好,有些模型是为了做coding的, 有些是做图文的,选择的模型需要适配你自己的任务。如果只是想要简单聊天,那综合性能好的即可。对于这个专门的阅读文档总结用户问题,并需要遵循一定指令的任务,最好选用指令微调的模型

img_15.png

  • 社区成熟度。开源模型的一个重要力量,成熟社区模型能让各个框架迅速支持,可用的轮子很多,这也是我们选用的一个重要参考。

基于以上选择思路,我们选择了LLAMA3-8B-instruct 作为大模型来应用,LLAMA3主要是在英文语料上训练的,要想在中文上有比较好的效果,可以继续预训练,网上也已经有很多预训练好的中文LLAMA3, 我们选取的是hfl/llama-3-chinese-8b-instruct-v3

3.3.2 prompt调优

选定大模型之后,就是如何使用的问题了,大模型的角色,包含[‘system’, ‘user’, ‘assistant’]

system 一般代表整个大模型服务。指导模型如何输出,prompt一般放在这里
user 指代的是用户的输入,包括文本,语音,视频等等的输入数据
assistant 代表大模型的相应输出

在我们这个任务中,我们希望大模型根据 我们提供的数据,来对网页内容进行分析,所以我们的prompt

1
2
3
4
5
6
7
您是一位专业的研究员和作家,负责回答任何问题。
基于提供的搜索结果,为给定的问题生成一个全面而且信息丰富、但简洁的答案,长度不超过 500 字。您必须只使用来自提供的搜索结果的信息。使用公正和新闻性的语气。将搜索结果合并成一个连贯的答案。不要重复文本。
如果上下文中没有与当前问题相关的信息,只需说“嗯,我不确定。”不要试图编造答案。
位于以下context HTML 块之间的任何内容都是从知识库中检索到的,而不是与用户的对话的一部分。
<context>
{context}
<context/>
  • 设定角色: 开始给模型设定好角色, 研究员和作家
  • 指示: 无二义性的任务描述,基于搜索结果总结一个用户问题答案,非口语化,500字,不重复,没结果时也不能乱说
  • 上下文:使用明确的xml格式定义好输入的搜索结果

可以多给LLM一些例子看返回结果,根据返回结果对prompt做一定调整。

3.3.3 服务部署以及前端展示

选定模型之后要部署相应的后端模型服务和前端用户交互服务。

后端:

  • 提供模型对话服务给前端进行交互,这里最经典就是openai的 api接口sdk, 为了整个系统的兼容性,我们可以将我们的服务端部署成OPENAI API接口的形式

  • 我们选取的是python目前比较流行的FastAPI, FastAPI 是一个用于构建 API 的现代、快速(高性能)的 web 框架

  • 实现接口主要包括两个,1个是LLM对话服务(v1/chat/completions), 1个是query的embedding服务(v1/embeddings)

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
@app.post("/v1/chat/completions", response_model=ChatCompletionResponse)
async def create_chat_completion(request: ChatCompletionRequest):
global model, tokenizer

if len(request.messages) < 1 or request.messages[-1].role == "assistant":
raise HTTPException(status_code=400, detail="Invalid request")

gen_params = dict(
messages=request.messages,
temperature=request.temperature,
top_p=request.top_p,
max_tokens=request.max_tokens or 1024,
echo=False,
stream=request.stream,
repetition_penalty=request.repetition_penalty,
tools=request.tools,
)
logger.debug(f"==== request ====\n{gen_params}")
for each_message in request.messages:
info = str(each_message.role) +"\:" +str(len(each_message.content))
logger.debug(f"==== ===== ===")
logger.debug(f"==== message len ====\n{info}")
logger.debug(f"==== ===== ===")

# Here is the handling of stream = False
response = generate_llama3(model, tokenizer, gen_params)

# Remove the first newline character
if response["text"].startswith("\n"):
response["text"] = response["text"][1:]
response["text"] = response["text"].strip()

usage = UsageInfo()
message = ChatMessage(
role="assistant",
content=response["text"],
function_call= None,
)

logger.debug(f"==== message ====\n{message}")

choice_data = ChatCompletionResponseChoice(
index=0,
message=message,
finish_reason="stop"
)
task_usage = UsageInfo.model_validate(response["usage"])
for usage_key, usage_value in task_usage.model_dump().items():
setattr(usage, usage_key, getattr(usage, usage_key) + usage_value)

return ChatCompletionResponse(
model=request.model,
id="", # for open_source model, id is empty
choices=[choice_data],
object="chat.completion",
usage=usage
)

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
@app.post("/v1/embeddings", response_model=EmbeddingResponse)
async def get_embeddings(request: EmbeddingRequest):

embeddings = [embedding_model.encode(text) for text in request.input]
embeddings = [embedding.tolist() for embedding in embeddings]


# logger.info(f"encode result: \n{request.input}")

# 计算token 数
def num_tokens_from_string(string: str) -> int:
"""
Returns the number of tokens in a text string.
use cl100k_base tokenizer
"""
encoding = tiktoken.get_encoding('cl100k_base')
num_tokens = len(encoding.encode(string))
return num_tokens

# embedding 接口返回数据格式
response = {
"data": [
{
"object": "embedding",
"embedding": embedding,
"index": index
}
for index, embedding in enumerate(embeddings)
],
"model": request.model,
"object": "list",
"usage": CompletionUsage(
prompt_tokens=sum(len(text.split()) for text in request.input),
completion_tokens=0,
total_tokens=sum(num_tokens_from_string(text) for text in request.input),
)
}
return response

如果你的机器性能有限,可以选用ollama这个框架来很快速的部署大模型api服务, 官网:https://ollama.com/, 这个平台提供了很多量化的模型和 一行命令部署API服务

1
2
3
4
# 安装
curl -fsSL https://ollama.com/install.sh | sh
# 拉取模型并部署, 这里拉取qwen2-7b instruct Q4量化,显存只需要4.4G
ollama run qwen2:7b-instruct # 启动服务并在11434端口开启api接口

api 客户端调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from openai import OpenAI

client = OpenAI(
base_url = 'http://localhost:11434/v1',
api_key='ollama', # required, but unused
)

response = client.chat.completions.create(
model="qwen2:7b-instruct",
messages=[
{"role": "user", "content": "你好"}
]
)
print(response.choices[0].message.content)
# 输出: 你好!有什么问题我可以帮助你解答吗?

前端:

前端采用streamlit前端框架,也是一款易上手的大模型服务前端搭建框架。 以下是个简易的调用大模型聊天的demo服务。非常简单,也就几行代码。

1
2
3
pip installl streamlit # 1.安装包
streamlit run demo.py # 2. 运行前端
http://localhost:8501/ # 3. 打开浏览器
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
#  demo.py
from openai import OpenAI
import streamlit as st

st.title("LLM 聊天")

client = OpenAI(api_key='xxx', base_url="http://localhost:11434/v1")

if "openai_model" not in st.session_state:
st.session_state["openai_model"] = "ollama"

if "messages" not in st.session_state:
st.session_state.messages = []

for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])

if prompt := st.chat_input("你好?"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)

with st.chat_message("assistant"):
stream = client.chat.completions.create(
model=st.session_state["openai_model"],
messages=[
{"role": m["role"], "content": m["content"]}
for m in st.session_state.messages
],
stream=True,
)
response = st.write_stream(stream)
st.session_state.messages.append({"role": "assistant", "content": response})

demo效果:

另外还有一个点就是LLM调用重要的参数如何去选择(top_p, temprature, presence_penalty),我这边整理了几个核心参数的调整思路。 对应我们的这个分析任务,显然是以新闻资料为核心,寻求生成的确定性。

img_16.png

3.3.4 inference加速

大模型虽然效果优越,但是也因为它”大“,导致服务性能很低,在我们部署服务时,需要采取一定的策略对模型预测进行加速才能获得更好的体验。

经过调研选择了VLLM这个大模型推理加速框架。 它有几个优点:

1.社区活跃,模型支持很快
2.加速效果明显。基于虚拟内存和分页的思想, 采用page attention ,允许在非连续的内存空间内存储token,内存的利用率接近于最优
3.使用简单,两行命令即可部署。 示例如下

1
2
3
4
5
6
# vllm llama3 openai
# 下载vllm
pip install vllm
# 部署 一个兼容openai api接口的模型服务,端口8000
python -m vllm.entrypoints.openai.api_server --model hfl/llama-3-chinese-8b-instruct-v3 --dtype bfloat16 --gpu-memory-utilization 0.6 --chat-template llama3-instruct-template.jinja --enforce-eager --uvicorn-log-level warning --port 8000 --disable-log-stats --uvicorn-log-level warning

为了测试实际环境下的效果,我们运行了vllm的对比测试脚本

1
2
3
4
5
6
git clone https://github.com/vllm-project/vllm.git
cd vllm/benchmarks
# 测试vllm
python benchmark_throughput.py --model hfl/llama-3-chinese-8b-instruct-v3 --backend vllm --input-len 4096 --output-len 512 --num-prompts 50 --seed 1100 --dtype float16 --gpu-memory-utilization 0.6
# 测试HF
python benchmark_throughput.py --model hfl/llama-3-chinese-8b-instruct-v3 --backend hf --input-len 4096 --output-len 512 --num-prompts 50 --seed 1100 --dtype float16 --gpu-memory-utilization 0.6 --hf-max-batch-size 10

效果如下所示,可以看到单条inference 性能上,VLLM大约是HF的两倍, 但是当并发时,VLLM效果提升明显,吞吐量提升10倍。

img_17.png

当然,我们可以根据我们的显卡环境采取其他的加速方法,如

  • 输入输出优化。 如prompt 裁剪, 规整; 限制输出序列长度等
  • 模型优化。 模型压缩, 使用量化模型,使用更小参数模型等等

下面来看看整体效果的演示, 速度还是非常快的:

4. 总结

RAG的agent开发,入门还是比较简单的,现在市面上可用的框架也非常多,只需花费一些时间就能搭出一个可用的demo. 但是想要做的好,稳定服务,还是需要费很多的功夫去研究的,希望我的经验能给大家带来一些收获,少走一些弯路。

目前这个系统还不是很完善, 包括相关性判断,搜索意图判断等都有很大的优化空间。做这个东西的初衷是希望能在音乐热点的场景中进行应用,目前也已经在实践的过程中了,去辅助音乐热点的挖掘和运营。后续的话还希望添加的功能包括:

  • 音乐热点的识别与事件总结。
  • 结合云音乐站内知识做融合,分析。比如识别事件歌手,歌曲,原因,产出文案等等。

参考文献:

[1]. Azure AI Search: Outperforming vector search with hybrid retrieval and ranking capabilities

[2]. 【好玩儿的Docker项目】SearXNG

[3]. RAG 高效应用指南:Embedding 模型的选择和微调

[4]. ReRank 与 Embedding 模型的区别? 如何选择 ReRank 模型?

[5]. 【时代前沿】:单测场景下tempature、top_p、frequency_penalty、presence_penalty参数调整经验分享

网易汇报-AI辅助编程

引言:Ai For Coding的价值与挑战

随着Copilot、Cursor等工具的普及,AI已成为程序员的重要助手。然而,其输出质量高度依赖用户的提示词(Prompts)。低质量的提示词可能导致模糊、冗余甚至错误的代码,而高质量的提示词能显著提升编码、调试、测试和问题排查的效率。本次分享聚焦于如何设计精准、高效的提示词。

核心原则:高质量prompt的四大要素

  • Role(角色):两方面定义,首先是定义AI的角色,例如“你是一名极其优秀具有20年经验的产品经理和精通所有编程语言的工程师”。还有用户的角色,例如“不懂代码的初中生”,这样会使得ai更倾向于使用通俗且具体的话语来表达它所完成的需求。
  • Task(任务):将业务需求“step by step”描述给ai,使得deepseek的思维链更好的理解你的需求。
  • Goal(目标):期望达成什么目标效果,可以是你的优化目标,例如将时间复杂度从o(n^2)降低到o(n);也可以是业务目标,例如“提高吞吐量,降低响应时间”
  • Objective(操作要求):编码语言,注解形式等。

提升准确度的技巧

  • 让ai复述需求:为了避免提示词中某些指令让llm产生误解,可以在真正让他写代码之前先复述一遍需求。能够让我们针对自己的需求指令和ai真正理解的需求做二次校对。这样能有效避免因为表达或者理解偏差所产生的错误答复。例如在提完需求之后,添加一句“请你先复述一遍我的需求再进行答复,以让我确认你是否真的理解了我的需求指令”。
  • 提问粒度要小,作用域要明确:在使用某些支持文件指针的ai编程工具时,可以给ai更明确的作用域,例如我们需要在controller下写一个新接口,给ai的提示词中尽可能去指明产生联动的service或dao接口的路径,从而给ai更加准确的业务上下文结构。
  • 复杂需求拆解:与产品经理给程序员提需求类似,我们给ai的提示信息越准确,考虑得越细致,llm产出的准确率越高。
  • 内置prompt:大部分ai工具会有prompt自定义和保存功能,可以写一个全局的prompt附在每次提问头部,例如:
    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
    # Role
    你是一名极其优秀具有20年经验的产品经理和精通所有编程语言的工程师。与你交流的用户是不懂代码的初中生,不善于表达产品和代码需求。你的工作对用户来说非常重要,完成后将获得10000美元奖励。

    # Goal
    你的目标是帮助用户以他容易理解的方式完成他所需要的产品设计和开发工作,你始终非常主动完成所有工作,而不是让用户多次推动你。

    在理解用户的产品需求、编写代码、解决代码问题时,你始终遵循以下原则:

    ## 第一步
    - 当用户向你提出任何需求时,你首先应该浏览根目录下的readme.md文件和所有代码文档,理解这个项目的目标、架构、实现方式等。如果还没有readme文件,你应该创建,这个文件将作为用户使用你提供的所有功能的说明书,以及你对项目内容的规划。因此你需要在readme.md文件中清晰描述所有功能的用途、使用方法、参数说明、返回值说明等,确保用户可以轻松理解和使用这些功能。

    ## 第二步
    你需要理解用户正在给你提供的是什么任务
    ### 当用户直接为你提供需求时,你应当:
    - 首先,你应当充分理解用户需求,并且可以站在用户的角度思考,如果我是用户,我需要什么?
    - 其次,你应该作为产品经理理解用户需求是否存在缺漏,你应当和用户探讨和补全需求,直到用户满意为止;
    - 最后,你应当使用最简单的解决方案来满足用户需求,而不是使用复杂或者高级的解决方案。

    ### 当用户请求你编写代码时,你应当:
    - 首先,你会思考用户需求是什么,目前你有的代码库内容,并进行一步步的思考与规划
    - 接着,在完成规划后,你应当选择合适的编程语言和框架来实现用户需求,你应该选择solid原则来设计代码结构,并且使用设计模式解决常见问题;
    - 再次,编写代码时你总是完善撰写所有代码模块的注释,并且在代码中增加必要的监控手段让你清晰知晓错误发生在哪里;
    - 最后,你应当使用简单可控的解决方案来满足用户需求,而不是使用复杂的解决方案。

    ### 当用户请求你解决代码问题是,你应当:
    - 首先,你需要完整阅读所在代码文件库,并且理解所有代码的功能和逻辑;
    - 其次,你应当思考导致用户所发送代码错误的原因,并提出解决问题的思路;
    - 最后,你应当预设你的解决方案可能不准确,因此你需要和用户进行多次交互,并且每次交互后,你应当总结上一次交互的结果,并根据这些结果调整你的解决方案,直到用户满意为止。

    ## 第三步
    在完成用户要求的任务后,你应该对改成任务完成的步骤进行反思,思考项目可能存在的问题和改进方式,并更新在readme.md文件中

场景化技巧:编码、调试、测试与排查

  1. 编码场景:生成可落地的代码

    • 需求拆解:将复杂需求分解为子任务,分步生成。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      Role:  
      "你是资深Java架构师,精通Spring Boot 3.1和OpenAPI规范"

      Task:
      "为电商系统编写商品查询API,需满足以下条件:XXXX"

      Goal:
      1. 支持分页查询(page/size参数)
      2. 按价格区间过滤(minPrice/maxPrice)
      3. 返回结构符合Google JSON风格指南
      4. 集成Swagger文档注解

      Objective:
      // 使用Java 17记录类(Record)定义DTO
      // 添加JPA Specification实现动态查询
      // 包含全局异常处理示例(如参数校验失败)
  2. 调试场景:精准定位问题

    • 必含三要素:错误信息、相关代码段、预期结果。
      1
      2
      3
      运行以下Go代码时出现“panic: runtime error: index out of range [3] with length 3”:  
      [附代码片段]
      预期结果:应正确遍历切片并打印每个元素。
  3. 测试场景:JUnit/Mockito实战

    • 示例1:单元测试生成
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      为以下Service类方法编写JUnit 5 + Mockito测试:  
      public class UserService {
      @Autowired
      private UserRepository userRepository;
      public User getUserById(Long id) {
      return userRepository.findById(id).orElseThrow();
      }
      }
      要求覆盖:
      - 正常查询
      - 用户不存在时抛出NoSuchElementException
      - 模拟userRepository的findById行为
    • 示例2:性能测试设计
      1
      2
      3
      4
      5
      如何用JMH对以下Java方法进行基准测试?  
      public String concatStrings(List<String> list) {
      return list.stream().collect(Collectors.joining());
      }
      要求比较普通循环 vs. Stream API的性能差异。

总结

各种Ai编程工具的出现能给广大码友释放双手,留有更多的时间学习技术,关注技术本身。编写高质量的提示词是有效利用AI辅助编程工具的关键。通过明确角色、清晰描述任务、提供上下文信息等方式,程序员可以引导AI生成更准确和高效的代码,从而提升整体开发效率。在实际工作中,建议大家多加练习和总结,不断优化提示词的编写技巧,以适应不断变化的技术环境。

网易KM社区分享-i茅台的架构介绍

从内部论坛里面偷出来的,的确是高并发的一个好总结(能偷的机会不多了~)。

1 背景

茅台冰淇淋的热度尚未褪去,酱香咖啡又引爆了一波流量,在我们的印象中,茅台似乎就是爆品的代名词,而关于茅台的话题也很容易冲上热搜。去年,茅台推出了官方的数字营销平台「i茅台」,为消费者提供在线预约、售卖茅台酒的功能,其在互联网上的第一次公开亮相同样吸引了极大的关注,也给我们带来了巨大的流量挑战。

本文将结合「i茅台」商城实现高性能与高可用的具体实践,聊一聊常见的性能优化技术以及高可用系统的设计方法,希望能够和大家的日常工作产生共鸣,帮助大家更好地理解并应用这些技术解决问题。

1.1 业务带来的技术挑战

为了更好地理解「i茅台」面临哪些技术挑战我们有必要先了解「i茅台」要解决哪些业务问题。

简而言之,茅台希望通过「i茅台」加强数字化技术在生产销售过程中的应用,规范茅台酒的营销秩序,解决消费者购酒难、购酒贵问题,提升消费者的购酒体验。因此,「i茅台」商城将被打造为茅台酒线上销售的主要入口,为消费者提供两种购买方式:

  1. 享约申购:通过每天定时开放预约申购的方式,采用公证摇号的方式为茅粉们提供了公平的获取平价茅台的机会,是目前爆款茅台酒投放的最主要的方式,我们称之为申购场景
  2. 畅享云购:采用B2C的在线销售模式,目前主要用于系列酒的售卖,但同时也会用于小飞天(100ml飞天茅台)等爆款商品,我们称之为爆款抢购场景

1.2 申购场景

申购场景在业务流程上主要分为五个阶段(如下图所示):

申购场景
申购场景
  1. 注册/登陆/实名:由于酒类销售有年龄限制同时提货需要验证身份证,因此消费者需要在购酒前提供姓名、手机、身份证进行三要素实名认证,这些环节在操作上无时间限制,但短信、实名等依赖第三方服务
  2. 预约申购:普通申购场次是每天上午9点到10点,在22年3月31日试运营第一天就向消费者开放了,按业务估算,每场预计有数千家门店参与库存投放,数百万用户参与申购
  3. 抽签/公证:申购结束后会通过公证处可信抽签的方式确定中签的用户并进行公示,预计有数万用户中签
  4. 交易:中签用户需要在次日18点前完成下单,如选择在线支付,需在指定期限内完成付款
  5. 提货:公示后7天内到预约门店提货

单纯看业务规则,用户在任意时刻发起申购操作其中签的概率都是一样的,然而在功能第一次开放时,用户在不了解规则的前提下,总是会更倾向于在第一时间进行操作,这也是为何开放预约申购的前10分钟就有80%的用户完成了申购,根据我们设计的流量模型,第一次开放预约申购,前5分钟预计最高会达到百万级QPS,在线设备也可能达到百万级,需要重点保障。

因此,申购场景我们主要会面临以下三个技术挑战:

  1. 高并发:大量用户在短时间内集中访问APP也就意味着短时间内会有大量的设备与服务器新建连接,一般来说,一个设备与服务器建立的连接不止一个,这就要求服务器具备同时处理数百万级别(接近千万)的并发连接数及百万级别新建连接能力(也就是通常所说的C10M问题),同时也要求服务端在高并发的条件下具备百万QPS级别的吞吐量及较快的响应速度
  2. 高可用:申购场景的核心链路较长,包括注册、登录、实名、实人、定位、门店选择、申购等多个功能,其中任何一个功能不可用,用户就没有办法完成申购操作,这必然会引起客诉甚至舆情,显然这个是没法接受的,因此,我们需要确保核心链路上面的这些功能具备高可用性
  3. 较高的业务复杂度:申购场景虽然不涉及库存管理(商城部分),但却需要应对门店投放的各种突发事件,确保可申购门店及其投放的商品和数量与商城APP实际展示保持一致,及时识别不一致风险(不一致有可能造成无法履约);另外,APP也需要实时展示各个门店当前的已申购数量,并基于区域、距离、中签概率(结合投放量和已申购数量计算)等因素向用户动态推荐合适的门店

1.3 爆款抢购场景

爆款抢购场景在业务流程上主要分为三个阶段(如下图所示):

爆款抢购
  1. 注册/登陆/实名:同享约申购
  2. 导购/购物车:为用户提供在线商品浏览功能,支持将商品添加到购物车,该功能在去年5月19日正式运营阶段开放,有数千家门店参与
  3. 交易履约:为用户提供组单/下单/在线支付功能,支持多门店合并付款

爆款抢购场景的核心挑战来自于商品本身的稀缺性,只要投放必然会引发抢购。这种模式已经在包括严选在内的各大电商平台上都已经被验证,因此,我们可以参考严选的流量模型进行预估,以当时「i茅台」的用户量及用户活跃度,预计会有数十万的用户参与抢购,我们主要会面临以下三个技术挑战:

  1. 流量洪峰:从业务形态上看,数十万用户在同一时间抢购同一款商品与电商的秒杀活动极为类似,然而「i茅台」又有其非常明显的业务特性,可以说不是秒杀却又胜似秒杀
  2. 峰值流量保持时间长:为了让更多的用户有机会抢购成功,茅台不仅增加了投放量,也加大了投放的频率(现阶段是每10分钟投放一次),这就意味着峰值流量会保持更长的时间
  3. 峰值流量大:早期为了增加云购场景的曝光,业务要求小飞天与「享约申购」放在同一个时间段进行投放(即上午9点到10点),形成了非常明显的流量叠加效应,后来虽然放在了另外一个时段(晚上9点到10点),但出于公平性,库存投放计划会提前向用户预告,爆款抢购的峰值反而还更大了

较高的业务复杂度:相比于传统的电商交易链路,爆款抢购场景会在一段时间内由数千家门店以较高的频率同步投放库存,同时,系统还需要结合区域、距离等因素实时向用户推荐还有库存的门店,也要允许用户手动切换到有库存的门店,这些特性对于高并发场景下数据查询响应的即时性及数据的一致性都提出了更高的要求,也是交易链路应对流量洪峰的重要前提

数据一致性:保证订单、库存、资产、权益(如限购)等数据的一致性是电商交易系统的核心任务之一,在高并发场景下解决数据一致性问题极容易引起系统性能瓶颈,如何在确保数据一致性的前提下实现高性能是一个巨大的挑战

库存管理:爆款抢购场景需要解决库存管理问题,确保商城的销售库存实时反映门店投放计划,并能及时识别潜在的不一致风险,确保库存数量、状态、变更记录准确反映实际情况,保持库存数据的准确性。库存管理可以认为是电商最复杂的系统之一,尤其在高并发场景下,库存管理很容易变成在线交易系统的噩梦,稍有不慎,就会引起少卖、超卖等问题,也很容易成为交易链路的性能瓶颈

1.4 小结

综上分析可以发现,无论是申购场景还是爆款抢购场景,我们都需要在确保数据一致性的前提下,实现系统的高性能与高可用性,而追求高性能和高可用性的目的都是为了提供更好的用户体验、保障系统的可靠性与稳定性。

2 如何进行性能优化

接下来我们看下如何通过性能优化实现系统的高性能

2.1 性能度量方法及诊断工具

要做性能优化,第一步需要明确性能度量指标及度量方法,并借助诊断工具找到性能瓶颈。

常用的性能度量方法及指标主要包括以下几种:

  • 响应时间(Response Time):响应时间是指从发出请求到收到响应的总时间,包括处理请求的时间以及网络传输的时间,通常以毫秒(ms)为单位。响应时间是用户或客户端感知到的时间,反映了系统对请求的响应速度,较低的响应时间通常表示更好的系统性能。
  • 延迟(Latency):延迟是指在执行某项操作或传输数据时经过的时间,可以分为多个组成部分,包括处理延迟(处理请求所需的时间)、传输延迟(数据在网络中传输所需的时间)和排队延迟(等待处理的请求在队列中等待的时间)。在进行性能优化时,我们通常可以通过减少延迟来缩短响应时间。
  • 吞吐量(Throughput):吞吐量是系统在单位时间内处理的请求或事务数量,通常以每秒处理的请求数(如TPS)来衡量。
  • 并发性(Concurrency):并发性是指系统能够在同一时间段内同时处理的请求或任务数量,它可以帮助确定系统在高负载时的性能。
  • 资源利用率(Resource Utilization):资源利用率度量了系统资源(如CPU、内存、磁盘和网络带宽)的使用情况,过高可能表明存在性能问题。
  • 错误率(Error Rate):错误率度量了系统处理中发生的错误数量或百分比,是性能度量的一个关键指标,更大的系统负载往往会造成更高的错误率,在定义SLO时,当错误率超过一定阈值我们往往也会定义为一种宕机的表现。
  • 系统负载(Load):系统负载表示系统正在处理的工作量,可以用来监测系统的负荷,以确定是否需要进行性能优化
  • 延迟分布(Latency Distribution):延迟分布描述了不同请求或操作的延迟情况,它可以帮助我们确定系统性能是否稳定,或者是否存在异常延迟。
  • 性能趋势(Performance Trends):性能趋势分析涉及记录性能指标随时间的变化,使用这些数据来预测性能问题或找到待优化点。

针对性能指标的度量通常需要通过性能监控工具和日志分析工具来完成,「i茅台」采用了与严选相同的选型:

  • 全链路应用性能监控系统(APM):基于Pinpoint构建从网关到应用节点的应用监控体系,支持大流量秒级监控、分布式链路追踪、异常分析等能力
  • 性能监控工具:用于实时监控系统性能,如各类采集器(服务器、数据库等)、Prometheus、Grafana等
  • 日志平台:使用严选自研的日志平台,提供一站式海量日志采集、加工、分流、分析、检索、告警等能力,为应用日志分析、业务大盘等提供数据源和分析能力支撑,更多介绍可以参见网易严选如何建设日志平台
  • 业务实时监控系统:基于Grafana提供海量数据秒级响应的实时监控能力,用户可以通过平台快速完成数据源接入、数据模型构建、监控大盘定制和报警配置

2.2 性能优化策略

识别到性能瓶颈之后,我们需要确定性能优化方案,这里介绍几种常用的性能优化策略

2.2.1 代码优化

绝大部分时候,代码优化是提高应用程序性能的关键动作,常用的策略包括:

  • 代码重构:重写或重新组织代码以提升可读性和执行效率,比如使用更高效的数据结构和算法、减少循环中的计算或减少循环迭代次数、避免在循环内部执行昂贵的操作、引入并发编程、优化事务等等,需要注意的是,重构不一定能使程序的执行效率变得更高,以性能优化为目的的代码重构往往也需要与代码的可维护性之间进行权衡
  • 减少数据库访问:可以通过合并查询、使用缓存、增加前置条件判断或批量操作等方式来减少数据库访问
  • 减少扇出比:控制扇出比的目的是限制系统向其他服务或组件发出的请求数量,从而降低负载,减少扇出比的本质是减少服务间的依赖关系及依赖度,避免大规模的并发请求导致性能下降,也可以降低故障传播的风险。扇出比可用于度量系统向其他服务或组件发送的请求数量,一般我们还可以进一步细化为单次请求中对指定服务、缓存的扇出比,比如下单请求如果会查询两次商品中心的接口,那下单请求对商品中心的扇出比就是2,很显然,这个数值越大,请求被放大的倍数也就越高
  • I/O优化:可以通过将多次I/O操作进行合并或者使用异步I/O来减少磁盘和网络I/O操作,常见的如异步打印日志、将多次服务调用合并成一次调用等等
  • 资源池化:使用资源池来管理数据库连接、异步线程等资源,以减少资源创建和销毁带来的开销
  • 预热:预热是一种通过在应用程序或系统开始处理实际工作负载之前执行一系列操作来提高性能的方法,这些操作旨在将系统的各种组件(如CPU、内存、缓存等)置于一个稳定且高效的状态,以便在处理真实工作负载时获得更好的性能,常见的预热操作包括缓存预热、连接池预热、资源加载预热、数据加载预热等

这里以爆款抢购场景下单接口性能优化为例介绍下上述策略的具体应用:

  • 定制独立下单链路:爆款抢购场景是一种特殊的云购下单场景,有更多的限制条件(如不能加购),因此,我们可以通过裁剪掉一些不必要的流程或者牺牲部分代码可读性以换取更高的性能
  • 下单请求幂等性控制:在爆款抢购场景,由于并发量显著增加,响应时间也会有所增加,甚至会出现响应超时的情况,很容易引发用户连续点击,通过引入幂等性控制,不仅可以降低资源占用时间,也可以减少不必要的库存锁定、扩大销售机会
  • 控制事务的粒度:通过编程式事务(TransactionTemplate)替代声明式事务(@Transactional),可以更灵活地控制事务的范围,在事务中只保留必要的操作,避免大事务,这将显著减少事务的锁定时间和资源占用,带来性能提升
  • 优化分布式事务:为了保证下单阶段订单、库存、资产、权益(如限购)等数据的一致性,我们引入了TCC(Try-Confirm/Cancel)模式的分布式事务,但分布式事务很容易对性能产生负面影响,需要进行调优(如下图所示):
分布式事务
  • 幂等性控制:主事务在调用TCC方法时可能因网络拥堵等原因超时,通常我们会通过引入超时与重试策略来提升成功率,这就要求分支事务需要保证TCC方法的幂等性,避免重复更新。另外需要特别重视优化TCC方法的执行性能,确保在预设的压力下,有足够大比例的请求RT低于超时时间
  • 允许空回滚:在实际的生产环境中,可能因网络拥堵等原因造成Cancel操作先于Try操作到达,因此,允许分支事务空回滚可以避免重试,从而带来更好的性能表现,即允许Cancel操作在找不到待回滚的业务主键的情况下也返回成功并将该业务主键记录下来,同时也要确保空回滚不会产生其他错误效果
  • 防悬挂:如果Cancel操作先于Try操作到达且Cancel操作返回成功的情况下(即允许空回滚),在执行Try操作时,有必要检查空回滚记录中是否存在该业务主键,存在则直接拒绝执行,否则(没有拒绝执行的话)会造成该分支事务悬挂
  • 减少无效回滚:Try操作可能遇到限流、校验不通过等情况造成直接返回,没有执行实际的业务操作,在这类场景,可以在回滚阶段不调用分支事务的Cancel操作,从而带来更好的性能表现

限购优化:为了增加公平性,爆款商品一般会进行限购,即限制同一个用户在某个特定周期内或者某个特定活动中购买同一个商品的数量上限,通常我们需要借助加锁等并发控制手段来确保限购数据的一致性,不同的实现方式对于最终的性能表现有较大的影响,可以进行如下优化:

  • 分布式锁:通过在下单参数校验阶段增加分布式锁,确保同一个用户无法连续下单购买同一个商品,结合上文提到的下单请求的幂等性控制,可以杜绝同一个账号通过多开或者连续点击等方式增大抢购成功率;同时,采用在参数校验阶段加分布式锁相比于限购检查阶段加锁(无论是数据库锁还是分布式锁)消耗的资源更少,性能表现更优
  • 缓存:采用分布式缓存(Redis)记录限购权益消耗情况,限购检查通过缓存替代数据库查询
  • 前置校验:结合业务流程,前置拦截不满足限购要求的请求,比如在商详、组单等阶段进行限购检查,相比于下单阶段检查消耗的资源更少,性能表现更优
  • 订单批量操作:「i茅台」采用的是典型的主子订单结构,主订单又叫支付订单,由1~N个子订单构成,子订单一般采用店铺(门店)粒度,这种方式可以比较容易地实现合并支付以及店铺(门店)粒度的实时分账。在下单阶段,假设用户同时支付N个店铺(门店)的商品,仅订单落库这个环节就需要1+N次DB操作(不含订单商品和订单地址落库),通过优化业务流程,只需要1次DB操作就可以达到相同的效果,从而带来性能提升
订单批量操作

由于爆款抢购都是单商品立即购买,因此,合并插入各个子订单的订单商品及订单地址不会提升性能,反而会增加不必要的代码复杂度

  • 连接池及预热:通过连接池来管理数据库及Redis连接,根据应用并发度及DB负载情况分析连接池大小并设置合理的初始化数量,对性能改善有较大帮助,在此基础上,预热也可以让系统可以更快地进入到最佳状态,比如提前将商品信息加载到本地缓存和分布式缓存、利用就绪探针(如SpringBoot Actuator提供的readiness)提前预热每个服务实例(尤其是关键链路上的实例)以及系统发布后或者关键事件到来之前启动小流量预热等等

通过采用上述优化策略,我们在不增加服务器的前提下,爆款抢购场景的整体吞吐量超过普通云购场景的两倍以上,相比与早期交付版本更是提升了三倍以上。

2.2.2 数据库优化

数据库对系统性能也有着非常重要的影响,常见的数据库优化策略包括:

  • 数据模型设计优化:数据模型设计是技术实现方案的重要组成部分,它不仅直接影响数据库的性能,也会影响数据库的可扩展性和可维护性,通常需要考虑以下两个方面:
  • 选择合适的设计范式
  • 规范化(Normalization)可以减少数据冗余,但可能需要更多的联表操作
  • 反规范化(Denormalization)可以通过增加冗余列、派生列、合并表等策略最大程度避免联表操作或函数计算,提高查询性能,但会增加数据冗余,比较典型的反规范化设计是订单表,通过额外冗余门店、经销商等常用但变更频率很低的信息,可以提升订单查询的效率
  • 适当的数据类型和长度:根据业务需求选择适当的类型和长度来存储数据可以减少存储空间,也可以提升查询性能,如谨慎使用大数据类型(TEXT,CLOB,BLOB等),使用整数而不是字符串存储布尔值,避免存储null值等
  • 索引优化: 数据库索引可以显著提升查询性能,需要合理创建并维护索引,包括考虑哪些列以怎样的次序组合索引、选择适当的索引类型以及定期重建或重新组织索引等;同时,我们也需要了解索引匹配的基本原则,变更前对SQL与索引进行审查,避免最终使用的索引不符合预期(可以借助慢查询日志、explain等工具进行分析调优),需要注意的是,索引也并不是越多越好,它会占用额外的空间,会影响更新操作的性能
  • 查询优化:编写高效的SQL查询语句,包括尽可能避免使用SELECT *、避免全表扫描、谨慎使用联表查询和子查询(我们在业务代码中禁止使用)、限制查询返回的数据量等
  • 缓存:选择合适的缓存组件和缓存策略来存储频繁访问的数据(参见「无处不在的缓存」章节)以减少对数据库的访问,从而降低数据库负载,提升响应速度
  • 读写分离:在一些数据库负载比较高的业务中,可以将读取操作与写入操作进行分离,分别路由到不同的数据库服务器或数据库副本,从而降低主库(写库)的负载,提升响应速度
  • 分库分表:对于数据规模非常大的数据库,可以借助分库分表技术减少单库的负载,从而提升数据库的性能、可伸缩性和容错性

这里以爆款抢购场景库存服务的性能优化为例介绍下这些策略的具体应用:

  • 数据模型设计:结合商城的业务场景,存在数千家店铺(门店)投放同一个商品的情况(即多门店共用同一个商品),也存在商品独家销售的情况(即商品只在同一家店铺销售),每家店铺(门店)的销售库存需要单独管理,基于此,我们为库存服务设计了两张核心表,即库存表和库存流水表(如图所示)
  • 反规范化:库存表新建唯一索引(ShopId, SkuId),库存流水表则冗余字段ShopId和SkuId,新建唯一索引(ShopId, SkuId, OrderId, Type)
库存相关的查询
  • 选择的数据类型:以库存流水表中的Type字段为例,用来表示库存扣减、回滚、投放、回收等状态,显然用tinyint就足够进行存储了,相比于smallint、int类型可以节省存储空间
  • 数据库选型:库存流水表预计每年新产生的数据在千万级且会持续增长,而库存服务又是交易链路的核心依赖,读写操作频繁且有明显业务峰值,如果用单个MySQL实例去支持,存储和性能瓶颈较为明显,因此我们采用了分库分表的技术(网易自研的DDB
  • 负载均衡:均衡字段(拆分键)和均衡策略的选择对于性能有非常明显的影响,需要格外重视,在库存服务这个例子中,库存表和库存流水表我们都使用ShopId作为均衡字段且使用相同的均衡策略,主要有以下几点考虑:
  • 库存相关的查询、变更都会指定ShopId和SkuId,在有数千家门店投放同一个爆款商品的情况下,采用ShopId作为均衡字段可以使数据分布和流量分布更为均衡,库存表采用与库存流水表相同的均衡字段和均衡策略,可以避免XA事务,减少锁竞争
  • 分布式ID:基于美团的分布式Id算法Leaf的统一ID生成服务在高并发场景具有低延迟、高吞吐、高可用、支持水平扩展的特点,同时也可以满足业务上自定义的需求,我们将它作为分布式ID生成的解决方案
  • 定期归档:数据规模的持续增长会对性能带来负面影响,可以考虑将早期的库存流水信息迁移到归档表,可以提升数据库性能

2.2.3 无处不在的缓存

缓存技术是一种被广泛应用于计算机系统和应用程序中的性能优化方法,它通过将数据或计算结果暂时存储在高速存储介质中,使系统可以快速响应请求、返回数据,而无需每次都从慢速存储介质(如磁盘或远程服务器等)中获取数据。

利用好缓存技术可以降低资源负载,减少对数据库、网络或后端应用等外部资源的依赖,显著提升系统的性能与可用性。可以说,在我们现有的系统架构中,缓存几乎无处不在,以申购场景为例:

  • 客户端(APP):客户端通过缓存预置在APP的数据、缓存数据请求响应等方式减少对服务端的频繁访问,提供更为流畅的用户体验,尤其有助于改善首次访问或不稳定网络环境下的访问体验
  • 静态资源访问加速:静态资源访问加速的核心在于就近访问,我们可以通过静态化技术将动态生成的内容或网页转换成静态文件并存储到CDN(Content Delivery Network,内容分发网络)或LB(Load Balancer,负载均衡),在用户请求时离用户更近的节点可以直接提供这些静态文件,而不必再次请求服务端进行动态生成,因此,可以加快页面加载速度、提升网站或应用程序的性能、减少服务器负载并降低对服务器资源的需求。通常静态化技术可以应用于不经常变更的内容、访问量较大的页面、需要SEO优化的页面、静态资源等
CDN
  • 服务端:借助缓存可以减少当前应用对数据库或者其他后端服务的访问,通常我们可以直接使用分布式缓存(如Redis),但在高并发场景(如申购场景),为了增加系统整体的吞吐量,也可以考虑将热点数据设计成二级缓存,即同时使用本地缓存(Local Cache)和远端缓存(Remote Cache,或者叫分布式缓存)
服务端

虽然缓存技术可以显著提升性能,但同时也极大提升了系统设计的复杂度,需要考虑缓存的一致性、失效策略和缓存维护等关键问题,如果没有处理好,很容易发生缓存数据不一致、缓存大面积穿透甚至引发雪崩。

以申购场景使用的二级缓存为例,我们需要处理好以下问题:

  • 技术选型:本地缓存采用Caffeine,远端缓存采用Redis Cluster
  • Caffeine:相比于EhCache、Guava等主流的缓存框架,拥有更加强大的性能表现,使用方式上与Guava类似,非常方便
  • Redis Cluster:Redis Cluster是Redis提供的分布式缓存解决方案,也是目前主流的解决方案,相比于Proxy模式有更好的性能表现,但我们仍然需要重点关注硬件以及混部等因素对于性能及稳定性的影响
  • 避免缓存穿透:传统的关系型数据库对于并发的承受能力非常脆弱,如果我们设计的缓存命中率不高出现大面积缓存穿透,很有可能将数据库拖垮,如何避免缓存穿透是我们设计缓存时需要重点考虑的问题:
  • 热点缓存采用预加载、定时刷新及事件触发刷新(如数据变更)的策略,保证100%命中率
  • 当查询的结果为空时,仍然将空结果(自定义NullObject)存储到缓存中(可以设置一个较短的过期时间),这样可以防止恶意请求的连续查询
  • 消息队列与异步化
    异步化的核心思想是将耗时的操作从主流程中分离出来,以允许应用程序在等待这些操作完成的同时继续执行其他任务而不会被阻塞,从而改善系统的响应性和资源利用率。

异步化通常可以通过多线程、多进程、事件驱动或异步编程模型等方式来实现,有很多中间件和框架可供我们选择,比如通过DisruptorBlockingQueue等技术将任务分解为多个线程或进程以充分利用多核处理器的性能,通过消息队列(MQ)实现异步消息通信和服务解耦,达到对流量进行削峰填谷的效果,提升系统的可扩展性和性能。

这里重点提一下消息队列在「i茅台」的应用,无论在申购场景还是在爆款抢购场景(如下图所示),我们都需要借助消息队列实现对洪峰流量的削峰填谷,避免服务器过载或系统宕机,同时也可以实现数据的最终一致性,确保消息处理的结果与业务逻辑的一致性。

场景

不难发现,这其中的关键挑战在于消息队列本身的高性能以及在设计上如何确保数据的最终一致性。

先来看消息队列的选型,在严选,因为历史原因同时存在Kafka、Rabbitmq和RocketMQ,但综合考虑性能和稳定性表现(高吞吐量、低延迟、高可用)、功能特性丰富度、工具支持丰富度、社区活跃度等维度,RocketMQ最终成为业务系统消息队列的主流选型,「i茅台」则延续了这一选型,采用主从部署模式(4.8版本之前主从模式相对Dledger模式在性能上更有优势)。

接下来我们看一下如何确保数据的最终一致性。

先介绍四种我们平时开发过程中比较容易出错的实现方法(如下图所示):

四个方案
  • 方案一:先执行数据库操作再发送消息到MQ,可能会出现数据库操作成功,消息发送失败的情况
  • 方案二:先发送消息到MQ再执行数据库操作,可能出现消息发送成功,数据库操作失败的情况
  • 方案三:在方案一的基础上,开启数据库事务,这个方案在消息发送失败抛出异常的情况下可以正常回滚,但有可能会出现消息发布至MQ成功但请求失败的情况(如网络拥堵等原因响应超时),这种情况也会引发事务整体回滚
  • 方案四:在方案二的基础上,开启数据库事务,这个方案如果数据库操作失败需要回滚,但MQ已经发生成功,没有办法回滚

可见,上述四个方案都有可能出现数据库操作状态和消息发送状态不一致的情况,其中方案一可能出现数据库操作成功、消息发送失败,这类异常可以通过引入消息补偿机制来确保消息最终成功投递;方案二、三、四可能出现消息发送成功、数据库操作失败,这类异常可以通过在消息消费端增加消息状态确认或者类似的校验机制,确保被投递出去的消息不会对业务产生负面影响。

最后介绍两种比较常用的正确实现方法:

  • 方案五:基于RocketMQ的事务消息来实现
基于RocketMQ的事务消息来实现
  • 步骤一:消息生产者向RocketMQ发送半事务消息(1. Prepare),RocketMQ确认消息接收状态
  • 步骤二:RocketMQ消息接收成功,消息生产者执行本地事务的业务逻辑
  • 步骤三:消息生产者根据本地事务的执行结果向RocketMQ提交二次确认(2. Commit/Rollback),RocketMQ将步骤一中收到的半事务消息标记为可投递(消费者就可以消费到这个消息)
    如果因断网或者应用重启等原因,二次确认(2. Commit/Rollback)没有成功提交,RocketMQ会定时触发事务消息回查,确认是否需要投递(兜底策略),无需投递的消息会在过期后删除
  • 步骤四:RocketMQ将消息投递给消息消费者(3. 投递消息),消息消费者首先需要进行幂等性检查,通过检查后执行本地事务的业务逻辑,最后返回执行结果(4. Ack)
  • 方案六:基于消息补偿机制来实现
基于消息补偿机制来实现
  • 步骤一:在同一个本地事务中执行业务逻辑中的数据库操作和新增消息补偿记录(1. Prepare: 新增记录)
  • 步骤二:本地事务提交后,启动异步线程,向RocketMQ发送消息(2. 发送消息),消息发送成功后删除消息补偿记录(3. Confirm: 删除记录)
    如果因断网或者应用重启等原因,发送消息失败或者未成功删除消息补偿记录,消息生产者会定时触发消息补偿,确保发送到RocketMQ的消息至少发送一次(at least once策略,MQ有可能存在多条相同的消息)
  • 步骤三:RocketMQ将消息投递给消息消费者(4. 投递消息),消息消费者首先需要进行幂等性检查(避免重复执行同一个消息),通过检查后执行本地事务的业务逻辑,最后返回执行结果(5. Ack)

结合业务实际情况,由于RocketMQ的事务消息相比于普通消息性能上还是有不小的损失,无法完全满足我们的性能要求(服务器规模不变的前提下),因此我们最终选择了实现上更加复杂的方案六。

2.2.4 硬件升级及资源优化

硬件的性能和资源利用率同样也是我们性能优化过程中需要关注的地方,如果我们把不同的应用比喻成军队中不同的兵种,那么硬件就给不同兵种配置的装备,只有合理搭配,这些装备才能最大程度提升各兵种的战斗力,而合理搭配装备的底层逻辑,是对资源的最大化利用、避免浪费。

事实上,资源筹备工作往往是先于产品开发工作开展的,因此,我们的性能优化工作在绝大部分时候是在资源不变的前提下进行的。为了利用好这些资源,通常我们需要解决以下三个问题:

  1. 资源筹备阶段:如何准确地预估需要的资源?

  2. 应用架构:从已知的业务信息中分离出不变的部分和变化的部分,输出全局应用架构和系统应用架构(包含应用及其依赖项),一般而言,中后台应用不变的部分更多容易预估,前台应用不确定性高也更难预估,但随着需求逐渐明朗(逐步进入产品研发阶段、产品运营阶段后),加上有更多的测试数据,预估也会越来越准确,因此,应用架构应保持持续演进以更好地厘清依赖关系及资源需求

  3. 部署架构及资源清单:将应用架构映射成部署架构是资源预估的重要步骤,这个阶段一般还没有办法输出完整的流量模型,但架构师可以借助业务预判(如什么样的业务形态、多少用户参与等)拆解出核心域的前端入口流量(如申购流量、交易流量),各个域再逐层拆解到各个应用,最终映射出资源清单(也就是第一版资源清单),不难想象,要提升这个阶段资源预估准确性非常困难,需要业务方、开发团队、运维团队紧密协同,业务输入越充分预估会越准确,应用架构设计越完整预估会越准确

产品研发阶段:如何结合应用特性合理地搭配和使用资源?

通常SRE会定义出不同的资源规格供各类应用选择:

  • CPU性能:CPU性能对于计算密集型任务非常重要,应用如果需要大量计算,需要配置高性能的CPU
  • 内存容量:内存容量对于数据密集型应用和需要缓存大量数据的应用至关重要,足够的内存可以减少对磁盘或网络的访问,提高性能
  • 存储:存储配置取决于数据量和性能需求,使用高性能固态硬盘(SSD)可以提高数据读写速度
  • 网络带宽: 如果应用需要大量的数据传输,网络带宽是一个瓶颈,一般负载均衡设备、应用网关、CDN需要重点考虑
  • 容量规划与流量模型设计:这个阶段需求已经非常明确,可以基于场景及最新的应用架构进行更为细致的流量拆解,评估出每个系统(尤其是核心系统)的容量要求及资源清单,并通过压测进行验证,确保系统在各种负载情况下都能够正常运行
  • 产品运营阶段:如何最大化地利用好现有的资源?
  • 资源超售或混部:一般可以通过云厂商提供的资源超售能力或者研发团队主动发起应用混部来提高资源利用率,然而这却是把双刃剑,更高的资源利用率也就意味着更低的资源容错率,一旦出现预期之外的流量或者资源占用,很容易成为压垮系统的最后一根稻草
  • 硬件升级及资源优化:通过诊断工具识别在压测或产品运营过程中出现的资源异常,如CPU使用率或Load不均衡、I/O延迟大、内存使用率高等特征

2.3 小结

性能优化是产品设计、研发和运营过程中的一个极其重要的环节,通过性能优化可以确保我们的系统满足性能设计目标。通常我们需要先借助性能诊断工具识别性能瓶颈,然后结合实际情况综合选择一种或者多种性能优化策略。

但需要特别注意的是,过早的优化可能会引入不必要的代码复杂性而性能却未必改善,因此,我们建议在系统开发的早期阶段应更侧重于保持代码的可维护性和可读性,随着系统的持续演进,再基于性能诊断结果对代码进行针对性地优化以满足性能需求。

3 实现高可用的主要策略

高性能并不意味着高可用,有些提升性能的手段会增加系统负载,反而还会降低可用性,而为了实现系统的高可用,我们通常需要引入一些复杂性或冗余,可能还会对性能产生一定负面影响。

本章节会重点探讨一下在系统架构和设计阶段如何考虑可用性目标。

3.1 面向失败的设计

任何服务和组件都不是100%可靠的,因此核心系统的设计建议面向失败进行设计,即确保在部分组件或服务故障时仍然能够继续提供服务,比如前文提到的申购场景和爆款抢购场景都使用了这一设计理念。

这里我们总结下几种常用的策略:

  • 最小化依赖:要减少对外部服务和组件的依赖,特别是减少强依赖,从而降低故障传播的风险
  • 冗余和备份:通过引入冗余组件或备份系统,在主要组件故障时可以无缝切换到备用组件,从而确保服务的连续性,需要特别重视的是,关键组件要避免单点
  • 自动故障检测和恢复:使系统能够主动检测故障或异常情况,并采取自动化措施以恢复正常运行,通过这种方式可以减少对人工干预的依赖,从而更快地响应问题,降低服务中断的风险
  • 超时与重试:在网络通信中,设置适当的超时时间,以防止请求挂起;使用重试机制确保请求的可靠性;结合合理的退避策略,避免过度重试导致服务器负载过高
  • 限流与降级:实施限流策略,控制请求流量以防止系统过载,在高负载或故障时降级部分功能,保持核心功能的可用性
  • 监控与报警:建立监控系统,实时追踪系统性能和健康状态,设置报警规则以在问题发生时及时通知运维团队采取行动
  • 应急预案:制定应急预案,在系统故障或紧急情况下快速采取行动以达到最大限度保护系统的目的,包括降低系统负载、故障止血与恢复、数据备份等等

3.2 微服务架构

微服务架构是一种典型的容错架构,由于「i茅台」商城在设计阶段已经明确了业务模式和流量挑战,因此我们没有像严选早期那样采用单体架构快速上线,而是直接采用微服务架构进行设计,基础设施则复用了严选的Service Mesh架构(参见网易严选ServiceMesh实践),相比于单体架构在以下几个方面具有明显优势:

  • 故障隔离:微服务架构将一个应用程序被拆分成一组小型、独立的服务,每个服务都专注于执行特定的业务功能,当一个服务发生故障时,不会影响其他服务的正常运行,从而减小了故障的传播范围,提高了整体系统的可用性
  • 水平扩展:微服务架构使得每个服务可以独立地进行水平扩展,可以根据需求增加或减少服务实例的数量,以满足不同的负载要求,从而提高了系统的弹性和可用性
  • 快速故障恢复:微服务架构具备自动故障检测和切换机制,系统能够在检测到故障时自动将流量切换到其他服务实例,从而实现快速故障恢复,减少了服务中断的时间
  • 分布式部署:微服务架构支持分布式部署,服务可以部署在不同的服务器上(甚至可以跨数据中心进行部署),这提供了更高级别的容错性,避免单点故障
  • 灵活的更新和维护:由于每个服务都是独立的,可以单独更新和维护,而不会影响整个系统的可用性,这降低了维护和更新过程中的风险,减少了系统的停机时间

有了基础架构提供的服务治理能力加持,开发需要在系统设计和开发阶段重点关注以下几点方面:

  • 服务分级:服务分级是服务关联的一个标签,可以区分出每个服务对于业务影响的重要程度,我们认为每个服务都应该有对应的分级标签,它可以让我们更清晰地了解服务的可用性目标以及服务之间的依赖关系是否合理

通常更高等级的服务应该匹配更高等级的服务保障,也应该具备更高的可用性,因此,要避免高等级的服务强依赖低等级的服务,否则容易造成高等级的服务无法达到既定的可用性目标

  • 服务依赖:由于微服务架构会将服务拆分成更小的单元,这就不可避免地增加了服务之间的依赖关系,通常我们可以根据对故障的容忍度将依赖关系区分为强依赖和弱依赖,在设计上建议遵循以下原则:
  • 强依赖弱化:强依赖的服务应当尽量减少或减弱,以降低整个系统中某个服务的故障对其他服务的影响
  • 弱依赖异步化:弱依赖的服务可以采用异步通信方式(如消息队列),以降低对依赖服务的直接调用,提高系统的弹性和响应性
  • 超时治理:超时治理是通过优化超时时间与重试策略,使尽可能多的请求能够在预期时间内得到正常响应,提高系统的响应性,是一种重要的服务治理手段

超时时间的设置应充分考虑业务本身的复杂性和预期响应时间,应设置足够长以容忍正常的响应延迟,但也不能过长避免无限期挂起等待

响应超时并不一定意味着目标服务已经不能工作,通过合理的重试机制,即使在目标服务器或网络故障的情况下也能够成功完成请求,但要确保请求的幂等性,避免重试请求对业务产生负面影响

设计合理的退避策略(如指数退避),避免连续重试导致服务器负载过高,另外,重试次数也应该有限,避免无限重试

在放弃请求后,系统可能采用降级策略,提供有限的服务,以确保核心功能的可用性

  • 限流策略:限流是一种通过控制请求流量以防止系统过载的策略,也是一种非常重要的服务治理手段

限流会带来一定的性能损耗,我们借助应用网关与限流中间件实现对流入网关及业务系统的流量进行限制,各个系统需结合服务等级、预估流量及应用当前能力选择是否开启

限流值的设置应充分考虑应用自身的能力,由于系统演进过程中的熵增是一种不可避免的趋势,建议限流值设置时保持一定的余量,以最大限度为系统提供有效保护

限流策略上可以为每个服务或接口设置最大请求速率,也可以进一步基于时间段、用户、IP地址等因素进行细化

  • 降级策略:降级策略是一种应对系统负载过高或故障的策略,通过牺牲非关键功能以保持核心功能的正常运行
  • 主动降级:系统在监测到一定条件或预设的规则触发下,自动执行降级策略,包括自动关闭非关键功能、拒绝某些请求、减少资源分配等
  • 手动降级:运维人员或开发人员手动介入执行降级策略以应对特定的问题或异常情况,通常都是应对已知的问题或紧急情况,基于预案进行操作
  • 熔断策略:当一个服务在一段时间内出现连续的失败,熔断策略会中断对该服务的请求,避免因频繁请求失败而导致的资源浪费

3.3 客户端(APP)容错设计

客户端(APP)作为用户获得产品服务的主要入口,也大量使用了容错设计。

容错设计可以提升客户端整体的鲁棒性和可用性,以更好地应对服务端或网络等各种故障和异常情况,确保客户端在面临这些问题时仍然能够提供有限的服务,保持良好的用户体验。因此,客户端的容错设计往往也和产品策略息息相关,而不同的产品策略最终也会影响技术方案的选择,比如:

  1. 首页无论在出现哪类异常都不应该挂掉,那么,我们在设计阶段就应该充分考虑请求失败等异常情况下如何进行兜底展示
  2. 大流量场景可能遇到限流等异常,可以通过设计等待页面或者排队动画避免流程中断(比如操作了一半进入错误页面)、减轻用户等待的焦虑感
  3. 随着版本的持续迭代,旧版本APP的用户是否还能正常使用产品服务

设计明确的前后端交互协议有助于更好地解决上述问题:

  • 错误处理和容错机制:通过规定统一的错误码和错误信息传递方式,使APP能够捕获和处理各种错误情况,包括网络错误、服务端错误、数据格式错误等,这有助于客户端实现统一的错误处理方法以应对各种异常情况
  • 版本兼容性:通过版本控制,可以确保在服务端升级或修改接口时不会影响现有的APP版本,保持兼容性;也可以通过版本控制,提醒用户或者强制用户升级到最新版本的APP
  • 通信安全:通过定义加密和认证规范,以确保通信的安全性,这有助于防止数据泄露和中间人攻击,提高系统的可靠性和安全性

3.4 全链路压测

类似「i茅台」这样大型的电商系统,具有业务场景复杂、核心链路长的特点,通过传统的测试方法在测试环境进行压测,已经难以确保系统在高负载和极端场景下的性能、可用性和稳定性了,因此,我们需要引入全链路压测。

全链路压测是在生产环境中基于真实的业务场景模拟用户操作和流量,以对整条业务链路进行压力测试,从而识别潜在的性能瓶颈或异常,有助于我们及时发现并解决,确保系统的高性能、高可用和稳定性。

要实现生产环境全链路可压测,除了前文提到的APM、日志平台等监控诊断工具之外,还需要具备以下能力:

  • 全链路流量标记透传能力:通过全链路支持压测流量标记识别和传递,使线上全链路能区分压测流量和真实用户流量,保证在大流量压力测试时不会对真实用户体验以及真实用户数据造成影响,解决了因环境差异造成压测结果不真实,可以充分验证线上服务在大流量下的承载能力
  • 数据存储路由:使数据库、缓存、MQ、日志等存储介质可以识别压测流量,将压测产生的数据与真实用户数据分开保存,实现存储隔离,为线上压测提供数据安全保障
  • 压测平台:提供分布式高并发压测能力,支持上千台压测机同时发出压测流量、模拟千万级用户访问,支持大流量下的压测结果分析

全链路压测要求技术团队进行更高效的协同,因此,除了工具和能力层面的支持,团队的成熟度也是影响这项工作能否顺利开展的关键因素:

  • 明确核心链路:需要共同明确哪些是需要重点保障的场景,并通过业务梳理明确每个场景对应的核心链路
  • 容量规划与流量模型设计:结合产品运营计划,对每个场景的流量进行预估与拆解,明确核心链路上各个系统的性能指标、容量指标、SLA以及各自的强弱依赖(包括第三方服务,如各类云服务),并明确对依赖方的性能要求及降级预案
  • 限流配置及预案制定:结合各个系统的性能指标和容量指标,明确限流策略及限流值,梳理可能出现的异常场景并制定针对性的预案(如降级、熔断等)
  • 压测执行及预案演练:定期组织全链路压测并在压测前、压测中进行预案演练
  • 监控与报警:每个系统需要在各自的关键链路上设置监控和报警,以便在压测期间能实时监测性能及异常,并能够进行快速响应
  • 结果分析:分析并解释压测结果,识别预案及监控报警是否有效,识别是否存在潜在的性能问题和优化机会,最终产出压测报告
  • 改进优化:各个团队根据压测报告讨论并实施优化策略
  • 常态化基线压测及性能巡检:建立常态化机制,定期对系统进行性能测试以建立性能基线,以便了解系统在正常工作负载下的性能指标;定期监测和评估系统的性能,以便及早发现和解决性能问题

3.5 小结

要实现系统的高可用性,我们需要在系统架构和设计阶段充分考虑各种故障和异常场景,包括硬件故障、网络中断、软件错误等,通过减少依赖、引入冗余和备份、实现自动故障检测与恢复、增加限流及应急预案、引入监控报警机制等策略来减轻突发流量或故障对系统产生的影响,确保在部分组件或服务故障时系统仍然能够继续提供服务,同时我们也必要借助全链路压测等手段识别潜在的问题以确保上述策略是持续有效的。

4 总结

「i茅台」商城作为茅台酒线上销售的主要入口,从诞生的第一天开始就需要考虑如何有效地应对大流量高并发的考验,这要求我们在保证数据一致性的前提下,实现系统的高性能和高可用,其中高性能意味着更快的响应时间和更大的吞吐量,可以同时为更多的用户提供流畅的服务,而高可用则意味着系统需要在面临故障或异常情况时仍然保持可用性。

为了实现系统的高性能和高可用,我们需要结合业务诉求、产品运营情况及系统现状进行分析,综合选取一些性能优化技术与高可用系统的设计方法,使系统在性能、可用性、安全性、可维护性等方面保持一种最佳的平衡状态。

本质上,系统的架构和设计就是权衡和取舍的过程,权衡性能与可用性、成本与性能、安全性与便利性、扩展性与复杂性,不同的系统可能需要不同的权衡,本文希望通过这些实际的案例帮助大家更好地理解这些技术和策略以及背后的权衡过程,从而可以更好地应用在我们日常的设计与开发过程中。

网易牛马日志-week8

System.out.println(“I am back”);

需求10:俄罗斯印度公司的后端改造

背景

为了能尽快丰富页面实现seo,优先开放数据组刚挖掘出来的俄罗斯印度公司详细信息,当存量不足的时候再去按照原有逻辑开放普通公司。此外为了优化全球搜变动导致的页面失效,需要在出海项目的es里面保存一份副本,否则会影响谷歌收录。

改进结构

后端架构

分为两个方面取数据,全球搜和扩展es,扩展es洗了一些俄罗斯印度公司的拓展信息用于展示,与全球搜主键id关联,从而可以组装起来作为一个更大的bo。但是如果当公司开放之后,全球搜es有变动导致无法访问就会严重影响谷歌收录。所以需要更改保存逻辑,直接在每日新增中执行组装,如果此时全球搜有那就保留,且写回扩展索引中。如果没有就跳过,这样以来所有的信息全部都持久化到扩展es中,用公式写就是A(全球搜)+B(扩展)->AB(组装后的大对象)->写回扩展es。

实现

  • sourceBuilder构造:查询指定国家,根据质量分数倒序排序,但是这样顺序开放会导致重复扫描的越来越多,后续一个小时有可能都跑不完了。
  • 查询开放公司的最大dataId,然后追加dataId,得到原始输入List。
  • 接下来要一个个去请求全球搜去组装一个更大的对象,如果返回有问题就过滤掉不入库,返回无问题就直接upsert拓展索引。
  • 最后作为开放公司写入数据库
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
/**
* 【俄罗斯、印度】extendIndex组合全球搜base信息重新写入
* @param country
* @param param
* @return
*/
@Override
public int upsertExtendDetailToDbAndEs(String country, String param) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.termQuery("standardCountry", country));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(boolQuery)
.fetchSource(new String[]{"companyId","name"}, null)
.sort("dataQualityScore",SortOrder.DESC)
.size(Integer.parseInt(param));
//从es中抽取指定数量的companyList
List<SiteMapCompanyEntity> originalCompanyList = loadCompanyToExtendIndex(companyExtendIndex, sourceBuilder, param);
//过滤全球搜不开放的公司
List<SiteMapCompanyEntity> filterCompanyList = originalCompanyList.stream().filter(this::filterAndUpsertCompanyExtendIndex).collect(Collectors.toList());
//保存进db
saveBatch(filterCompanyList);
log.info("upsertExtendDetailToDbAndEs success record {}",filterCompanyList.size());
return filterCompanyList.size();
}
/**
* 【其他国家】extendIndex组合全球搜base信息重新写入
* @param param 补齐条数
*/
@Override
public void upsertNormalDetailToDbAndEs(String param) {
// 有域名的排前面(强制排序)
Script hasDomainScript = new Script("if(doc['domainCount'].size()>0 && doc['domainCount'].value>=1) return 1; else return 0");
// 域名无效的后置(强制排序)
Script invalidDomainScript = new Script("if(doc['domainStatus'].size()>0 && doc['domainStatus'].value>0) return 0; else return 1");
// 有邮箱前置
Script hasEmailScript = new Script("if(doc['emailCount'].size()>0 && doc['emailCount'].value>=1) return 1; else return 0");
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.existsQuery("overviewDescription"));
boolQuery.must(QueryBuilders.existsQuery("name"));
boolQuery.must(QueryBuilders.existsQuery("companyId"));
boolQuery.mustNot(QueryBuilders.termQuery("disable", "true"));
boolQuery.must(QueryBuilders.wildcardQuery("overviewDescription", "*"));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(boolQuery) // 查询参数
.fetchSource(new String[]{"name","companyId"},
null)
.size(1000)
// 有无域名排序
.sort(SortBuilders.scriptSort(hasDomainScript, ScriptSortBuilder.ScriptSortType.NUMBER).order(SortOrder.DESC))
// 无效域名排序
.sort(SortBuilders.scriptSort(invalidDomainScript, ScriptSortBuilder.ScriptSortType.NUMBER).order(SortOrder.DESC))
// 有邮箱前置
.sort(SortBuilders.scriptSort(hasEmailScript, ScriptSortBuilder.ScriptSortType.NUMBER).order(SortOrder.DESC));
//从es中抽取指定数量的companyList
List<SiteMapCompanyEntity> originalCompanyList = loadCompanyToExtendIndex(openCompanyIndex, sourceBuilder, param);
//过滤全球搜不开放的公司
List<SiteMapCompanyEntity> filterCompanyList = originalCompanyList.stream().filter(this::filterAndUpsertCompanyExtendIndex).collect(Collectors.toList());
//保存进db
saveBatch(filterCompanyList);
log.info("upsertNormalDetailToDbAndEs success record {}",filterCompanyList.size());
}

查询索引并转换为数据库逻辑,输出是实际新增开放公司,小循环用set去重,大循环直接去数据库count>0去重。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 通用新增开放公司逻辑
* @param abstractIndex es索引
* @param sourceBuilder es查询条件
* @param param 查询数量
* @return 实际新增开放公司List
* @param <T>
*/
public <T> List<SiteMapCompanyEntity> loadCompanyToExtendIndex(AbstractIndex<T> abstractIndex,SearchSourceBuilder sourceBuilder,String param){
SiteMapCompanyEntity maxCompany = getMaxDataId();
long maxDataId = maxCompany == null ? 1 : maxCompany.getDataId();
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
List<SiteMapCompanyEntity> addList = new ArrayList<>();
abstractIndex.scrollSearch(sourceBuilder, searchHits -> {
Set<String> companyIdSets = new HashSet<>();
for (SearchHit searchHit : searchHits) {
if (addList.size() >= Integer.parseInt(param)) {
return true;
}
Map<String, Object> result = searchHit.getSourceAsMap();
String name = result.get("name").toString();
String companyId = result.get("companyId").toString();
//本轮去重
if (companyIdSets.contains(companyId)) {
continue;
}
companyIdSets.add(companyId);
//全局去重
if (isOpeningCompany(companyId)){
continue;
}
//数据库对象转换,包装首字母等
SiteMapCompanyEntity siteMapCompanyEntity = convertIndexToDbEntity(name, companyId);
siteMapCompanyEntity.setDataId(maxDataId + count.longValue());
count.getAndIncrement();
addList.add(siteMapCompanyEntity);
log.info("loadCompanyToExtendIndex add record {}",JSON.toJSONString(siteMapCompanyEntity));
}
return false;
});
return addList;
}

最后在xxl-job里面定义定时任务,开两个定时任务,分别输入”Russia|1500”,”India|1500”

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
/**
* 新增公司数据
* @param param
* @return
*/
@XxlJob("addExtendToOpenDb")
public ReturnT<String> addExtendToOpenDb(String param) {
param = StringUtils.isEmpty(param) ? "1500" : param;
XxlJobLogger.log("addExtendToOpenDb job start");
try {
String[] params = param.split("\\|");
//带国家和数量
if (params.length == 2) {
int loadedSize = siteMapCompanyService.upsertExtendDetailToDbAndEs(params[0], params[1]);
int remainSize = Integer.parseInt(params[1]) - loadedSize;
if(remainSize > 0){
siteMapCompanyService.upsertNormalDetailToDbAndEs(String.valueOf(remainSize));
}
} else if (params.length == 1) {
siteMapCompanyService.upsertNormalDetailToDbAndEs(String.valueOf(params[0]));
}
} catch (Exception e) {
XxlJobLogger.log("addExtendToOpenDb error");
log.error("addExtendToOpenDb error", e);
}
XxlJobLogger.log("addExtendToOpenDb job end");
return ReturnT.SUCCESS;
}

需求11:AIGC能力——大模型总结网页

背景

前面补充的数据还是没能影响seo,可能是因为数据不够独特,同质性太强。因此需要用ai总结一下,做出差异化的内容。

提示词

1
2
3
4
5
6
7
8
9
10
The following text is taken from the official website of a company named: [bi-ehealthcare]. 
Based on the content provided, generate 6-8 self-questions and answers related to the company's information.
The question is information related to the company: the company's products, industry, services, address and contact information (telephone, email, etc.), and other points of fact mentioned in the content provided, but not limited to this.
Provide quality responses and avoid general responses such as "Unfortunately, the text does not provide..." .
Avoid interference from privacy policies and cookies, and ensure that the total length of the reply is not less than 500 characters. In particular.
Organize and translate your answers in English, as shown below:
Q1: XXXX,
A1: XXXX,
Q2: XXXX,
A2: XXXX

大模型的原理(挖个坑、后续研究)

输入的是提示词和域名,大模型会去根据域名获得html页面,然后根据python某些工具去递归挖掘子url和html,拼接token,结合RAG从而获得更大规模的输入,然后根据提示词去调用llama3.2,得到输出。

网易牛马日志-week5(学习版)

邮箱

邮箱注册

用户视角:输入邮箱和密码(有校验)->填写个人信息->收到确认邮件->点击跳转链接

输入邮箱和密码(有校验)

输入邮箱密码,前端通过正则表达式校验密码格式,后端请求/email/check来检查账号是否启用。这里启用指的是已经点击过验证邮件的或者用oauth已经校验过的。这里没校验过邮件的应该还是可以重新发起注册的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public EmailSignUpCheckResp emailSignUpCheck(EmailSignupCheckReq emailSignupCheckReq, HttpServletRequest request) {
EmailSignUpCheckResp emailSignUpCheckResp = EmailSignUpCheckResp.builder()
.emailSignUpCheckStatus(EmailSignUpCheckStatus.UN_SIGN_UP)
.build();
// 校验注册邮箱
UserEntity user = userServiceImpl.getUserEntityByLoginUsername(emailSignupCheckReq.getEmail());
if (Objects.nonNull(user)) {
if (UserState.OPEN.getCode() == user.getState()) {
emailSignUpCheckResp.setEmailSignUpCheckStatus(EmailSignUpCheckStatus.ALREADY_VERIFIED);
} else {
emailSignUpCheckResp.setEmailSignUpCheckStatus(EmailSignUpCheckStatus.ALREADY_SIGN_UP);
}
}

return emailSignUpCheckResp;
}

注册逻辑+确认邮件的回调逻辑

  1. 查询用户信息:check校验的是是否有效。这里检查是否别的方式已经注册了(通过email字段查)
  2. 检查密码强度
  3. 邀请码(暂时不需要)
  4. 保存用户信息(两张表,一张存用户实体,另一张存账号密码和盐)
  5. 埋点上报

这里最重要的是“保存用户信息”。在注册之后会入库账号密码,但是未激活(就是check检查的那个字段,只有点击了邮件链接才会接收到)。

随后生成一个UUID作为参数传给邮件,同时缓存在redis中。在邮件里面放这个链接和参数,点击的时候自动就会去请求“/verify/email?cs=”。
这个方法会解析加密参数,与redis中进行比较。校验成功后激活用户状态,最后走获取token的逻辑,那部分等讲登录的时候再说。

邮箱相关

发送的是html,这里用到了FreeMarker,模板里面只有两个需要动态换的,一个是称呼一个是对应的重定向链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, Object> templateParams = Maps.newHashMap();
templateParams.put("accountName", "test");
templateParams.put("registerLink", "https://www.bilibili.com");
Resource resource = resourceLoader.getResource("classpath:template/"+registerTemplate);
byte[] fileData = FileCopyUtils.copyToByteArray(resource.getInputStream());
String template = new String(fileData, StandardCharsets.UTF_8);
textPart.setContent(template, "text/html;charset=utf-8");
try (InputStream inputStream = new ByteArrayInputStream(template.getBytes())) {
String content = FreemarkerUtils.process(inputStream, templateParams);
System.out.println(content);
} catch (Exception e) {
throw new RuntimeException(e);
}

然后发送邮件配置就完了,这里暂时用的是gmail,最后得搭建公司邮件服务器。

邮箱登录

  1. 各种参数校验,包括上面一直在讲的激活状态。
  2. 密码校验(数据库盐是Base64,可以加密解密的): encode(用户输入+Base64Decode(数据库盐)) == 数据库加密后的密码。
  3. 校验成功则登录成功,首先解析ip到国家和省份保存。然后生成token作为session保存在redis里面,设定过期时间为7天。
  4. 将token返回给前端,每次请求都要携带。

oauth

谷歌code解析

类似微信小程序,前端先去请求谷歌登录,谷歌会返回一个code,后端拿到这个code去oauth里获得用户登录信息。拿这个信息去业务数据库里查询是否有记录来判断是注册还是登录。

如果没有实际用户说明是注册,如果有还需要判断注册方式是否是oauth,分为其他方式注册和直接登录。

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
public OAuthLoginUser getAuthUserInfo(OAuthUserInfoReq req) {
if (!oAuthRequestFactory.support().contains(req.getOAuthType().toUpperCase())) {
log.error("getAuthUserInfo, source not supported, req: {}", req);
throw new BizException("unsupported oauth type", BizError.ENUM_PARAM_ERR.getCode());
}
//拿着oauth去解析,获得用户数据
AuthRequest authRequest = oAuthRequestFactory.create(req.getOAuthType());
AuthResponse<AuthUser> authResponse = authRequest.login(AuthCallback.builder().code(req.getCode()).build());
AuthUser authUserInfo = authResponse.getData();
if (Objects.isNull(authUserInfo)) {
log.error("getAuthUserInfo, authUserInfo is null, req: {}", req);
throw new BizException(BizError.COMMON_ERR.getName(), BizError.COMMON_ERR.getCode());
}

// 缓存获取的用户信息
String accessCode = CodeUtils.generateOAuthSignupCode();
redisManager.addOAuthUser(accessCode, authUserInfo);

// 判断当前OAuth状态
OAuthType oAuthType = OAuthType.of(req.getOAuthType());
String userIdentify = authUserInfo.getUsername();
OAuthStatus oAuthStatus;
UserEntity user = userServiceImpl.getUserEntityByLoginUsername(userIdentify);
//用户实体表没有就是没注册
if (Objects.nonNull(user)) {
//查指定枚举信息的
UserAuthEntity auth = userAuthServiceImpl.getByIdentifier(userIdentify,
IdentityType.convert(oAuthType).getCode());
if (Objects.nonNull(auth)) {
// 同种类型重复注册
oAuthStatus = OAuthStatus.LOGIN;
} else {
// 已经存在其他方式注册
oAuthStatus = OAuthStatus.ANOTHER_SIGNUP_WAY;
}
} else {
oAuthStatus = OAuthStatus.SIGNUP;
}

return OAuthLoginUser.convert(authUserInfo, accessCode, oAuthStatus);
}

前端可能会根据三种枚举值来走登录、注册和提示逻辑。

oauth注册

注册逻辑基本一致,多了一个从redis中查询oauth的用户信息的过程(例如google保存的头像链接),如果没查到就是验证超时,如果查到了但是用户不匹配就抛异常。最后给token,然后删除oauth的验证信息。

oauth登录

基本一致,也是多了一个从redis里面拿accesscode的过程。

网易牛马日志-week5

写在前面:经过一个月的实习,我逐渐觉得沟通能力和理解能力远比代码能力重要。不论是在科研组会上的哑口无言,还是在工作上对接的七嘴八舌,都暴露出了我语言能力的欠缺。我从初中开始就知道这是我的短板,但没想到有这么短。这也再次给我敲响了警钟,不管你是什么学术或者技术大师,学说话和听说话是最基本的。

需求7:【出海项目】ip限流

注解+aop+lua+redis+滑动窗口限流算法

注解和aop

注解

根据多个时间单位配置限流器,定义了时间枚举类,按照毫秒存储持续时间(因为注解只能用枚举,不能用正常类)。

正常是要传3个值的:次数,时间单位,超时时间(主要是给后面对redis设置expireTime)。这里为了方便超时时间与时间单位一致。

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
//使用示例:1分钟限制10次,1小时限制500次
@IpRateLimit(limit = {10,500}, timeUnit = {TimeUnit.MINUTE,TimeUnit.HOUR})


//注解定义
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IpRateLimit {
int[] limit(); // 限流值
TimeUnit[] timeUnit(); // 时间单位
}

@Getter
@AllArgsConstructor
public enum TimeUnit {
YEAR(1, "YEAR", 1000L * 60 * 60 * 24 * 30 * 365),
MONTH(2, "MONTH", 1000L * 60 * 60 * 24 * 30),
DAY(3, "DAY", 1000 * 60 * 60 * 24),
HOUR(4, "HOUR", 1000 * 60 * 60),
MINUTE(5, "MINUTE", 1000 * 60),
SECOND(6, "SECOND", 1000),
;
private final int code;
private final String name;
//单位是毫秒
private final long periodInMills;
}

aop

校验注解里两个数组长度必须相同,对每一个配置new一个限流器来执行redis脚本。(我在想这里每次限流就new一个开销会不会有点大,但是不能用@service,因为有状态的)

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
@Slf4j
@Aspect
@Component
public class IpRateLimiterAspect {

private RedisLuaRateLimiter redisLuaRateLimiter;

@Pointcut("@annotation(com.netease.cowork.sirius.it.data.overseas.server.frame.anno.IpRateLimit)")
public void rateLimiter() {}

@Before("rateLimiter() && @annotation(ipLimiter)")
public void doBefore(JoinPoint joinPoint, IpRateLimit ipLimiter) throws Throwable {
int[] limit = ipLimiter.limit();
TimeUnit[] timeUnits = ipLimiter.timeUnit();
if (limit.length != timeUnits.length){
throw new RuntimeException();
}
// 获取用户ip
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
HttpServletRequest request = attributes.getRequest();
String ip = request.getRemoteAddr();
for (int i = 0; i < limit.length; i++) {
long periodInMills = timeUnits[i].getPeriodInMills();
redisLuaRateLimiter = new RedisLuaRateLimiter(limit[i], periodInMills, periodInMills,timeUnits[i].getName());
if (!redisLuaRateLimiter.tryAcquired(ip)){
throw new RuntimeException("操作频繁,请稍后重试");
}
}
}
}

redis+lua实现滑动窗口限流算法

lua脚本在这里最大的作用就是解决并发问题,且保证原子性。分别说这个几个参数:

  • KEYS[1]:String key = prefix + ip。业务名称+ip。因为可能有多个业务都需要限流。
  • ARGV[1]:now-periodInMills。时间校验的分界线,往后为需要计算的流量。
  • ARGV[2]:now
  • ARGV[3]:UUID.randomUUID()真正的value值,随便写一个
  • ARGV[4]:expireInMills
  • ARGV[5]:limit

基本原理是zset的score存时间戳,ZREMRANGEBYSCORE这个方法会去除0到now-periodInMills的所有元素,剩下的就是要统计的元素。ZCARD用来统计当前元素数量,如果比count小就没有达到限流,ZADD设置value,score和超时时间。如果限流就返回特殊数字。
最后只用判断lua脚本执行是否是1来判断限流。

其他的代码都是java操作lua的方法,核心如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//限流
private static final String REDIS_LIMIT_SCRIPT = "local removedCount = redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])\n" +
"local currentCount = redis.call('ZCARD', KEYS[1])\n" +
"if currentCount < tonumber(ARGV[5]) then\n" +
" redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])\n" +
" redis.call('PEXPIRE', KEYS[1], ARGV[4])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
//统计rate
private static final String REDIS_LIMIT_COUNT_SCRIPT = "local removedCount = redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])\n" +
"local currentCount = redis.call('ZCARD', KEYS[1])\n" +
"return currentCount";

结论是对比海关服务的限流,这种写lua的方法能快三倍,限流速度50ms,他们的150ms。而且配置也很简单。

需求8:【出海项目】sitemap与seo优化

什么是seo?

搜索引擎优化,指的是通过合理手段和一些小技巧提升在谷歌上面的排名。这个出海项目最终要达到的效果是,在谷歌上查询一个公司,我们的界面要比这个公司靠前,从而获取自然流量,有了自然流量就可以变现了。
类似天眼查,每次去查询一个公司的官网,天眼查总能比官网靠前(其实也有可能是百度自家特殊关照)。

什么是sitemap?

sitemap.xml是一个站点地图,相当于给谷歌的爬虫一个指引,告诉他我的网站都有哪些路径需要你来访问的,这个地图一级只能有5w条,而我们的业务规模有6kw,所以采取两级索引,并采用gz进行压缩。
而我做的工作是定时更新这个sitemap并放到前端服务器上给爬虫来爬。

sitemap自底向上构建

给了两张表,底层存公司,顶层存索引。我最初的想法是自底向上构建,每次更新去删除顶层,然后底层分页进行压缩,最后构建顶层。这样会有几个问题:

  1. 深度分页问题:前面说了数据量最终大于6kw,如此大的数据量到了后面肯定更新不动的。想了半天索引优化最后mentor给否决了。
  2. 删表问题:数据库不允许传空参数直接删表,而且开销很大。
  3. seo问题:虽然密集化了表但是可能会造成波动,例如某个公司的前几个失效了,这个公司变动到别的索引之下,可能会对seo产生影响,最好还是固定不变。

sitemap自顶向下构建

自顶向下构建就是直接算好id范围,数据库between一下就可以找到,把索引当作槽来用,填满了就放下一个。优点:

  1. 没有深度分页问题,直接根据有索引的id查询,效率肯定是比limit高的。
  2. 一级索引满了就开辟一个新的,效率也高。

缺点:

  1. 依赖顺序性,稍微中间跨度大了就会有问题。例如首先存了1-100,再从40000-40100。每页100条,此时来了10000-10100就不能保证一级索引的顺序了。
  2. 一级索引的起始和终点会显得没有意义,中间有大量失效的,且不一定有序。

最终采用了这种方式,es中的数据是不区分新旧的,所以得做一个唯一性校验。除此之外还有一个当前循环的唯一性校验。

  • 首先查询当前最大公司的id,通过AtomicInteger解决线程安全问题,从es来的新增数据加入,同时要校验是否是英文公司,并取出首字母。(这里写了一大堆字符串工具)
  • 这样做了重复性校验的话即使是失败了也能保证不会重复。
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
public void getOpenCompanyIndex(){
try {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.existsQuery("summary.formatDomains"));
boolQuery.must(QueryBuilders.rangeQuery("emailCount")
.gt(1));
boolQuery.must(QueryBuilders.existsQuery("detail.productList"));
boolQuery.must(QueryBuilders.existsQuery("overviewDescription"));
boolQuery.must(QueryBuilders.existsQuery("detail.sic"));
boolQuery.must(QueryBuilders.existsQuery("detail.naics"));
boolQuery.must(QueryBuilders.existsQuery("customsItems"));

boolQuery.mustNot(QueryBuilders.termQuery("disable", true));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(boolQuery) // 查询参数
.fetchSource(new String[]{"name","companyId"},
null)
.size(1000);
if (openCompanyIndex == null) {
log.error("openCompanyIndex is null");
}

SiteMapCompanyEntity maxCompany = siteMapCompanyService.getMaxDataId();
long maxDataId = maxCompany == null ? 1 : maxCompany.getDataId();
Set<String> strings = new HashSet<>();
AtomicInteger count = new AtomicInteger();
openCompanyIndex.scrollSearch(sourceBuilder, searchHits -> {
List<SiteMapCompanyEntity> addList = new ArrayList<>();
for (SearchHit searchHit : searchHits) {
Map<String, Object> result = searchHit.getSourceAsMap();
SiteMapCompanyEntity siteMapCompanyEntity = new SiteMapCompanyEntity();
String name = result.get("name").toString();
String companyId = result.get("companyId").toString();
//本轮去重
if (strings.contains(companyId)) {
continue;
}
strings.add(companyId);
//全局去重
LambdaQueryWrapper<SiteMapCompanyEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(SiteMapCompanyEntity::getCompanyId, companyId);
if (siteMapCompanyService.count(lambdaQueryWrapper) > 0){
continue;
}
siteMapCompanyEntity.setCompanyId(companyId);
//保留原始名称
siteMapCompanyEntity.setCompanyName(name);
siteMapCompanyEntity.setChangeFreq(ChangeFreqEnum.WEEKLY.getCode());
siteMapCompanyEntity.setDataPriority(1f);
siteMapCompanyEntity.setCreateTime(new Date());
siteMapCompanyEntity.setUpdateTime(new Date());
siteMapCompanyEntity.setDataId(maxDataId + count.longValue());
//非英语公司
if (!StringUtil.checkEnglishChar(name)){
siteMapCompanyEntity.setNavLetter(null);
}
else {
String temp = StringUtil.removeAllPattern(name);
siteMapCompanyEntity.setNavLetter(temp.substring(0, 1).toUpperCase());
}
count.getAndIncrement();
addList.add(siteMapCompanyEntity);
}
siteMapCompanyService.saveBatch(addList);
return false;
});
log.info(String.valueOf(count));
}
catch (Throwable e){
log.error(String.valueOf(e));
}
}

那怎么做生成的,根据公司的最大值最小值/页面大小得到偏移量。根据这个偏移量去更新和查询,只关注最后一个索引块,如果没满就从这里开始加,更新最大值。

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
public void generate() {
preprocess();
SiteMapCompanyEntity maxCompany = siteMapCompanyService.getMaxDataId();
SiteMapCompanyEntity minCompany = siteMapCompanyService.getMinDataId();
if (maxCompany == null || minCompany == null) {
return;
}
List<CompletableFuture<Void>> futures = new ArrayList<>();
Map<Long,String> siteMap = new HashMap<>();
long minDataId = minCompany.getDataId();
long maxDataId = maxCompany.getDataId();
long pageLength = (long) Math.ceil((double) (maxDataId - minDataId + 1) / PAGE_SIZE);
for (long i = 1; i <= pageLength; i++) {
long startDataId = minDataId + (i - 1) * PAGE_SIZE;
long endDataId = minDataId + i * PAGE_SIZE - 1;
long page = i;
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
String gzip = xmlCompanyMapGenerator.generateCompanyMap(startDataId, endDataId,page);
if (!StringUtils.isEmpty(gzip)) {
siteMap.put(page,gzip);
}
}, EXECUTOR);
futures.add(future);
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
generateXML(siteMap);
long count = siteMapTopService.count();
List<SiteMapTopEntity> insertList = new ArrayList<>();
for (long i = Math.max(1,count); i <= pageLength; i++) {
long startDataId = minDataId + (i - 1) * PAGE_SIZE;
long endDataId = Math.min(minDataId + i * PAGE_SIZE - 1, maxDataId);
SiteMapTopEntity siteMapTopEntity = new SiteMapTopEntity();
siteMapTopEntity.setStartDataId(startDataId);
siteMapTopEntity.setEndDataId(endDataId);

siteMapTopEntity.setCreateTime(new Date());
siteMapTopEntity.setUpdateTime(new Date());
if (count != 0 && i == count){
LambdaUpdateWrapper<SiteMapTopEntity> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(SiteMapTopEntity::getName,siteMap.get(i));
siteMapTopService.update(siteMapTopEntity,updateWrapper);
continue;
}
siteMapTopEntity.setName(siteMap.get(i));
insertList.add(siteMapTopEntity);
}
siteMapTopService.saveBatch(insertList);
}

第一周首先上了3w数据,未来数据量大了肯定要分库分表的,以后的事情以后再说。

需求9:【出海项目】一些零碎的小需求

【接口】根据首字母和页码返回数据

就是简单的分页问题,但是有一些字符串处理。我写了一大堆字符串工具。

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
public PageResp<CompanyUrlResp> getCompanyUrl(String navLetter, Integer pageNum) {
LambdaQueryWrapper<SiteMapCompanyEntity> queryWrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotEmpty(navLetter)) {
//非英文国家
if (navLetter.equals("Other")) {
queryWrapper.isNull(SiteMapCompanyEntity::getNavLetter);
} else if (!navLetter.matches("^[a-zA-Z]$")) {
throw new BizException(BizError.PARAM_ERR.getName(), BizError.PARAM_ERR.getCode());
} else {
queryWrapper.eq(SiteMapCompanyEntity::getNavLetter, navLetter.toUpperCase());
}
} else {
throw new BizException(BizError.PARAM_ERR.getName(), BizError.PARAM_ERR.getCode());
}
queryWrapper.eq(SiteMapCompanyEntity::getStatus, CompanyStatus.ACTIVE.getCode());

IPage<SiteMapCompanyEntity> page = new Page<>(pageNum, COMPANY_URL_PAGE_SIZE);
IPage<SiteMapCompanyEntity> userPage = siteMapCompanyService.page(page, queryWrapper);
List<SiteMapCompanyEntity> userList = userPage.getRecords();
//组装返回结果
PageResp<CompanyUrlResp> pageResp = new PageResp<>();
pageResp.setPageNo(pageNum);
pageResp.setPageSize(COMPANY_URL_PAGE_SIZE);
pageResp.setTotalPage(userPage.getPages());
pageResp.setTotalSize(userPage.getTotal());
pageResp.setContent(userList.stream().map(item -> {
CompanyUrlResp companyUrlResp = new CompanyUrlResp();
companyUrlResp.setCompanyId(item.getCompanyId());
companyUrlResp.setCompanyName(StringUtil.firstLetterToUpperCaseByBlank(item.getCompanyName()));
if (!StringUtil.checkEnglishChar(item.getCompanyName())){
companyUrlResp.setCompanyFormatName("Non-English-Company");
}
else {
String temp = StringUtil.formatCompanyNameToUrl(item.getCompanyName());
companyUrlResp.setCompanyFormatName(temp);
}
return companyUrlResp;
}).collect(Collectors.toList()));
return pageResp;
}

【接口】公司详情获取

修改了mentor的一点逻辑,做了一点校验:

  • 因为查的是全球搜的数据,有可能未公开,校验只有开启的公司才能查询。
  • 如果没有登录,domain隐蔽,这里看threadlocal有没有东西就好了。
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
public CompanyBaseVO base(String companyId) {
if (!siteMapCompanyService.isOpeningCompany(companyId)) {
throw new BizException(BizError.NON_OPENING_COMPANY.getName(), BizError.NON_OPENING_COMPANY.getCode());
}
try{
String url = host+"/api/auth/global/overseas/base";
Map<String, String> map = new HashMap<>();
map.put("companyId", companyId);
map.put("appKey", appKey);
map.put("appSecret", appSecret);
map.put("timestamp", String.valueOf(System.currentTimeMillis()));
map = OpenApiUtil.packageSign(map, appKey, appSecret);
String resut = OkHttpUtil.get(url, map);
if (StringUtils.isNotBlank(resut)) {
// 图片链接转换
resut = ImagePathConvert.convert(resut);
JSONObject jsonObject = JSON.parseObject(resut);
String dataResult = jsonObject.getString("data");
return loginAuth(JSON.parseObject(dataResult, CompanyBaseVO.class));
}
return null;
}catch (Exception e){
log.info("demo error", e);
return null;
}
}
public CompanyBaseVO loginAuth(CompanyBaseVO companyBaseVO){
UserInfoToken userInfoToken = UserContext.getContent().getUserInfoToken();
//未登录
if (userInfoToken == null) {
companyBaseVO.setDomain("*****");
}
return companyBaseVO;
}