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

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


  • 首页

  • 归档

  • 搜索

Redis成长记 - Redis的陷阱(一) 缓存穿透 缓存

发表于 2024-04-26

相信很多老铁在求职过程中都看到过类似下面这样的任职要求

image.png

你申请的岗位上面写着”熟悉Redis”,那么你已经准备好回答面试官可能会问到的问题了么?
后面我将开启一个针对Redis的系列分享,希望能帮助刚刚开始学习Redis的朋友们。

在开始阅读本篇文章之前,默认你已经具备基础的Redis知识,如果你没有,可以先阅读文末相关文章推荐

当使用 Redis 作为缓存或数据存储时,虽然它提供了高性能和灵活性,但也存在一些陷阱需要注意。之前看博客的时候看到过这样一句话”Experts aren’t the only people who know what to do. They’re the people who know what not to do.“ 专家并不是唯一知道如何做的人,他们只是知道如何避免一些陷阱。

下面讲诉的是一些常见的 Redis 陷阱,或者说容易忽略的问题。内容较多,可能会分多篇文章,尽情期待。
同时由于要讲的内容实在是太多,所以本文更多的只是起到”抛砖“的作用,更多的详细的内容还需要老铁们自己再深层次的去学习。

缓存穿透

缓存穿透指的是恶意请求或者大量不存在的 key 导致缓存无法命中,从而绕过缓存直接访问数据库,导致数据库压力过大,甚至宕机的情况。

image.png

缓存穿透的原因

缓存穿透通常发生在以下情况下:

  1. 恶意请求:攻击者发送大量不存在于缓存中的 key,导致缓存无法命中,直接访问数据库。
  2. 大量并发查询:当并发查询量很大时,可能会出现大量不存在于缓存中的 key,从而导致缓存穿透。

缓存穿透的影响

  • 数据库压力过大:大量无效请求直接访问数据库,导致数据库压力过大,甚至导致数据库宕机。
  • 系统性能下降:数据库压力增大,可能导致系统响应变慢,影响用户体验。

缓存穿透的解决方法

  1. 空对象缓存:当查询结果为空时,也将该空结果缓存起来,但设置一个较短的过期时间,防止攻击者利用缓存穿透问题。
  2. 布隆过滤器:在缓存层之前增加布隆过滤器,用于快速过滤掉不存在于缓存中的 key,从而避免缓存穿透。
  3. 热点数据预热:将热点数据提前加载到缓存中,提高命中率,减少缓存穿透的发生。
  4. 限流控制:对于频繁查询的接口,可以进行限流控制,防止攻击者发起大量无效请求。
  5. 使用缓存锁:在查询数据库时,使用缓存锁进行串行化处理,防止大量并发查询导致缓存穿透。

下面是一个使用 C# 空对象缓存的示例代码:

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
C#复制代码public class UserBll
{
public static readonly int CACHE_NULL_TTL = 10;
public static readonly int CACHE_TTL = 20;
/**
* 缓存穿透*
* @param id
* @ return
*/
public WebUserInfo QueryUser(string key)
{
var redis = Redis.GetDatabase(0);
// 1.从redis中查询store缓存
String value = redis.StringGet(key);
WebUserInfo user = null;
// 2.判断是否存在
if( value.HasValue() ) {
user = value.FromJson<WebUserInfo>();
// 3.存在,直接返回
return user;
}
// 判断命中是否为空值
if ( value == null) {
// 返回错误信息
return null;
}
// 4.不存在,根据id查询数据库
user = DatabaseQuery(key);
// 5. 不存在,返回错误
if( user == null ) {
// 向redis写入空值(缓存穿透)
redis.StringSet(key, "", new TimeSpan(0, CACHE_NULL_TTL, 0));
return null;
}
// 6.存在,写入redis
redis.StringSet(key, user.ToJson(), new TimeSpan(0, CACHE_TTL, 0));
// 7. 返回
return user;
}


public WebUserInfo DatabaseQuery(string key)
{
// 模拟从数据库中查询数据
// 实际情况下,这里可以是访问数据库、调用外部 API 等操作
// 这里简化为返回 null
return null;
}


}

class Program
{
static void Main(string[] args)
{
UserBll cache = new UserBll();

// 第一次查询,缓存中不存在,但是查询结果为空
string result1 = cache.QueryUser("key1");
Console.WriteLine("Result 1: " + result1); // Output: Result 1: (null)

// 第二次查询,缓存中已存在空对象缓存,直接返回空值
string result2 = cache.QueryUser("key1");
Console.WriteLine("Result 2: " + result2); // Output: Result 2: (null)

// 第三次查询,模拟数据库中存在对应值的情况
string result3 = cache.QueryUser("key2");
Console.WriteLine("Result 3: " + result3); // Output: Result 3: <value from database>
}
}

上述代码中,当用户请求一个key时,redis和数据库都不存在。我们直接将key对应的null值缓存到redis中,这样下次用户重复请求这个key的时候,redis就可以命中(hit null),只是不会询问数据库

  • 优点:实现简单,易于维护
  • 缺点:额外的内存消耗(可以通过添加TTL来解决)

同时可能会造成短暂的不一致(控制TTL时间可以在一定程度上缓解)。当null被缓存时,我们只是在数据库中设置值,而用户query为空,但数据库中实际存在,会造成不一致(可以通过插入数据时自动覆盖之前的空数据来解决)

缓存雪崩

缓存雪崩指的是在缓存失效的瞬间,大量的请求同时涌入数据库或其他数据源,导致数据库负载剧增,甚至造成数据库宕机的情况。

image.png

缓存雪崩的原因

缓存雪崩通常是由于缓存中的大量数据同时失效而引起的。当多个缓存键具有相同的失效时间,并且这些缓存键又在同一时间失效时,就会导致大量请求直接击穿缓存,同时涌入数据源,造成缓存雪崩

缓存雪崩的解决方案

1. 设置随机过期时间

通过给缓存键设置随机的过期时间,可以有效地分散缓存失效的时间点,降低大量缓存同时失效的可能性,从而减轻了缓存雪崩的风险。

2. 使用多级缓存策略

采用多级缓存架构,包括本地缓存、分布式缓存和持久化存储,当主缓存失效时,可以从备用缓存中获取数据,降低对数据库的直接访问。

3. 限流和降级

在缓存失效期间,可以对请求进行限流,降低请求的并发数量,从而减轻了数据库的压力。同时,可以对部分非关键请求进行降级处理,暂时屏蔽一些非必要的服务,保证核心服务的稳定性。

4. 预热缓存

在系统启动或低峰期,预先加载缓存数据,提前将常用数据缓存起来,避免在高峰期间大量请求直接击穿缓存。

缓存击穿

缓存击穿是指某个热点key突然失效或者未命中,导致大量请求直接访问数据库,造成数据库压力剧增的现象。这种情况通常发生在具有高并发访问量的系统中,特别是在缓存系统中使用了较短的过期时间或者热点数据的访问频率突然增加时。

image.png

  1. 设置热点数据永不过期: 对于一些热点数据,可以设置永不过期,或者设置较长的过期时间,保证其不会在短时间内失效,从而避免了缓存击穿的发生。
  2. 加锁机制: 在缓存失效时,可以通过加锁机制确保只有一个线程能够进入数据库查询数据,并将查询结果更新到缓存中,避免了多个线程同时查询数据库的情况。
  3. 使用互斥锁和分布式锁: 使用互斥锁或者分布式锁来保证在查询数据库的过程中,只有一个线程能够执行查询操作,其他线程需要等待锁释放后再进行查询,避免了并发访问数据库的情况。
  4. 使用缓存预热: 在系统启动或者低峰期,可以预先将热点数据加载到缓存中,提前减少了缓存失效时的并发请求量,从而避免了缓存击穿的发生。

下面是一个用C#实现的示例代码,演示了如何使用互斥锁来解决缓存击穿问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
C#复制代码using System;
using System.Collections.Generic;
using System.Threading;

public class Cache
{
private Dictionary<string, string> cache = new Dictionary<string, string>();
private Mutex mutex = new Mutex();

public string Get(string key)
{
// 先尝试从缓存中获取数据
string value;
if (cache.TryGetValue(key, out value))
{
return value;
}

// 如果缓存中不存在,加锁查询数据库
mutex.WaitOne();
try
{
// 再次检查缓存,防止多个线程同时查询数据库
if (cache.TryGetValue(key, out value))
{
return value;
}

// 模拟从数据库中查询数据
value = QueryFromDatabase(key);

// 将查询结果更新到缓存中
cache[key] = value;
}
finally
{
mutex.ReleaseMutex();
}

return value;
}

private string QueryFromDatabase(string key)
{
// 模拟从数据库中查询数据的过程
Thread.Sleep(100); // 模拟耗时查询操作
return "value for " + key;
}
}

class Program
{
static void Main(string[] args)
{
Cache cache = new Cache();

// 并发查询
List<Thread> threads = new List<Thread>();
for (int i = 0; i < 10; i++)
{
Thread thread = new Thread(() =>
{
string value = cache.Get("key");
Console.WriteLine(Thread.CurrentThread.Name + ": " + value);
});
thread.Name = "Thread " + i;
threads.Add(thread);
}

foreach (Thread thread in threads)
{
thread.Start();
}

foreach (Thread thread in threads)
{
thread.Join();
}
}
}

相关文章推荐

  • Redis官方文档
  • Redis教程

今天就不总结了,未完待续😪…

更多一手讯息,可关注公众号:ITProHub

本文转载自: 掘金

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

java多线程面试——新版api

发表于 2024-04-26

在Android开发的面试中,Java多线程的问题是绕不开的。这个系列主要介绍面试过程中涉及到的多线程的知识点,以及相关的面试题。这是本系列的第三篇,介绍Java中多线程的新版api,及其对应的常见的面试题。

  • Java多线程面试系列——为什么需要多线程 - 掘金 (juejin.cn)
  • java多线程面试——旧版api - 掘金 (juejin.cn)
  • java多线程面试——新版api - 掘金 (juejin.cn)

ReteenLock

ReteenLock 是 java 1.5 以后提出的加锁API。使用它加锁解锁非常简单,只需要调用 lock、 unlock
方法

1
2
3
4
5
java复制代码private Lock lock = new ReentrantLock();
lock.lock();//加锁
//执行一些操作
...
lock.unlock();//解锁

除此之外,ReteenLock 还提供了 lockInterruptibly 和 tryLock 的不同的加锁接口,对加锁操作进行更细致的控制。

  • lockInterruptibly

lockInterruptibly 可以中断等待锁的线程。当线程调用 lockInterruptibly ,没有获取锁时,线程处于等待锁的状态,这时就可以通过 interrupt 方法来中断线程的等待状态。注意使用 lock 方法、synchronized 关键字是不能中断等待或者阻塞状态的。

  • tryLock
1
2
3
4
java复制代码//直接尝试获取锁,没有获取则直接返回
boolean tryLock();
//在一定时间尝试获取锁,否则等待获取锁,超时会返回 false
boolean tryLock(long var1, TimeUnit var3) throws InterruptedException;

tryLock 来尝试获取锁,如果没有获取到锁会返回 false。我们也可以设置规定时间内等待获取锁。

1
2
3
4
5
6
7
8
csharp复制代码Lock lock = new ReentrantLock();  
if(lock.tryLock()) {
//执行一些操作
...
lock.unlock();
} else {
//未获取锁的操作
}

await 、signal 和 signalAll

1
2
3
4
5
6
7
java复制代码Condition condition = lock.newCondition();  
try {
condition.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
condition.signalAll();

在新版java多线程的api中,我们可以通过 Condition 的 await 和 signal 方法来等待和唤醒线程。await 和 wait 对应,signal 和 notify 对应。调用 await 方法会让线程进入等待状态(WAITING),同时释放掉锁。如果需要唤醒线程,需要调用 signal 或者 signalAll 方法。signal 方法是随机唤醒一个线程,而 signalAll 方法是会唤醒所有等待线程。

面试题:Synchronized的原理以及与ReentrantLock的区别。

  • ReenTrantLock 可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。
  • ReenTrantLock 提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
  • ReenTrantLock 通过 lock.lockInterruptibly() 提供了一种能够中断等待锁的线程的机制

ReentrantReadWriteLock 和 StampedLock

ReentrantReadWriteLock 是读写锁。解决的场景是:读多写少。当没有写操作时,多线程读取数据,此时加锁会影响并发的性能。这时就需要读写锁,它有两个特点:

  1. 当线程写操作时,其他线程不能读取
  2. 当线程读操作时,允许其他线程读操作,但是不能写操作

代码示例如下:

1
2
3
java复制代码ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
Lock readLock = readWriteLock.readLock(); //获取读锁
Lock writeLock = readWriteLock.writeLock();//获取写锁

StampedLock 和 ReentrantReadWriteLock 类似。它特殊的地方是,它可以乐观读,即当线程读取共享变量时,其他线程可以写共享变量。而这在 ReentrantReadWriteLock 中是不被允许的。

代码示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
java复制代码/*
*使用StampedLock读操作模板
*/
final StampedLock sl = new StampedLock();
// 乐观读 ,获取 stamp 版本号
long stamp = sl.tryOptimisticRead();
// 读取共享变量,并用局部变量保存
......
// 校验 stamp 版本号
if (!sl.validate(stamp)){
// 如果失败,则升级为悲观读锁
stamp = sl.readLock();
try {
// 再次读取共享变量,并用局部变量保存
.....
} finally {
// 释放悲观读锁
sl.unlockRead(stamp);
}
}
// 执行业务操作
......



/*
*使用StampedLock写操作模板
*/
long stamp = sl.writeLock();
try {
// 写共享变量
......
} finally {
sl.unlockWrite(stamp);
}

StampedLock 虽然通过共享读提升了读多写少场景的性能,但是也提高了它的复杂度。需要注意的是:StampedLock 是不可重入的

Semaphore

Semaphore 是一个限制器,可以限制访问资源的线程数量。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码//限制只有四个线程可以访问
//第5个线程会被阻塞,直到其他线程release
Semaphore semaphore = new Semaphore(4);
try {
semaphore.acquire();
//执行操作
...
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
semaphore.release();
}

CountDownLatch 和 CyclicBarrier

CountDownLatch 和 CyclicBarrier 都可以解决多线程并发依赖的问题。它们的主要区别是:CountDownLatch 主要用来 解决一个线程等待多个线程的场景;而CyclicBarrier 解决的是一组线程之间互相等待的场景。

CountDownLatch

如上图所示,当我们需要判断当前两个用户是否是好友时,线程3需要依赖线程1和线程2的结果,才可以执行。这时就可以使用 CountDownLatch,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
scss复制代码final CountDownLatch countDownLatch = new CountDownLatch(2);  
Runnable runnable1 = new Runnable() {
@Override
public void run() {
//获取用户的id
...
//计数减一
countDownLatch.countDown();
}
};
Runnable runnable2 = new Runnable() {
@Override
public void run() {
//获取其他用户的id
...
//计数减一
countDownLatch.countDown();
}
};
Runnable runnable3 = new Runnable() {
@Override
public void run() {
try {
//等待其他线程执行完成
countDownLatch.await();
//判断两个人是否是好友
...
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(runnable1).start();
new Thread(runnable2).start();
new Thread(runnable3).start();

CyclicBarrier

如上图所示,CyclicBarrier 解决的是一组线程之间互相等待的场景,代码如下:

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
java复制代码final CyclicBarrier cyclicBarrier = new CyclicBarrier(2, new Runnable() {  
@Override
public void run() {
//计数归0时的回调
...
}
});
Runnable runnable1 = new Runnable() {
@Override
public void run() {
while (是否满足退出条件) {
try {
//执行操作
...
//阻塞线程,计数减少1
cyclicBarrier.await();
//执行操作
...
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
}
}
};

Runnable runnable2 = new Runnable() {
@Override
public void run() {
while (是否满足退出条件) {
try {
//执行操作
...
//阻塞线程,计数减少1
cyclicBarrier.await();
//执行操作
...
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
}
}
};
new Thread(runnable1).start();
new Thread(runnable2).start();

CyclicBarrier 类是通过 await 方法来让计数减一,同时会阻塞当前线程。通过这种方式,让不同线程步调保持一致,以此来实现一组线程之间的互相等待。

需要注意,CyclicBarrier 的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0 会自动重置到你设置的初始值。除此之外,CyclicBarrier 还可以设置回调函数,可以说是功能丰富。但是 CountDownLatch 的计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用 await(),该线程会直接通过。

原子类

原子类.jpg

如上图,是在java 1.5 以后新增了原子类,这些类可以分成五种类型:基本类型、数组类型、对象引用类型、对象属性类型、累加器类型。

基本类型

基本类型的原子类有三个,分别是:AtomicBoolean、AtomicInteger、AtomicLong。它们的方法都是类似的,这里以 AtomicInteger 为例。AtomicInteger 对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

AtomicInteger的方法 作用
int get() 获取当前值
void set(int newValue) 设置 value 值
int getAndIncrement() 先取得值,然后加1
int getAndDecrement() 先取得值,然后减1
int incrementAndGet() 加1,然后返回新值
int decrementAndGet() 减1,然后返回新值
int getAndAdd(int delta) 先取得值,然后增加指定值
int addAndGet(int delta) 增加指定值,然后返回新值
boolean compareAndSet(int expect, int update) 将旧值设置成新值(先要获取当前值)

数组类型

数组类型的原子类有三个,分别是:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。这里以 AtomicIntegerArray 为例,它的方法与上面的方法类似,只是多了index 的参数。

AtomicIntegerArray的方法 作用
int get(int index) 获取数组index位置的值
void set(int index, int newValue) 设置数组index位置的value 值
int getAndIncrement(int index) 获取数组index位置的值,然后加1
int getAndDecrement(int index) 数组index位置的值,然后减1
int incrementAndGet(int index) 让数组index位置的值加1,然后返回新值
int decrementAndGet(int index) 让数组index位置的值减1,然后返回新值
int getAndAdd(int index, int delta) 先获取数组index位置的值,然后增加指定值
int addAndGet(int index, int delta) 让数组index位置的值增加指定值,然后返回新值
boolean compareAndSet(int index, int expect, int update) 让数组index位置的值设置成新值(先要获取当前值)

代码示例如下:

1
2
3
4
java复制代码int[] v = new int[]{1, 2, 3};  
AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(v);
int value = atomicIntegerArray.get(0);
System.out.println(atomicIntegerArray.compareAndSet(0, value, 100));

对象引用类型

对象引用类型的原子类有三个,分别是 AtomicReference、AtomicStampedReference、AtomicMarkableReference。AtomicReference 的方法相对于上面就少了很多,但是大致的功能是一样的。

AtomicReference的方法 作用
int get() 获取当前对象值
void set(T newValue) 设置对象值
int getAndSet(T newValue) 先取得对象值,然后设置新的对象值
boolean compareAndSet(T expect, T update) 将旧对象值设置成新对象值(先要获取当前值)

代码示例如下:

1
2
3
java复制代码Test test = new Test();  
AtomicReference<Test> atomicReference = new AtomicReference<>(test);
atomicReference.compareAndSet(atomicReference.get(), new Test());

AtomicStampedReference 和 AtomicMarkableReference 相对于 AtomicReference 的不同是,它们分别通过 Stamp(整数标记) 和 Mark(布尔标记) 解决了ABA问题。

对象属性类型

对象属性类型的原子类有三个,分别是 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater。下面代码以 AtomicIntegerFieldUpdater 为例,其方法与 AtomicInteger 类似,区别是新增了对象入参。代码示例如下:

1
2
3
4
5
ini复制代码Test test = new Test();  
test.id = 0;
AtomicIntegerFieldUpdater<Test> updater = AtomicIntegerFieldUpdater.newUpdater(Test.class, "id");
//获取当前对象的id值,并加1
updater.getAndIncrement(test);

注意:对象属性类型的原子类只支持被 volatile 关键字修饰的可见成员属性

累加器类型

累加器类型的原子类有四个,分别是LongAdder、LongAccumulator、DoubleAdder、DoubleAccumulator。它们是java 1.8加入的,专门用来执行数值类型的数据累加操作,相对于 AtomicLong 性能更好。代码如下:

1
2
java复制代码LongAdder longAdder = new LongAdder();  
longAdder.increment();

LongAdder 和 LongAccumulator 的区别:LongAdder的功能增强版,它支持自定义的函数操作。DoubleAdder 和 DoubleAccumulator 的区别也一样。

并发容器

思维导图结构图.jpg

如上图,java新版的并发容器可以分成 List、Set、Map、Queue 四种类型。

List

对于List,新版Api只提供了 CopyOnWriteArrayList 这个并发容器。
Copy On Write(写时复制),意思就是在对其进行修改操作的时候,复制一个新的ArrayList,在新的ArrayList上进行修改操作,从而不影响旧的ArrayList的读操作。

详情看阿里Java面试官:CopyOnWriteArrayList底层是怎么保证线程安全的

Map

对于Map类型,有两个实现类,分别是 ConcurrentHashMap、ConcurrentSkipListMap。从应用的角度来看,主要区别在于ConcurrentHashMap 的 key 是无序的,而 ConcurrentSkipListMap 的 key 是有序的。所以如果需要保证 key 的顺序,就只能使用 ConcurrentSkipListMap。

使用 ConcurrentHashMap 和 ConcurrentSkipListMap 需要注意的地方是,它们的 key和 value 都不能为空,否则会抛出NullPointerException这个运行时异常

Set

对于Set类型,有两个实现类,分别是 CopyOnWriteArraySet、ConcurrentSkipListSet。

  • CopyOnWriteArraySet:基于数组实现的并发 Set,内部是使用 CopyOnWriteArrayList 来实现的
  • ConcurrentSkipListSet:基于跳表实现的并发 Set,其内部是通过 ConcurrentSkipListMap 来实现的

Queue

对于Queue类型,有九个实现类,分别是 ArrayBlockingQueue、LinkedBlockingQueue、

SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue、
DelayQueue、LinkedBlockingDeque、ConcurrentLinkedQueue、ConcurrentLinkedDeque。

Queue.jpg

如上图所示,根据其数据结构方式和是否可以阻塞可以分成四种。Queue表示单端队列,遵循的先进先出的原则;Deque表示双端队列,该队列两端的元素既能入队,也能出队。阻塞指的是当队列已满时,入队操作阻塞,直到队列有空位才能插入;当队列已空时,出队操作阻塞,直到队列不为空才返回。非阻塞则是指入队出队操作不会阻塞,如果队列已满或者为空,(根据调用的方法)直接返回null或者报错。

队列 作用
ArrayBlockingQueue 基于数组的阻塞队列,使用数组存储数据,并需要指定其长度,所以是一个有界队列
LinkedBlockingQueue 基于链表的阻塞队列,使用链表存储数据,默认是一个无界队列;也可以通过构造方法中的capacity设置最大元素数量,所以也可以作为有界队列
SynchronousQueue 一种没有缓冲的队列,生产者产生的数据直接会被消费者获取并且立刻消费
PriorityBlockingQueue 优先级别的阻塞队列,底层基于数组实现,是一个无界队列
DelayQueue 延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队
LinkedTransferQueue 基于链表的数据交换队列,基于链表实现,是一个无界队列

线程池

java中自带的线程池

线程池 特点 获取方式
FixedThreadPool 线程数固定的线程池 Executors.newFixedThreadPool
CachedThreadPool 线程数根据任务动态调整的线程池 Executors.newCachedThreadPool
SingleThreadExecutor 只有一个线程的线程池 Executors.newSingleThreadExecutor()
ScheduledThreadPool 定时或周期性执行任务的线程池 Executors.newScheduledThreadPool()
SingleThreadScheduledExecutor 定时或周期性执行任务的线程池,但是它的线程数只有一个 Executors.newSingleThreadScheduledExecutor()

一般在业务中,我们不会使用java中自带的线程池,而是根据自己的需要自定义线程池。

ThreadPoolExecutor

自定义的线程池需要通过 ThreadPoolExecutor 来创建。ThreadPoolExecutor 的构造函数非常复杂,如下面代码所示:

1
2
3
4
5
6
7
8
arduino复制代码ThreadPoolExecutor(  
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)

参数意义如下:

  • corePoolSize:核心线程数,由于频繁创建线程会对性能产生影响,因此就需要线程被创建后一直存在,这就是核心线程。Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,包括核心线程
  • maximumPoolSize:线程池创建的最大线程数。当核心线程都在执行任务时,还有任务需要处理,就会创建新的线程来处理,但是系统的资源不是无限的,因此需要限制最多创建的线程。
  • keepAliveTime :非核心线程的存在时间。当一个线程如果在一段时间内,都没有执行任务,就回收该非核心线程
  • unit :上面 keepAliveTime 的时间参数,有秒、分钟等
  • workQueue:阻塞队列(BlockingQueue),具体实现类上面已经介绍过了。当线程数达到最大时,这时还有任务来,就把任务放到这个任务队列中,等待处理。
  • threadFactory:自定义如何创建线程,例如可以给线程指定一个有意义的名字。
  • handler:自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,并且工作队列也满了(前提是工作队列是有界队列),那么此时提交任务,线程池就会拒绝接收。这时你可以通过 handler 这个参数来指定拒绝的策略

面试题:shutdown 、shutdownNow 的区别

  • shutdown() : 执行后停止接受新任务,会把队列的任务执行完毕。
  • shutdownNow() : 执行后停止接受新任务,但会中断所有的任务(不管是否正在执行中),将线程池状态变为 STOP状态。

面试题:当任务超过阻塞队列数量时,有哪些拒绝策略

ThreadPoolExecutor 已经提供了以下 4 种策略:

  • CallerRunsPolicy:提交任务的线程自己去执行该任务。
  • AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。
  • DiscardPolicy:直接丢弃任务,没有任何异常抛出。
  • DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列

面试题:使用线程池要注意些什么

  • 不要使用无界的 LinkedBlockingQueue,在高负载情境下,无界队列很容易导致 OOM。很多Executors 提供的很多方法默认使用的都是无界的 LinkedBlockingQueue,使用前需要特别注意。
  • 默认拒绝策略要慎重使用。当任务过多时,会有拒绝策略,最好针对业务的情况来自定义拒绝策略

面试题:自定义线程池的参数如何配置

根据任务的不同,推荐配置如下:

  • CPU密集型: cpu数量 + 1
  • IO密集型: cpu 数量 * 2

也可以使用动态线程池,可以看动态线程池

面试题:线程池都有哪些状态?

  • RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。
  • SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
  • STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
  • TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。
  • TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。

面试题: 线程池中 submit() 和 execute() 方法有什么区别?

  • execute():只能执行 Runnable 类型的任务。
  • submit():可以执行 Runnable 和 Callable 类型的任务。

Callable 类型的任务可以获取执行的返回值,而 Runnable 执行无返回值。

ForkJoinPool

ForkJoinPool 是java7引入的线程池,它可以把一个大任务拆成多个小任务并行执行。代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码class SumTask extends RecursiveTask<Long> {
protected Long compute() {
if(判断是否需要拆分任务) {
//创建两个子任务
SumTask task1 = new SumTask(...);
SumTask task2 = new SumTask(...);
// invokeAll会并行运行两个子任务:
invokeAll(task1, task2);
// 等待获得子任务的结果:
Long result1 = task1.join();
Long result2 = task2.join();
return result1 + result2;
} else {
//执行sum操作
...
return result;
}
}
}


ForkJoinTask<Long> task = new SumTask(...);
Long result = ForkJoinPool.commonPool().invoke(task);

Fork/Join线程池在Java标准库中就有应用。Java标准库提供的java.util.Arrays.parallelSort(array)可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。

CompletableFuture

CompletableFuture 是 java 1.8 以后提供的类。它可以处理任务之间的时序关系,如串行关系、并行关系、汇聚关系等用来简化异步编程。它内部默认是通过ForkJoinPool线程池来执行任务,当然我们也可以设置自己的线程池。CompletableFuture 是官方提供的异步编程类,可以满足简单的异步编程需求,在Android中复杂的异步编程使用最多的是RxJava,或者现在的kotlin 协程,这个了解即可。

CompletableFuture

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码//串行任务,任务1、2、3串行执行
CompletableFuture<String> result =
CompletableFuture.supplyAsync(() -> "hello") //任务1
.thenApply(s -> s + " world"); //任务2
.thenApply(String::toUpperCase); //任务3
System.out.println(result.join());


//汇聚关系
CompletableFuture<String> result =
CompletableFuture.supplyAsync(() -> "a")
.thenCombineAsync(CompletableFuture.supplyAsync(() -> "b"),
(a, b) -> a + b );
System.out.println(result.join());

如果想深入了解CompletableFuture,具体可以看异步编程利器:CompletableFuture详解 |Java 开发实战 - 掘金 (juejin.cn)

CompletionService

CompletionService 是一种能处理批量异步任务并在完成时获取结果的并发工具类。你可以把它看成 线程池 + 队列,当一个任务完成时,就可以通过 completionService.take().get() 获取返回值(任务执行完的值存储在队列)。如果所有任务都在执行,调用 take 方法时会阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码ExecutorService executorService = Executors.newFixedThreadPool(3);  
CompletionService<Integer> completionService = new ExecutorCompletionService<>(executorService);
completionService.submit(() -> { //任务1
Thread.sleep(200);
return 1;
});
completionService.submit(() -> { //任务2
Thread.sleep(100);
return 2;
});
completionService.submit(() -> { //任务3
Thread.sleep(150);
return 3;
});
int sum = 0;
for(int i = 0; i < 3; i++) {
try {
//这里获取顺序是 2 3 1
sum += completionService.take().get();
System.out.println(sum);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}

参考

  • 14个Java并发容器,Java高手都知道!-阿里云开发者社区 (aliyun.com)
  • Java面试题|多线程22道必看面试题 - 知乎 (zhihu.com)
  • 【建议收藏】106道Android核心面试题及答案汇总(总结最全面的面试题)
  • 面试官问我什么是JMM - 知乎 (zhihu.com)
  • Java 并发编程实战 (geekbang.org)
  • final保证可见性和this引用逃逸 - 知乎 (zhihu.com)
  • Synchronized的底层实现原理(原理解析,面试必备)_synchronized底层实现原理-CSDN博客
  • 线程间到底共享了哪些进程资源 - 知乎 (zhihu.com)
  • stackoverflow.com/questions/1…
  • spotcodereviews.com/articles/co…
  • Linux内核同步机制之(三):memory barrier (wowotech.net)
  • 万字长文!一文彻底搞懂Java多线程 - 掘金 (juejin.cn)
  • 【死磕Java并发】常用并发原子类详解-腾讯云开发者社区-腾讯云 (tencent.com)

本文转载自: 掘金

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

面试官问如何实现二🐔缓存怎么进行回答以及延伸出更多知识点呢?

发表于 2024-04-26

二级缓存的优势与缺点

优点:

1)二级缓存相比只调用一层 Redis 缓存,访问速度更快。对于一些不经常修改的数据而查询十分频繁的可以直接放在本地缓存(一级)里面。

作为面试者的扩展延伸:我在本地缓存的实现中,我使用到了本地缓存性能之王 Caffeine 作为一级缓存,在市面上很多像 Redisson、Druid、Hbase 等知名开源项目都用到了 Caffeine 。它实现了更加好用的缓存淘汰算法 W-TinyLFU 算法,结合了 LRU(最近最久未使用算法) 算法以及 LFU(最少使用算法) 算法的优点,所以选择它能使本地缓存的使用更加方便快速。

2)使用了本地缓存相比直接去 Redis 中取,能够减少与远程 Redis 的数据 I/O 网络交互,降低了网络之间的消耗。

缺点:

1)增加了本地缓存对于一致性的维护更加复杂,提高了维护成本。

2)在分布式环境下,如何解决各个节点本地缓存一致性问题?使用类 Redis 的发布订阅功能,当一个节点的数据发生修改时,直接通知其他节点的缓存进行更新。

是不是已经初步了解了二级缓存的应用咧~ 下来先带你实现一下二级缓存。

image-20240426105159466

简单实现

1)引入依赖

1
2
3
4
5
6
7
8
9
xml复制代码<dependency>
   <groupId>com.github.ben-manes.caffeine</groupId>
   <artifactId>caffeine</artifactId>
   <version>2.9.2</version>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2)配置 Caffeine

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Configuration
public class CaffeineConfig {
   @Bean
   public Cache<String,Object> caffeineCache(){
       return Caffeine.newBuilder()
              .initialCapacity(128)//初始大小
              .maximumSize(1024)//最大数量
              .expireAfterWrite(60, TimeUnit.SECONDS)//过期时间
              .build();
  }
}

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
java复制代码@Resource
private Cache<String, Object> cache;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Test
void testCache() {
   // 缓存测试存取
   cache.put("test", "test");
   assertEquals("test", cache.getIfPresent("test"));

   //实现二级缓存读取
   cache.get("test",
             k -> {
                 //先查询 Redis
                 Object obj = redisTemplate.opsForValue().get(k);
                 if (Objects.nonNull(obj)) {
                     System.out.println("缓存命中:" + k + " 从 Redis 读取成功");
                     return obj;
                }

                 // Redis没有则查询 DB
                 System.out.println("缓存没有命中:" + k + " 从 DB 读取");
                 // 从 DB 读取 ..此处模拟省略
                 obj = "test";
                 // 存入 Redis
                 redisTemplate.opsForValue().set(k, "test");
                 //存入本地缓存由cache.get执行
                 return obj;
            });

}

这样一个缓存简单的二级缓存使用就是这样啦,但这样是不是感觉对代码的侵入性有点强了,使用起来不够灵活,下面再来带大家使用 Spring 提供的 CacheManager 接口以及注解来实现它。

image-20240426101016819

使用 Spring 的 CacheManager 实现

1)引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
xml复制代码<dependency>
   <groupId>com.github.ben-manes.caffeine</groupId>
   <artifactId>caffeine</artifactId>
   <version>2.9.2</version>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

2)配置 CacheManager

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Configuration
public class CacheManagerConfig {
   @Bean
   public CacheManager cacheManager(){
       CaffeineCacheManager cacheManager=new CaffeineCacheManager();
       cacheManager.setCaffeine(Caffeine.newBuilder()
              .initialCapacity(128)
              .maximumSize(1024)
              .expireAfterWrite(60, TimeUnit.SECONDS));
       return cacheManager;
  }
}

3)在启动类上面加上 @EnableCaching 注解

1
2
3
4
5
java复制代码@SpringBootApplication()
@EnableCaching
public class Application {

}

4)使用二级缓存查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码@Resource
private RedisTemplate<String,Object> redisTemplate;
@Cacheable(value = "test",key = "#id")
public String getStringTextById(Long id) {
   String key= "test" + id;
   //先查询 Redis
   String obj = (String) redisTemplate.opsForValue().get(key);
   if (Objects.nonNull(obj)){
       log.info("从Redis中获取数据");
       return obj;
  }
   // Redis没有则查询 DB
   log.info("从DB中获取数据");
   //此处省略查询DB的代码
   obj = "test";
   //redis 中存入
   redisTemplate.opsForValue().set(key,obj,120, TimeUnit.SECONDS);
   return obj;
}

这里要注意的是!

1)@Cacheable 注解的 value 跟 cacheNames 是互为别名的关系,表示当前方法的结果被缓存到哪个 cache 上面,它可以是一个数组绑定多个 cache。

2)@Cacheable 注解的 key 用来指定返回结果的 key,这个属性可以支持 SpringEL 表达式。

SpringEL表达式如下:

1
2
3
bash复制代码#参数名
#参数对象.属性名
#参数为数组对应下标

5)使用二级缓存更新数据

1
2
3
4
5
6
7
8
9
10
java复制代码@CachePut(cacheNames = "test",key = "#id")
public String updateOrder(String testData, Long id) {
   log.info("更新数据");
   //更新数据库 此处省略
   //修改 Redis
   redisTemplate.opsForValue().set("test" + id,
                                   testData, 120, TimeUnit.SECONDS);
   //返回要缓存本地的数据
   return testData;
}

这里要注意的是!需要返回对象或值,因为要进行本地缓存操作。

6)使用二级缓存删除数据

1
2
3
4
5
6
7
java复制代码@CacheEvict(cacheNames = "test",key = "#id")
public void deleteOrder(Long id) {
   log.info("删除数据");
   //此处省略删除数据库的代码
   //删除 Redis
   redisTemplate.delete("test" + id);
}

在这里简单的使用就到这里啦!还有更加优雅的方式大家可以去实现一下,通过 AOP 去实现二级缓存~

image-20240426103353051

二级缓存抛砖引玉

在上面跟大家一起谈了谈二级缓存的实现以及使用,这里我们可以跟面试官再次周旋一下!我曾经在阅读 Spring 源码时我还了解到了 Spring 的三级缓存实现。

在 Spring 框架中,循环依赖是指两个或多个 Bean 之间相互依赖,形成一个循环引用的情况。这种情况下,Spring IOC 容器在实例化 Bean 时可能会出现问题,因为它无法决定应该首先实例化哪个 Bean。为了解决这个问题,Spring 引入了三级缓存。

三级缓存是指 Spring IOC 容器中用于解决循环依赖问题的一种机制,它包含三个缓存阶段:

1)singletonObjects:这是 Spring IOC 容器的一级缓存,用于存储已经完全创建并初始化的 Bean实例。当 Bean 完全创建后,它会被放置在这个缓存中。

2)earlySingletonObjects:这是 Spring IOC 容器的二级缓存,用于存储提前暴露的、尚未完全初始化的 Bean 实例。当 Bean 正在被创建但尚未完成初始化时,它会被放置在这个缓存中。

3)singletonFactories:这是 Spring IOC 容器的三级缓存,用于存储 Bean 的工厂对象。在创建Bean 实例时,Spring 首先会在这个缓存中查找工厂对象,如果找到则使用该工厂对象来创建 Bean 实例。

三级缓存的作用

三级缓存的作用是为了解决循环依赖时的初始化顺序问题。在初始化 Bean 时,Spring 会首先将 Bean 的实例放入三级缓存中,然后进行属性注入等操作。如果发现循环依赖,Spring 会在二级缓存中查找对应的 Bean 实例,如果找到则直接返回,否则会调用Bean的工厂方法来创建 Bean 实例,并将其放入二级缓存中。当 Bean 实例化完成后,会从二级缓存中移除,并放入一级缓存中。

为什么需要三级缓存而不是二级缓存

二级缓存只存储了尚未初始化完成的 Bean 实例,而三级缓存存储了 Bean 的工厂对象。这样做的好处是,当发现循环依赖时,可以通过 Bean 的工厂对象来创建 Bean 实例,从而避免了直接从二级缓存中获取可能尚未完成初始化的 Bean 实例而导致的问题。因此,三级缓存提供了更加灵活和可靠的解决方案,能够更好地处理循环依赖问题。

本文转载自: 掘金

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

阿里巴巴瓴羊基于 Flink 实时计算的优化和实践

发表于 2024-04-26

摘要:本⽂整理⾃阿里云智能集团技术专家王柳焮⽼师在 Flink Forward Asia 2023 中平台建设专场的分享。内容主要为以下四部分:

  1. 阿里巴巴瓴羊基于 Flink 实时计算的平台演进
  2. Flink 能力优化与建设
  3. 基于 Flink 的最佳实践
  4. 未来规划
  1. 阿里巴巴瓴羊基于 Flink 实时计算的平台演进

1.1 关于瓴羊

首先简单介绍一下瓴羊,瓴羊是阿里云智能集团的重要业务,致力于将阿里巴巴沉淀十余年的数字化服务经验,系统化、产品化地全面对外输出给千行百业。2012 年开始,阿里提出数据建设方法论,内部形成多款标杆数据产品,如生意参谋、双十一数据大屏等。2015 年阿里巴巴启动中台战略,进行全域数据建设,强化数据能力在业务端的价值显现,让数字化能力广泛服务于各个业务。2018 年应商家日益增长的需求,阿里巴巴集团中台能力进行对外输出,推出 Dataphin、QuickBI 等产品,帮助企业通过数字化技术驱动创新和增长,在生态内外产生显著价值。2021 年瓴羊成立,成为阿里巴巴动物园中的一员。

1.2 Dataphin平台实时业务规模

而在这个过程中打造出的智能数据建设与治理 Dataphin,不仅在阿里巴巴集团内部支撑着各 BU 实时业务,同时也在云上服务于各行业推进企业数字化进程。

在集团内部,平台约承载有 15000+ 的实时计算任务,1000+ 的流批一体任务,覆盖约 50+ 个BU。

在云上,我们的客户分布在电商、金融、交通、零售、制造等行业,应用场景覆盖诸如实时 ETL、实时大屏、实时集成等等实时主要场景,以及像金融行业的特征计算以及风控场景等等。

1.3 Dataphin平台实时架构大图

下面介绍一下 Dataphin 平台的实时架构大图。

首先部署形态上平台支持多云输出,提供公共云、私有云、专有云、混合云等多种输出形态。在多云架构之上,支持提供基于不同调度系统的作业运行能力。在这之上,平台提供不同 Flink 发行版的多版本多集群管理能力,以及多数据源、计算源的元数据管理体系。

最上层,主要由四大模块组成。

● 数据集成,提供无代码化全增量一体的数据集成能力。

● 研发中心,提供编译优化、任务模版、流批一体等能力的实时研发体验。

● 运维中心,提供任务全方位的监控告警体系,保障线上任务的稳定性。

● 资产,支持表、字段、函数、血缘等数据资产的盘点与展示、标准定义与管理、质量评估及保障、分类分级与脱敏等等能力

如上支撑着整个 Dataphin 平台实时计算的全流程研发体系。

  1. Flink 能力优化与建设

接下来将围绕采、管、建、用四个方面,分别讲解我们在 CDC 能力提升、元数据管理、流批一体建设、运维体系上分享平台对 Flink 能力的优化与建设。

2.1 CDC 能力提升

在实时数据采集上,平台除了支持社区 Flink CDC 的能力外,还提供了自研的 Flink Connector 去满足更多样化的数据同步场景需求。左右两列是平台目前已经支持的提供增强 CDC 去做数据同步的部分输入输出,更多的数据源还在陆续增加中,我们目前做到的四个能力分别是:

● 一个是自动化感知来源库/表变更的能力,

● 支持配置多样的规则满足不同数据源的入湖入仓场景,

● 同时平台提供无代码化的整库实时同步能力,

● 最后凭借元数据管理体系支撑这种丰富多样的源端到源端的数据流通能力。

平台期望基于 Flink 引擎的能力,提供集实时性、全增量一体、无代码化、自动化为一体的实时数据同步能力。

搭载自研 Connector 的 Flink 采集任务目前支持自动感知8种数据库的源端操作,以及提供 3 种不同的处理策略。

● 对于8种数据库的源端操作,因为对行数据的增删改操作进行感知是 CDC 最基本的能力,这里就不赘述了。除此之外,平台还支持对数据表中列的一系列操作,比如列的新增、列的删除、列的重命名、列类型变更,特别的像表的重命名、清空表等等操作,都会进行自动采集发往下游进行处理。当然更多的场景用户期望的是整库同步,平台对数据库中表的删除或者表的新增,都会形成记录进行采集。

● 对于3种不同的处理策略,同时平台支持用户针对每一种源端操作的记录,去配置不同的消息处理策略。除了正常处理外,还可以配置忽略或者出错策略。比如数据开发人员对业务库一些新增的列不需要关注的可以选择不去处理,但对于已采集的列如果被删除或者更改的下游不可接受的情况,可以终止任务采集并发出告警。

同时针对 CDC 数据发往下游入湖入仓的场景,提供一些可配的规则。

● 支持用户选择目标库已有表,或者自动建表。

● 可以配置自动建表的转换规则,比如支持基于来源表名字加上前后缀来创建目标表名。

● 平台提供表级别映射状态监控,用户可以实时查看整库同步中每一张来源表到目标表的映射状态。

● 最后支持自动在目标表添加变更记录的描述字段,包括变更发生时间、操作类型等,以便业务识别使用,同时用户还可以自行添加全局字段满足特有场景的需求。

刚才介绍的所有的 CDC 能力的提升,平台侧都可通过配置化的任务形式,无需代码,只需配置一个任务即可实现整库的数据实时同步。

如上图所示,用户可以按数据源的类型选择同步任务的输入数据源和输出数据源。在同步规则配置里,支持选用整库、圈选表、排除表的方式从来源库选择需要同步的表,平台能实时感应所配置的数据源中的元数据信息,并进行展示,用户直接勾选对应的数据表即可。

如上图1是刚才说的配置表的映射规则,在列表中会显示当前的映射状态。比如规则选择已有表时,检测到目标库并没有对应的表名时,映射状态就会显示告警,在同步任务运行前就能发现问题。

如上图2是配置源端操作处理策略的配置页面,针对每一种源端操作都可分别配置对应的 DDL 处理策略。其中行数据的增删改默认是正常处理的。

2.2元数据管理

在Flink支持这样丰富的多源数据同步的场景下,面对多达几十上百种不同的数据源,用户又该如何有效应对呢?如何有效的管理不同的数据源连接信息,做好元数据管理就显得尤为重要了。

用户常常会遇到如下情况:

● 相同的源例如 Mysql,既能 CDC 又能 JDBC,如何有效识别并按需引用?

● 相同的 DDL 在不同的作业间如何复用?

● 相同的 DDL 在不同的作业使用场景能否进行差异化配置?

● 离线物理表是否可以直接引用,并与实时 DDL 进行联动从而接入全域血缘?

于是我们基于这些问题,构建了一套元数据的管理体系,分别是 Source 层、Meta 层和 Job 层。

● Source 层按照物理数据源进行管理,与引擎解耦,管理基础的连接信息,这样离线和实时可以复用。如果有些数据源能当做计算引擎还能将其配置为离线计算源,比如 Hive、Hologres、Adbpg 等等。源的 Catalog 也会在这一层进行管理。

● 再上一层构造 Meta 层,这一层会基于数据源的构建,可以是按照 Connector 类型区分的 Flink DDL,我们将称为实时元表;也可以是来自数据源或者计算源中的物理表。还可以是基于实时元表或物理表构建的流批一体映射镜像表或者是批查询的混合元表,每一种表都各有用处后续会介绍。而在这一层的表,平台会自动将其翻译映射为对应的 Flink DDL 供上层的作业层引用。

● 最上一层的 Job 层,可以重复引用 Meta 层的表,这样除了避免反复构建 DDL,也可以使得 DDL 的变更可以被所有引用的作业所感知。同时平台提供自研的 SET 词法,可以针对不同的任务差异化配置 DDL,比如不同作业 Kafka 的 Consumer Group 是不一样的,可以通过 SET 词法单独配置。当然作业里也可以直接写原生 Flink DDL 语句,支持混合使用,灵活性很高。

下面我们来看看具体的案例

目前平台支持接入数十种数据源,其中大约 10 种数据源的 Catalog 接入到 Flink,这里以 Paimon 为例,作业通过引用对应的数据源编码ID,平台可帮用户翻译生成 Create Catalog 语句,用户可直接在作业中访问 Catalog 中的物理表。

如果要基于数据源创建实时元表,对于可以识别到表结构的数据源比如 MaxCompute,平台会自动帮助用户填充对应的字段快速创建元表。如果用户想在流批一体场景下创建逻辑镜像表,可根据字段进行智能映射,提高效率。这里以 kafka 为例,作业通过引用对应的实时元表名称,平台可帮用户翻译生成对应的 DD L语句,提交到引擎侧运行。

在作业层,还是以 Kafka 为例,对于同一个 Kafka 的 Topic,不同作业可分别 Set 不同的消费组 ID 来针对同一个实时元表进行复用,或者可以根据业务场景 Set 诸如计算列、Watermark 等参数,这些作业的差异化配置,平台都会翻译在最终提交到引擎的可执行 SQL 上,如右图所示,以此来做作业粒度的差异化配置。

用户还可直接在平台上写原生 DDL,平台也支持反向地将 DDL 创建映射为 Meta 层的实时元表。基于元表的体系还有个比较大的好处,就是数据安全,源和表可以针对敏感信息比如密码进行加密,避免 Flink DDL 在 SQL 作业层的明文展示,保护我们的数据安全。

综上这套就是目前平台正在使用的元数据管理体系。

2.3 流批一体建设

当我们把数据也采集上来,元数据也管理起来,就可以基于 Flink 进行业务上的任务开发了。那接下来就流批一体这个典型场景,分享一下在元数据管理体系之上,平台在流批一体建设中所做的一些工作。

首先,Flink 作为一个流批一体引擎,其流任务和批任务还是需要区分模式分别运行。这就造成了开发人员在 Flink 流和批任务两个作业间来回切换,开发体验割裂,容易出现变更遗漏,也就导致数据一致性和质量难以保障。从存储层面来看,流批存储系统隔离,提供的数据服务不一致,维护成本高。

针对这种现状,平台的解法是提供流批一体化的开发模式。首先面向开发人员,只需维护一套代码,由平台根据流批不同的模式翻译为对应的流批可执行 SQL。在这个场景下,对一个流批任务进行变更,流或者批的计算口径会同时变更,解决业务口径不对齐带来的数据质量风险。同时在存储层面提供面向流批一体逻辑镜像表,无论底层是统一存储或流批不同存储,面向 SQL 侧看来是一张表,这张表可对外提供一致的数据服务。

下面我们来看看流批一体的具体的案例

最上层是在上一节提到的元数据体系。通过配置流批一体混合源镜像表关联流和批对应元数据表(上节说过,元表可以是 DDL 或者 Catalog 中的物理表),随后我们可以在一个 Flink SQL 作业中对其进行引用,如下图所示,从这张混合源表中读取数据,根据条件进行聚合计算,写入下游表。

平台侧提供两种模式的开关:

● 纯实时模式,平台根据用户配置的实时参数帮助用户翻译为对应的实时可执行 SQL。

● 纯离线模式,用户可以在当前作业配置离线的调度配置。

当同时打开这两种模式时,用户就开启了“实时+离线”的流批一体模式。用户可以用 Set 语句操作混合源表,如红框所示,实现对流批不同的 Flink DDL 的 With 参数进行差异化配置。也可以设置宏变量对不同的 Where 条件进行分别配置。

依靠平台自研的引擎编译模块:

● 对实时模式对应的 Kafka 表,设置相应的消费组,配置相应的数据偏移过滤条件,最终可生成的如左图所示的可执行 SQL。可以看到 DDL 的 With 参数新增了消费组的配置项,过滤条件被替换为实时的过滤条件。

● 对离线模式对应的 MaxCompute 表,同样能设置离线特有的分区,配置相应的过滤条件,最终可生成如右图所示的可执行 SQL。可以看到 DDL 的 With 参数新增了分区的配置项,过滤条件被替换为离线的过滤条件。

值得注意的是,时间参数 stat_date、bizdate 可以在任务的启动或者调度时按需配置。综上所述就是基于平台解决方案实现的一个 Flink 流批一体场景。

2.4运维体系

在刚才的三个小节,已经介绍过实时计算的采集、管理、建设,接下来最后的关键环节就是如何去运维好 Flink 任务。整个运维体系是支持多调度模式、多集群环境、多引擎版本的。

● 首先是基于 Hadoop 的 Yarn 体系,平台目前支持诸如 CDH、CDP、EMR、TDH 等等的 Hadoop 发行版,用户只要有个 Hadoop 集群,无需自行安装任何的 Flink 组件,由平台提供对应的 Client 帮助用户与集群对接,并帮助管理引擎的多版本。

● 其次还支持像阿里云实时计算 Flink、华为云 MRS Flink 的全托管方式对接。

● 另外平台也支持 K8s 的任务调度模式,Dataphin 部署的时候可内置 K8s 集群,用户可在无 Hadoop 集群情况下享受实时计算的能力。如果用户期望对接自己的 K8s 集群,平台后续也将支持。

基于这种多调度模式、多集群环境、多引擎版本的体系,用户可按需选择适合其的 Flink 运行方式。

好了,当 Flink 任务真正运行起来之后,需要通过全面的监控告警体系来保证业务的正常。对于运行中的 Flink 任务,平台通过 Metric Reporter 和 Log Agent 分别采集任务的运行指标和日志,指标会落到时间序列数据库中进行持久化存储,日志会落到文件存储中,平台提供统一的监控看板供用户查看。

同时用户也可基于相应的指标配置对应的告警策略,告警中心匹配告警事件后通过通知服务多渠道发送消息。用户收到告警,可在监控看板上定位问题,形成闭环。

在这个体系中

● 平台为用户提供丰富的监控指标监控,包括:Checkpoint、Io、Watermark、CPU、Memory、Jvm 等等。

● 提供灵活的报警策略,包括值班表、自定义消息渠道,用户不想被频繁打扰时还支持配置发送次数和报警抑制逻辑。

● 对于异常日志,平台支持 Warn 级别以上日志的定位,方便用户直接定位到具体某个 Jobmanager 或 Taskmanager 上,减少用户的检索时间。

● 指标支持当前到数天内均可查询,一是方便用户进行查看复盘,另一方面后续平台也会尝试去基于历史数据提供 AIops 的能力帮助用户提前感知任务异常。

● 对于 Flink batch 的任务,平台提供整个离线调度链路的基线产出监控,链路上可能包括其他 Hive、MaxCompute、Hologres 等离线任务,破线同样会发出告警。

如上展示的是监控看板中对监控视图和日志采集的一些产品截图。

以上就是 Dataphin 平台对 Flink 在采、管、建、用四个方面所做的一些工作,通过这四方面对 Flink 的能力优化以及平台化的功能建设,让用户在实时计算全生命周期的研发效能和体验有较大提升。

  1. 基于 Flink 的最佳实践

接下来介绍平台基于 Flink 的最佳实践。

这里挑选两个比较典型的场景来给大家做一个分享,一个是特征计算,另一个是湖仓一体。

3.1 特征计算

首先是特征计算,一开始我们做特征计算,使用的明细数据方案,那时候数据量还没那么大,明细数据直接通过 Flink 实时地写入到 Hbase 表中,离线数据从 ODPS 回流 Hbase 中,用于做特征的冷启动加速。

在这个架构中,计算任务只负责导入明细数据到视图中,聚合逻辑全部放在服务任务中。

这个架构的痛点也很明显,当处理热点数据的特征加工时,明细数据在服务端的计算压力很大,上述的方案是无法满足大数据量的计算要求的。

所以后面我们考虑利用分账的思想,将计算任务分别生成日维度、时维度、分维度的固窗数据,提前计算并保存在数据库中,查询任务利用窗口合并的方式,提供滑窗的在线计算能力。

如上图所示,因为提前计算好不同维度的窗口,用户配置任意的窗口,都可以根据天、时、分钟的固窗数据进行增量聚合来计算出最终结果。在这里我们自己对 Flink 的 State 进行管理,并新增了很多自研算子,像偏度、峰度等等。通过自研的算子 Operator 去适配这种自定义的窗口触发方式。

基于刚才的解法,我们称其为预计算方案。Flink 依赖算子拆分日、时、分维度计算并存储于 Hbase,配置不同滑窗的特征批量生成,基于 Hbase 查询及内存计算,这样在服务端聚合计算的是已经预聚合后的数据,计算压力大大减少。

再后来,我们想能不能直接利用 Flink 算出结果,并利用其批处理的能力做到流批一体的冷启动,所以后来我们提出了全计算方案。直接服务端进行划窗聚合,写入 Hbase。这样集群的存储数据量相比于预计算会更低,服务端的聚合查询性能由于是点查会更好,但同样的 Flink 任务计算端消耗的资源会相应增加。

如上图下半部分是预计算方案和全计算方案的一个对比表格,可以看出有几个区别:

一个是特征开发的灵活性,对预计算方案来说,依赖自研的状态算子按照天、时、分来做预计算,开发成本高。对于全计算方案来说,除了可依赖自研算子外还能用官方的滑动或固定窗口进行计算,灵活性更高一点。

另一点特征快上的能力来说,对于特征口径的更改,预计算方案对任意窗口的计算放在服务端,可以及时生效。而全计算方案在Flink端,需要操作任务来让配置生效,快上的能力预计算更优。

最后从计算成本来说,全计算方案相对于预计算方案,Flink 新增资源消耗会比 Hbase 的存储减少来说,成本更高。

下面分析一下基于自研状态算子和官方滑动窗口的性能对比。场景是以交易TT流为输入,Hbase 为存储输出,每5s触发过去24小时的滑动窗口计算任一卖家累计销售金额。方式是通过回拉 3 天点位进行性能压测。从端到端延迟、任务 RPS、资源消耗、下游 Hbase 写入IO、State、Checkpoint 等多个维度进行对比,这里只展示其中差异比较典型的三个,分别是回追过程中的数据时延、任务 Failover、Checkpoint 大小以及成功率。从监控图表对比可以看出,自定义状态算子相较于官方窗口,有更快的数据回追能力,因为官方窗口的滑动窗口是不共享状态的,而全计算方案针对同一份天、时、分计算状态进行聚合。导致官方窗口在任务中有过多窗口处于计算中,Checkpoint 一直无法成功打上,导致作业频繁的 Failover,无法有效输出最终的计算结果,作业延迟无法追上。也就使得自研状态算子,有更优的作业稳定性,更好的 Checkpoint 成功率。

以上就是我们在特征计算中的一些实践。

3.2 湖仓一体

另一个实践场景是基于 Flink 的湖仓一体,首先我们来看一下整体的架构体系。

结构化、半结构化、非结构化的数据一方面可通过基于 Flink 或者 DataX 的集成方案写入数据湖中,另一方面也提供 OpenAPI 的方式将数据写入湖的底层存储中。

我们以 Paimon 数据湖为例,底层的存储根据云环境和部署形态,可选择基于 HDFS 或者 OSS。按业务可分为 ods、dwd、dws 层,分层之间可通过 Flink 进行数据处理,也可通过其他离线引擎分开处理。

● 湖的上层提供诸如 Hive、Spark、Presto 等等的 Adhoc 即席查询能力,提供不同场景的取数需求。

● 湖中的表、字段、血缘经过解析都会进入到资产中,进行统一的元数据管理。

● 湖中的数据可通过数据服务对外提供服务,提供给诸如实时报表、实时监控、算法服务等等应用中。

目前的湖仓架构,基于 OSS 或 HDFS 的文件系统,有三种数据湖 Paimon、Iceberg、Hudi 的可供选择。

外层对接的数据源按类型分为6大类存储,分别是:

● 文件系统,如 Oss、Hdfs 等

● 消息队列,如 Kafka、Mq 等

● 大数据存储,如 Hive、Maxcompute、Impala 等

● 关系型数据库,如 Mysql、Pgsql、Oracle 等

● NoSQL,如 Hbase 等

● 用户还可以自定义数据源,提供自定义 Flink Connector 接入平台

对数据来说,不流通无价值,无价值不流通。在整个湖仓一体体系中,基于 Flink 与 DataX 架构的数据同步能力,我们让数据很方便地流通起来,使得数据入湖、数据出湖、湖与湖之间、仓与仓之间的数据流转变得简单高效。如此跨源支持数据流通,就可以轻松汇集和保存海量业务数据。

但再方便的入湖工具,历史数据都有迁移成本,无论是人力成本、数据验证成本等等。所以当用户觉得历史数据太重,不想完整地把仓的数据迁移到湖中的时候,我们的解法是增量数据将其入湖,历史数据不迁移,平台侧提供统一引擎服务智能识别分区。

如上图左半部分所示,将数仓里的 Hive 表和湖的 Paimon 表形成一张混合源的表,One Service 查询引擎对这张混合源的表进行查询。

如右图所示,假设这张混合源的表我们将其命名为 ods_orders,用户可以写如下语句,select * from ods_orders where dt = 某个分区,One Service 查询引擎判断混合源表的分区界限,当 dt 小于迁移时间时,继续查询原 Hive 表返回结果。当dt大于迁移时间时,查询新 Paimon 表。这样对用户的体验来说,他面向的还是一张表,用数取数的体感和一张表还是比较一致的。但是对于跨分区区间查询的场景,还是更推荐用户通过上一节讲的平台同步工具将数据完整入湖。

以上就是我们在湖仓一体中的一些实践,湖仓一体场景也是我们一直在实践优化的一块,后续也会持续在这个场景上发力探索,敬请期待。

  1. 未来规划

接下来分享一下平台的未来规划。

一个是行业解决方案方面,我们会优先将湖仓一体的场景深化落地,在产品上做成完整的解决方案面向客户,让客户能在 Dataphin 平台上便捷高效地构建起自己的湖仓架构。

第二个是平台功能完备,首先会继续丰富 Flink CDC 支持的数据源,覆盖更多客户的场景。第二个是建设全域元数据中心,在前面有介绍过我们的元数据体系,后面我们会将 Dataphin 平台内,即域内;平台外的元数据体系,指域外。将域外和域内进行打通,形成全域元数据中心,以形成更全面的表、字段的血缘体系。

第三个是持续进行体验优化,首先我们期望的是对于一条实时数据从上游往下游发送的过程中,平台能帮助用户定位到其具体的流向位置,方便纠错排查。其次,在运维体系上,期望能基于一些典型的异常场景,通过智能诊断,做到预警,在可能的异常要发生前就能将告警发出通知到用户。

本文转载自: 掘金

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

一起长锈:2 什么神器能确保Rust构建稳定可靠?(从Jav

发表于 2024-04-26

讲动人的故事,写懂人的代码

  • 故事梗概:
  • 在她所维护的老旧Java系统即将被淘汰的危机边缘,这位在编程中总想快速完事的女程序员,希望能转岗到公司内部使用Rust语言的新项目组,因此开始自学Rust;
  • 然而,在掌握了Rust编程知识之后,为了通过Rust项目组的技术面试,使得转岗成功而不至被裁员,她必须领会编程如何”快速”才能有真正的意义。

上回的故事里,我们的Java程序员赵可菲和C++程序员席双嘉,在Rust大神贾克强的指导下,一起掌握了rustup工具链的用法。

接下来,他们准备把Rust书里的Guessing Game的需求稍作修改,然后重新写一遍代码,这样就能更好地学习Rust的基础知识了。

coraliewissing_In_a_bright_and_spacious_meeting_room_three_youn_36b4018b-7338-4db5-8f83-1c9cf4ae7874-xiaohongshu.png

2.1 猜骰子冷热游戏需求

那么,该如何调整书中的Guessing Game游戏的需求,让它既好玩又可以让大家练习书中的知识点呢?

贾克强突然想起了公司新近部署的AI编程助手艾极思。他想,也许可以问问它。

于是,贾克强对艾极思说:“我希望你能根据下面《The Rust Programming Language》书中的游戏描述,帮我改编一下需求,设计一个新游戏。新游戏要趣味十足,而且要保留原游戏的关键行为,让我在重写代码的过程中,可以练习到书中的主要知识点。”

“书中原游戏的描述是这样的:这是个猜数字的游戏,首先,程序会生成一个1到100之间的随机整数。“

”然后,程序会提示玩家猜这个数。“

”玩家输入他们所猜的数字后,程序会告诉他们这个猜测是偏低还是偏高。如果猜对了,游戏就会打印一条祝贺信息然后结束。”

艾极思马上回复了:“根据《The Rust Programming Language》书中的原游戏,我们可以设计一个新游戏,既保留了原有的基本逻辑,也加入了一些有趣的元素和挑战。“

”这样你在练习编程的同时,也可以享受游戏的乐趣。以下是新游戏的设定:”

“新游戏叫做:猜骰子冷热(Dicey Temperatures)”

“游戏规则是这样的:”

“1 首先,程序会生成两个骰子的点数之和,这个数是2到12之间的随机整数。”

“2 然后,程序会提示玩家猜两个骰子的点数之和。”

“3 如果玩家第一次输入点数之和就答错了,程序就会提示‘第一次猜就错了哦’。”

“4 如果玩家第二次或之后输入点数之和也答错了,但这次猜测比上次更接近正确答案,程序就会提示‘更热了’。”

“5 如果离正确答案更远了,程序就会提示‘更冷了’。”

“6 如果距离正确答案的距离和上次一样,程序就会提示‘不冷不热’。”

“7 如果玩家猜对了,程序就会提示‘恭喜你,猜对了!’”

大家看到艾极思的回复,都觉得这个新游戏真的很好玩。

接下来,就可以开始新的项目了。

2.2 如何创建一个Rust新项目

赵可菲:“创建新项目真是小菜一碟。书里写的超详细的,直接运行cargo new diceytemperatures就搞定了。”

2.3 Rust语言的命名风格

贾克强指着赵可菲的屏幕说:“等等。项目名要用锈族的snake_case风格哈。”

“其实嘛,Rust在英文里就是铁锈的意思,所以我们国内的朋友们就直接叫Rust程序员为锈族啦。“

”而且你知道吗,国外的Rust程序员他们自己都爱叫自己甲壳族(Rustaceans),因为这个词跟甲壳生物Crustacean差不多嘛。“

”有一些外国的程序员朋友就把那个橙色的螃蟹Ferris当做Rust程序员的非官方吉祥物了(如图2.1)。“

f_2.1.svg.png

图2.1 有一些外国的程序员朋友就把那个橙色的螃蟹Ferris当做Rust程序员的非官方吉祥物

“锈族或者甲壳族,对于所有的变量名、方法名、函数名、项目名、包名和模块名,我们都喜欢用snake_case风格哟。只有类名,我们才会用PascalCase。”

“snake_case风格,很简单明了,就是所有的单词都是小写,用下划线连接起来。”

赵可菲:“哦,我可能需要一点时间来适应这个锈族的习惯。”

她一边说,一边把命令改成cargo new dicey_temperatures。

1
2
java复制代码 zkf@mbp  ~  cargo new dicey_temperatures
Created binary (application) `dicey_temperatures` package

“在Java的世界里,给项目起名字,真没有什么硬性规定。”

席双嘉:“C++这边也是如此,没有定论。”

席双嘉告诉赵可菲:“咱们搞定新项目后,就用git提交一次,怎么样?每改一点点就提交一次,这样就能明显看出哪些文件变了。”

赵可菲回应:“这主意不错。不过,我可没那么有耐心。这个提交的事你来吧。”

“没问题。“席双嘉接过键盘,顺手就用git提交了代码。

赵可菲接着又输入了cargo run来启动程序,屏幕上出现了“Hello, world!”。

2.4 确保构建稳定可靠的Cargo.lock文件

“看!”席双嘉一边指着屏幕一边说,“终端窗口提示符的颜色,从绿变黄了。这就意味着代码在上次提交后有点变化。”

赵可菲:“但是我们只是运行了程序,代码应该没动呀。”

席双嘉敲了下git status -uall,这样就能显示出所有未被git跟踪的文件。

屏幕上出现了一个名叫Cargo.lock的文件。

1
2
3
4
5
6
7
8
9
10
vbnet复制代码 zkf@mbp  ~/dicey_temperatures  ↱ main  git status -uall
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
(use "git push" to publish your local commits)

Untracked files:
(use "git add <file>..." to include in what will be committed)
Cargo.lock

nothing added to commit but untracked files present (use "git add" to track)

席双嘉:“看,只要一运行cargo run,Cargo.lock文件就被自动创建出来了嘛。”

他随便点开了这个文件。

1
2
3
4
5
6
7
ini复制代码# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3

[[package]]
name = "dicey_temperatures"
version = "0.1.0"

赵可菲:“嘿,这个文件需要提交到版本库吗?”

贾克强:“对的,Cargo.lock文件得提交到版本库,让我们的构建更稳定和可靠。”

“就像咱们程序员最怕的那种情况,明明在自己这儿代码运行得好好的,但怎么在测试环境就犯傻了。”

“许多时候,这就是因为开发环境和测试环境不一致。”

“也就是说,虽然构建的是同一份代码,但由于环境差异,开发环境能跑的包在测试环境再构建后就跑不通了。这两个包本身就不一样。”

“Cargo.lock文件就是为了解决这个问题。”

“当你运行 cargo build 时,Cargo 会查看一下 Cargo.toml 文件,看看哪个版本的依赖项最合适。”

“然后它会把这些版本写入 Cargo.lock 文件。一旦有了这个文件,Cargo 会忽略所有的更新,只参考这个文件。”

“除非你想更新。那就得用 cargo update。”

“这个机制就保证了我们构建的包,无论过多久或是谁去构建,都是一致的,保护我们的项目不被新版本的依赖项带来的问题影响。”

赵可菲:“但我们并没有运行cargo build命令呀。”

贾克强:“哈哈!你们刚才运行的cargo run命令呀。“

”它会先执行cargo build来编译你的项目。如果编译成功,cargo run接着就会运行编译后的二进制文件。”

贾克强话音未落,席双嘉已经把Cargo.lock文件提交到版本库了。

2.4.1 Java世界如何确保构建稳定可靠

赵可菲笑着说:“在Java的世界里,要实现类似Rust中Cargo.lock的功能,我们得靠Maven和Gradle这两大神器了。”

“Maven就是通过pom.xml文件来管理项目的依赖。“

”要锁定依赖版本,保证我们构建的东西能稳稳的运行,Maven通常会在<dependencyManagement>里头指定依赖的具体版本,或者用Maven Enforcer插件之类的外部工具。“

”虽然Maven没有直接类似于Cargo.lock的文件,但我们可以在pom.xml中明确所有版本,并利用<dependencyManagement>来锁定它们。”

“此外,Maven的发行版和快照机制,也能分别帮我们管理稳定构建和开发构建。”

“然后是Gradle,它通过build.gradle文件来配置依赖。”

“和Maven一样,Gradle原来并不会自动产生锁文件,不过我们可以通过依赖约束等策略来达到类似的效果。”

“从Gradle 4.8版本开始,它引入了依赖锁文件的概念,允许我们开发者明确锁定版本。”

“只要运行gradle dependencies --write-locks命令,Gradle就会生成一个锁文件,这个文件会固定依赖的版本,这在功能上就像Rust的Cargo.lock一样,保证了不同环境和时间下构建结果的一致性。”

2.4.2 C++世界如何确保构建稳定可靠

席双嘉:“嗨,你知道吗?在C++的世界里,我们也有类似Rust中的Cargo.lock机制,就是用Conan这个小工具。”

“Conan,这可是专门为C++量身打造的包管理器哦,它能帮我们处理所有的依赖和版本控制问题,让项目构建得稳稳当当。”

“用Conan的话,它会给我们生成一个叫做conan.lock的文件,这个玩意儿和Rust的Cargo.lock差不多。”

“这个conan.lock文件的作用就是把项目依赖的版本给锁定住,这样无论在哪个环境下构建,依赖都能保持一致。”

“这样一来,就能避免因为依赖版本不同,在开发、测试和生产环境中出现的那些麻烦事儿。”

“虽然CMake本身并没有内建的生成锁文件的功能,但它可以跟Conan这样的包管理器搭个档,通过Conan来管理依赖和版本,也就能间接实现锁定机制了。”

“在CMake的项目里,你可以在CMakeLists.txt文件中包含Conan的配置,然后通过链接Conan管理的库来构建应用程序。”

2.5 小结

两位程序员在Rust大神的带领下,决定给原有的Rust编程书籍中的”Guessing Game”游戏需求来点变化,重新操刀代码。

他们找了个AI编程小助手艾极思,把游戏需求改头换面,变成了一个新的、好玩儿的游戏“猜骰子冷热”。

这个新游戏不仅保留了原游戏的精髓,还加入了新的元素和挑战,让编程学习变得更加有趣。

他们用 cargo new 命令创了个新的Rust项目,还学习了Rust语言的命名风格。

命名风格 Rust/Python Java/Kotlin/Scala C/C++
Class Name PascalCase PascalCase PascalCase
Method Name snake_case camelCase camelCase
Variable Name snake_case camelCase snake_case
Function Name snake_case - snake_case
Project Name snake_case - -
Package Name snake_case lowercase -
Module Name snake_case - -

项目搞定后,他们咔嚓一下cargo run,程序就跑起来了。你会发现,跑这个命令会自动弄出个Cargo.lock文件。

这小文件可是厉害了,它能保证我们构建的稳定性和一致性呢。Java和C++的世界里,也有类似的东西,比如Java的Maven和Gradle,C++的Conan等等。

语言 机制 文件 特性
Rust Cargo Cargo.lock 锁定依赖版本。通过cargo build或cargo run自动创建和更新。
Java Maven/Gradle pom.xml/build.gradle 通过Maven的和Gradle的依赖约束来锁定依赖版本。Gradle 4.8+可以生成一个锁文件。
C++ Conan conan.lock 锁定依赖版本。与CMake一起管理依赖和版本。

如果你想要了解Rust是如何通过超越传统赋值语句的binding,实现不变性、模式匹配和所有权设计理念的,那就关注我,继续看下去吧!

【未完待续】


如果喜欢我的文章,期待你的点赞、在看和转发。

如果不喜欢,在评论区留个言告诉我哪里不喜欢呗~

本文转载自: 掘金

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

论文解读-面向高效生成大语言模型服务:从算法到系统综述 一、

发表于 2024-04-26

一、简要介绍

在快速发展的人工智能(AI)领域中,生成式大型语言模型(llm)站在了最前沿,彻底改变了论文与数据交互的方式。然而,部署这些模型的计算强度和内存消耗在服务效率方面带来了重大挑战,特别是在要求低延迟和高吞吐量的场景中。本调查从机器学习系统(MLSys)研究的角度,解决了对高效LLM服务方法的迫切需求,这是先进人工智能创新和实际系统优化的关键。论文提供深入的分析,涵盖了一系列的解决方案,从尖端的算法修改到系统设计的突破性变化。该调查旨在全面了解高效LLM服务的现状和未来发展方向,为研究人员和从业人员克服有效部署LLM的障碍提供有价值的见解,从而重塑人工智能的未来。

二、背景

2.1基于transformer的LLM

其中,dk是键的维度。这种机制允许模型为输出的每个元素关注输入序列的不同部分,捕获复杂的依赖关系,而不管它们在输入序列中的距离如何。 Transformer的另一个重要结构是前馈网络(FFN),它存在于transformer的每一层,对其计算强度有重要贡献。FFN通常由两个线性变换组成,中间有一个非线性激活函数,通常表示为:

这里,W1、W2、b1和b2是FFN的可学习参数,而非线性函数max(0,·)(ReLU,在本例中)在模型中引入了必要的非线性,允许它学习更复杂的模式。FFN负责模型的参数计数的很大一部分,因此,还负责它的内存占用和计算负载。在每个transformer层中,在多头注意(MHA)聚合了来自输入的不同部分的信息后,FFN为每个位置独立地处理这些聚合的信息。这种并行处理能力是transformer的一个关键优势,允许它有效地处理序列。然而,这也意味着计算负载和内存需求随着输入序列的长度和网络的深度而变化。 在基于transformer的LLMs中,自注意和FFN的结合使这些模型能够捕获广泛的语言上下文和细微差别,并在各种NLP任务中设置新的基准。然而,对训练和推理的大量计算需求已经成为一个关键的研究领域,重点是在不显著影响性能的情况下优化这些方面。transformer模型还包括其他关键组件,如位置编码,位置编码添加了关于每个token在序列中的位置的信息,以及多头注意机制,它允许模型在不同的表征空间中关注序列的不同部分。

2.2 GPU和其他加速器

llm的快速发展在很大程度上要归功于GPU架构和其他加速器的发展,这是提高模型性能和效率不可或缺的一部分。GPU(图形处理单元)已经成为这一领域的基石,主要是由于其优越的并行处理能力。与传统的为顺序处理而设计的CPU不同,GPU由数千个小型、高效的核心组成,它们被设计用于同时处理多个任务。这使得它们非常适合于在深度学习计算中无处不在的矩阵和向量操作,特别是对于基于transformer的模型。 一个典型的GPU架构包括一个流式多处理器(SMs)阵列,每个多处理器包含几个核心,它们共享一个共同的指令单元,但可以并行执行独立的线程。此外,每个SM中的共享内存(SRAM)允许线程之间的高效数据交换和同步,显著地优化了LLM计算中所需的内存访问模式。这种设计特别有利于llm中的计算密集型任务,如transformer中的自注意网络和前馈网络的计算。GPU还配备了高带宽内存(HBM),这允许更快的数据传输速率,显著减少了在大规模计算过程中与内存访问相关的瓶颈。此外,最新的GPU架构,如NVIDIA的Ampere和Hopper架构,继续提供增强和推动LLM计算的边界,如改进内存带宽和容量,更高的浮点运算(FLOPS),专门的混合精度计算单元(即张量核心)和更有效的资源利用,进一步加速LLM的性能。其中一些支持各种精度格式,包括FP32(32位浮点)、TF32(TensorFloat-32)、FP16(16位浮点)、BF16(脑浮点),甚至INT8/INT4,允许在计算速度和数值精度之间进行灵活的权衡,这对优化LLM性能至关重要。 除了 GPU, LLM部署已经探索了大量的硬件平台,包括 CPU, mobile and edge devices , ASIC,以及专门的加速器如TPU, FPGA,和来自不同制造商的其他新兴人工智能芯片(比如 Apple M2 Ultra, AWS Inferentia, SambaNova, Cerebras, Graphcore IPU)。这项调查主要强调了基于GPU使用的研究,而一些技术动机推动了这一重点。由于其架构创新和卓越的计算能力,GPU在过去的几年中主导了大规模深度学习的研究领域。此外,GPU的编程语言,如NVIDIA的CUDA和AMD的ROCm,有助于对线程层次结构的细粒度控制,允许研究人员利用GPU中固有的大规模并行性。它吸引了大量开发人员在这些GPU之上构建成熟的软件生态系统,促进了大部分开创性和先进的LLM研究。虽然其他硬件平台确实为特定的上下文带来了独特的优势,但以GPU为中心的大量研究、开发和部署库使其成为深入理解LLM推理方法的不可或缺的参考。考虑到硬件的相似性,其他硬件平台也可以从本调查中讨论的设计哲学、见解和方法中获益。

2.3 LLM推理

LLM推理,特别是在像GPT(Generative Pre-trained Transformer)这样的模型中,通常采用自回归解码方法。这种方法是这些模型如何生成文本的核心,它确保生成的每个新单词或标记都要考虑到迄今为止生成的整个序列。自回归解码的原理是顺序预测一个序列中的下一个token,给定所有之前的标记,如算法1所示。

这种自回归方法是LLM推理的基础,用于生成连贯的和上下文适当的文本。它确保生成的每个token都以对之前生成的所有内容的全面理解为条件,允许llm生成高度相关和流畅的文本序列。先前的研究对基于transformer的LLM推理的算法强度(如计算失败、I/O和内存消耗)进行了深入的分析,并根据自回归解码算法执行的成本估计(如建模推理延迟)进行了广泛的经验分析。LLM推理的优化是一个复杂的问题,因为不同的算法配置和系统设置可能存在不同的优化策略。

2.4挑战

延迟和响应时间。高效的大型语言模型推理需要实现低延迟和快速的响应时间,特别是在诸如聊天机器人、虚拟助手和交互式系统等实时应用程序中。平衡模型的复杂性和推理速度是一个关键的挑战,需要优化算法和系统架构,以在不降低精度的情况下最小化响应时间。

内存足迹和模型大小。由于大型语言模型的大小和包含的大量参数,因此需要大量的内存需求。在内存受限的设备上部署这样的模型是一个挑战,它需要开发有效的模型压缩技术和系统优化,以在不牺牲性能的情况下减少内存占用。 可伸缩性和吞吐量。推理系统在生产环境中经常面临不同级别的请求负载。为了确保可伸缩性和高吞吐量以有效地处理多个同步请求,需要并行计算、请求调度和其他系统级优化,以有效地跨资源分配计算工作负载。

硬件兼容性和加速性。有效地利用硬件资源对于大型语言模型推断至关重要。将LLM模型适应于不同的硬件平台和架构,包括CPU、GPU和专门的加速器,需要对硬件感知的算法设计和优化,以充分利用底层硬件的潜力。

准确性和效率之间的权衡。优化LLM推理的效率有时可能涉及到与模型精度的权衡。在模型大小、计算复杂度和性能之间取得正确的平衡是一项具有挑战性的任务,需要仔细考虑和评估各种算法和系统级技术。

三、分类

现有的提高LLM服务效率的工作可大致分为两类,包括算法创新和系统优化,将单独讨论。

3.1算法创新

本节全面分析了为优化语言模型推理效率所提出的各种算法和技术。这些工作是为了通过算法的改进来解决大规模transformer模型的固有性能缺陷。

3.1.1解码算法。

在本节中,论文将回顾如图2所示的优化llm推理过程的新解码算法。这些算法旨在降低计算复杂度,提高生成任务中语言模型推理的整体效率。

非自回归解码。现有llm的一个主要限制是默认的自回归解码机制,它一个一个地生成输出token。为了解决这个问题,一个具有代表性的工作路线是放弃自回归生成范式,且并行解码输出token。非自回归解码,通过打破解码过程中的单词依赖性,并假设有一定程度的条件独立性来实现机器翻译加速。为了缓解翻译质量的降低,一些后续研究,如半自回归解码,进一步扩展了这些非自回归方法,通过建模输出依赖关系或迭代细化输出token来达到自回归模型质量。顺时针并行解码在基本LLM中插入一个前馈层,以并行地对多个未来位置进行预测,然后返回到由基本模型验证的最长前缀。然而,这些方法需要使用新的依赖项复制新的LLM或调优原始LLM的部分层,但这并不总是可能的。最近的一些努力已经致力于在一个解码步骤中生成多个token,而无需对模型进行任何训练或修改。

推测解码。另一项工作通过利用投机执行和改进解码并行性来解决顺序执行限制。自回归LLM推理过程中的每个解码步骤都可以被视为带有条件分支的程序的执行,例如决定下一个生成哪个token。推测解码提出首先以有效的方式对多个步骤进行解码预测(例如,使用较小的草案模型和较少的模型参数),并与LLM同时验证这些预测。然而,在将推测解码应用于llm时,仍然存在一些实际的挑战,例如,如何使解码预测足够轻和准确,以及如何使用llm实现有效的并行验证。SpecInfer首先通过引入多个小草案模型和基于树的推测推理和token验证机制来解决这些挑战,并提出了一个低延迟的LLM服务系统实现。推测解码的主要优点是,它在不改变任何输出的情况下增加了并行性。这种保证来自于预测的输出总是由原始的LLM来验证,并且当预测出错时,回退机制就会生效。

早期退出。其他一些研究试图利用现有llm的深层多层架构,并利用早期退出的机制来加速解码过程。直觉是,早期模型层的输出有可能自信地推断出目标分布。它们可以基于内部分类器发出预测,而不是运行整个LLM,并且多个工作已经探索了各种退出条件。它们也被自适应计算调用,因为它们调整每个请求的计算量,以分摊总推理成本,即,为更容易的推理请求花费更少的计算。总的来说,这些方法大多局限于内部表征所携带的信息不足,而且可能不能忠实地做出准确的预测。

级联推理。由推理请求的不同复杂性所驱动,级联推理使用了一套不同尺度的llm来最小化响应时间。CascadeBERT不是直接对每个查询使用大模型,而是涉及一系列对应于不同模型深度的内部分类器,以级联的方式组织它们,并根据实例的难度自适应地选择合适的分类器。Tabi优化服务于判别模型(即,不是生成的llm),但它采用了类似的方法来合并小模型和llm,以不同的置信度处理查询。FrugalGPT利用了一种基于学习的方法,自适应地将查询分配给不同的LLMapi,从而优化了成本和性能。一个最新的工作联合优化了模型多路复用和查询缓存,并分析了最小化推理成本的最优性。思想混合将级联思想扩展到LLM推理任务,以节省成本,它从思维链和思维程序提示中抽取答案。总的来说,级联推理是提高推理效率的一个很有前途的方向,但设计一个精确的调度机制以避免影响模型质量仍然具有挑战性。

3.1.2架构设计。 本小节探讨了为大型语言模型量身定制的创新架构设计。研究人员提出了超越原始transformer的新模型架构,它在模型大小、性能和效率之间取得平衡,为更快和资源高效的推理开辟了新的途径。

配置缩减:为了降低LLM推理的计算成本,一个简单的方法是缩小模型配置,例如使用浅层编码器或解码器、权重共享和词汇表缩减。然而,减少模型参数的数量也会影响下游任务的性能。

注意力简化:与自注意计算相关的一个突出挑战是计算复杂度O(L2),它与输入序列长度成二次比例。许多transformer变体已经被提出,以将标准注意力简化为非常长的序列任务的更有效的替代方案,如稀疏化、核化和因子分解。最近,有一种趋势借用的想法从先前的注意力简化方法,概括和结合他们缩短上下文和减少KV缓存的大小,以及注意力的复杂性,稍微解码质量下降(例如,滑动窗口注意,基于哈希的注意力,扩张注意)。这些方法的一类是通过将上下文压缩为更少的软token的上下文压缩(例如,替换摘要标记或标志性token,利用额外的自动编码器方案),或根据不同的重要性指导(或称为语义压缩)直接删除或重新措辞不重要的上下文标记(称为语义压缩)。例如,自适应稀疏注意采用了一种基于学习的方法来动态地消除每个标记的无信息上下文标记。Scissorhands和H2O选择了一些可能对未来的解码过程有重大影响的重要token,并保存了它们的KV缓存。简化LLM的初始token值,并使用滑动窗口来维护它们,这也类似于之前的工作。FastGen允许不同的注意力头自适应地使用不同的强调模式。表1说明了四种具有代表性的方法及其应用的稀疏注意模式。然而,由于上下文的不完整,这些方法在注意力分布更复杂的实际工作负载中可能面临不可避免的信息丢失。

激活共享:另一个方向是共享中间激活,以提高注意力计算效率。注意共享方法观察不同层的注意矩阵分布之间的相似性,并重复使用这些注意矩阵,以降低计算成本。多查询注意(MQA)使不同的头共享一组密钥和值,以减少增量推理中的内存带宽需求。组查询注意(GQA)将单个键和值限制放松到多个集,每个集合都与一组查询耦合。它们已经被最近的几个公共LLM成功采用,并显示出其优越的性能,包括基于MQA的模型如Falcon、PaLM、ChatGLM2- 6B和基于GQA的模型如LLaMA-2和Mistral-7B。

条件计算:稀疏激活的专家混合(MoE)范式将模型的容量划分为不同的“专家”,这些“专家”是较小的神经网络,每个专家专门用于数据的不同子集。它允许系统仅为基于某些路由机制的给定输入调用必要的专家,而不是计算整个大型模型,从而产生计算和内存效率。例如,TaskMoE 说明了与token级对应路由相比,任务级路由能够增加模型的容量,同时提高了推理吞吐量。随着llm的不断增长,MoE体系结构是一个很有前途的途径,可以确保未来llm的可扩展性和效率。同时,MoE的动态特性也要求分布式通信和GPU内核实现进行特殊的系统优化,以提高MoE推理效率。

循环单元:虽然循环神经网络(RNN)(如LSTM)倾向于难以捕获序列中的长期依赖关系,但仍有几种方法使用循环单元来取代transformer模块,并在推理过程中实现线性计算和内存复杂性,如RWKV和RetNet。具体来说,与之前的方法不同,这些最近的探索大多建立在线性注意(即线性transformer,无注意transformer)表示之上。改革后,他们通过建模具有线性递归单元(如状态空间模型,LRU )的标记之间的交互,克服了O(L2)的注意瓶颈,这更容易保持并行训练特性。他们的设计还由不同的位置编码模块、指数衰减机制和一堆标记级的非线性MLPs 或GLUs 组成,以提高模型的表示能力。最近,他们在模型性能和计算效率方面都显示出了良好的结果。然而,循环单元能否成功地取代llm中的transformer仍然是一个悬而未决的问题(即,特别是对于长序列)。

3.1.3模型压缩。

在这里,论文深入研究了模型压缩技术,其目的是通过创建更高效、更紧凑的模型而没有显著的性能损失来减少llm的内存占用和计算需求。

知识蒸馏:知识蒸馏是一种工作方式,它在一个大型教师模式的监督下训练一个小学生模式。这个方向的大多数方法都是探索白盒蒸馏,这需要访问整个教师模型参数。由于基于api的LLM服务(如ChatGPT)的出现,一些黑盒蒸馏模型吸引了大量的关注,如 Alpaca、Vicuna 、WizardLM等。这些模型通常具有较少的模型参数,但与原始的llm(如GPT-4)相比,在各种下游任务上显示出了良好的性能。

网络剪枝:网络剪枝方法在过去的几年中得到了广泛的研究,但并不是所有的方法都可以直接应用于llm。必须考虑到与再训练相关的潜在的过高的计算成本,并评估修剪是否基于底层系统的实现在推理效率方面产生可识别的收益。最近的一些方法在LLM上应用了结构剪枝方法,它删除了整个结构化的LLM组件,促进了有效的GPU加速。例如,Deja Vu在不修改预训练模型的情况下,切断了由上下文稀疏性假设引导的特定注意头和MLP参数。非结构化方法方面也有一些最新的进展,它对于LLM压缩通常能达到50-60%的稀疏性。值得注意的是,它们可以进一步推广到半结构化的N:M稀疏性(即2:4和4:8),从而通过NVIDIA稀疏张量核的加速实现显著的推理加速。LoSparse和DSFormer使用小密集和稀疏半结构矩阵的近似模型权值。Flash-LLM通过为使用张量核的非结构化修剪提供了一个内存高效的SpMM实现,从而放松了这一需求。PowerInfer假设这些稀疏激活的神经元的倾斜访问,并提出了一个GPU-CPU混合推理引擎,使GPU和CPU处理不同的神经元。

3.2系统优化

本节研究了LLM推理系统优化技术,以在不修改LLM计算语义的情况下加速LLM推理。这项工作的目标是通过改进用于大型语言模型推理的底层系统和框架来提高系统效率。

3.2.1低位量化

本节探讨了最先进的低位量化技术,使其能够有效地表示模型权重和激活。通过使用更少的位(即小于32)来表示数值,这些方法显著地减少了内存消耗,并加速了硬件平台上的推断。其中一种方法是对LLM进行量化,这些量化方法可以简单地分为两个方向:量化感知训练(QAT)和训练后量化(PTQ)。PTQ降低模型权重的计算精度甚至激活INT8或INT4通过使用定制CUDA内核或编译效率效益,如W8A16(即INT8重量量化和FP16或BF16激活),W4A16 GPTQ,W8A8 SmoothQuant和W4A4。硬件的发展也满足了这些要求。一个支持证据是NVIDIA最近的架构像Turing和Ampere包括INT8和INT4张量核心,和最新的Hopper架构禁用INT4支持但引入FP8张量核更好的数值精度(例如,与FP32相比,FP8的H100 GPU可以达到60×TFLOPS)。现有的方法通常采用各种量化函数,包括均匀方法(即 Round-to-Nearest)和非均匀方法。为了缓解低精度带来的性能损失,QAT在模型训练中集成了量化。值得注意的是,由于底层系统实现中的挑战,与传统的FP16等精度水平相比,低精度的量化方法可能会导致较慢的推理速度。虽然低精度的方法显著降低了模型部署的资源需求,但也有研究表明,由于尺度律的存在,量化方法可以对模型的推理性能产生显著影响。此外,量化还被应用于上下文压缩(例如,CacheGen)和内存高效的微调(例如,QLoRA,PEQA),从而降低了LLM推理的内存消耗。

3.2.2并行计算。

本节将研究为大型语言模型量身定制的并行计算策略。利用现代硬件架构的并行处理能力,这些方法将计算分布到多个核心或设备上,从而在推理过程中大大加速。

模型并行性:大多数模型并行性方法首先是用于大规模dnn的分布式训练的,特别是基于transformer的模型。例如,张量模型并行性(TP)将模型层(如注意力、FFN)从内部维度(如头部、隐藏)分割成多个部分,并将每个部分部署在一个单独的设备(如GPU)上。它可以通过并行计算显著减少推理延迟,并行计算被广泛应用于同一台机器内的多个GPU,特别是对于具有高速NVLink连接的场景。PaLM推理通过涉及二维张量并行性,扩展了大规模transformer推理上的TP,并声称对于大集群(超过256个设备)的理论通信复杂度更低。对于只有一个头表示键和值的多查询注意,它进一步涉及到混合张量划分策略的数据并行性。管道模型并行(PP)跨多个设备按顺序排列模型层。每个设备都负责一个由多个连续的模型层组成的管道阶段。虽然PP可以显著增加单位时间处理的输入数量(吞吐量),但它并不会减少像TP那样减少从开始到结束处理单个输入所花费的时间(延迟)。序列并行性(SP)具有多种不同的设计和实现,但其LLM推理的关键思想是通过沿着序列长度维度分割多个GPU来分配计算和存储负载。不同的并行性技术引入了不同程度的通信开销和计算延迟。为了实现最佳的性能和资源利用率,自动并行性已经被先前的分布式训练方法(如Alpa,FlexFlow,Galvatron)广泛研究。通过替换他们的成本模型的可预测运行时间的transformer模型,很容易应用以前的自动搜索算法(例如,动态规划、整数线性规划)LLM服务(例如, AlpaServe, FlexFlow-Serve, SpotServe)和确定最有效的并行策略没有手动干预。还有一些方法,使卸载技术能够使用更大但更慢的内存(例如,CPU DRAM),除了有限的设备内存(例如,GPU DRAM)之外,还可以保存模型参数和KV缓存。

分散推理:这种方法涉及到模型和数据并行性的结合,其中多个分散的自愿节点协作来处理数据和推断输出。这种方法在硬件资源按地理位置分布的情况下特别有用。受众包计算的启发,Petals使用互联网上的协作商品图形处理器提供开花176B模型。分散推理为运行llm打开了被忽视的消费者级GPU开辟了一个新的方向,但也面临着一些实际挑战,如设备异构性、有限的计算和内存容量、低带宽网络、容错和隐私保护。

3.2.3内存管理。

高效的内存管理仍然是LLM服务的前沿挑战,特别是考虑到transformer架构固有的内存密集型特性。随着对长序列推理的需求日益增长,与模型权重和其他激活的必要工作空间相比,KV缓存的内存占用是一个主要的优化目标。在增量解码过程中,随着KV缓存内存动态且不可预测地增长和收缩,朴素方法(例如,faster transformer)以最大序列长度假设预先分配一个连续的内存片段。1)具有不同请求长度的输入批次和2)复杂的解码场景,并行生成多个输出序列(例如,波束搜索,并行解码)。vLLM提出了Paged attention方案,将KV缓存划分为非连续的内存块,并显著提高了批处理的大小和吞吐量。SpecInfer提出了树注意和深度优先树遍历,以消除对共享相同前缀的多个输出序列的冗余KV缓存分配。LightLLM采用了一种更细粒度的token级内存管理机制来进一步减少内存使用。然而,这种分散的内存管理机制的开销带来了新的挑战。特别是对于使用其他优化来提高批处理大小的情况,这些细粒度的内存管理方法可能只提供边际吞吐量的好处,同时大幅放大了推理延迟。很明显,LLM推理中的内存减少与其他算法创新和系统级优化是复杂的联系。虽然有些工作可能对特定的工作负载很有效,但它们可能会相互抵消,导致整体性能下降。在LLM推理系统的内存效率和计算性能之间取得正确的平衡仍然是该领域的一个开放和紧迫的挑战。

3.2.4请求规划。

有效地调度传入的推理请求对于优化LLM服务至关重要。本节回顾了最大化资源利用率、保证延迟服务级别目标(SLO)内的响应时间以及有效处理不同请求负载的请求调度算法。LLM服务的请求调度与一般的ML服务技术共享共性,因为两者都旨在有效地管理传入的请求和优化资源利用率。这些常见的方面包括动态批处理、抢占、优先级、交换、模型选择、成本效率、负载均衡和资源分配。然而,LLM服务由于其独特的特点,如庞大的模型规模、迭代自回归解码机制、未知的变量对上下文信息的输出长度和状态管理,也带来了独特的挑战。 早期的LLM服务系统(例如,在NVIDIA Triton上的Faster transformer)只支持与以前的方法类似的请求级调度。Orca首先注意到生成式llm和以前的ML推理系统的请求级调度之间的差距。考虑到可变的输出序列长度,它以先到先得(FCFS)顺序以迭代粒度调度引擎的执行,并允许对选定的操作进行批处理,以更好地利用硬件。以下许多方法继承了选择性批处理和迭代级调度策略,例如vLLM和RayLLM中的连续批处理以及TensorRT-LLM 中的实时批处理。此外,SpecInfer扩展到推测解码,通过迭代地选择一批请求来执行推测推理和验证的一次迭代。FastServe专注于作业完成时间(JCT),并涉及迭代级优先处理,以对具有较短输入长度的请求进行优先排序,而不是FCFS。 SARATHI针对由不同长度的输入请求的初始迭代引起的分布式推理中的管道气泡。为了使GPU计算饱和,它将输入提示分割成统一的块,如果可能的话,用其他请求的解码迭代来附加块槽,这也被称为Dynamic SplitFuse的DeepSpeed-FastGen所采用。S3涉及一个输出序列长度预测器,并帮助在GPU内存约束内调度更多的并发请求,以获得更大的批处理大小和更高的推理吞吐量。

3.2.5内核优化。

在本小节中,论文将深入研究内核级优化,它是针对语言模型推理管道中的特定操作的性能。这些优化利用了特定于硬件的特性和软件技术来加速关键的计算内核。

内核融合:为了减少内核启动和内存访问造成的开销,内核融合被以前的DNN框架和编译器广泛采用。由于LLM推理不需要反向计算,因此存在更多的核融合机会。一些当代transformer推理引擎(如Faster transformer,TenTrans,Turbo transformer,LightSeq,Byte transformer)和编译器(如Welder)融合(1)具有相同形状的GEMMs(例如,对查询、键和值的三个线性转换)和(2)向其他非GEMM内核添加偏差,如残差连接、层规范化和激活函数(例如,ReLU)。其中,融合的多头注意核的优化已经得到了广泛的探索,并将在以下几个方面进行讨论。

定制注意力:为了使注意力操作在GPU上有效地运行,专门为注意力计算定制或定制GPU内核是至关重要的。例如,cuDNN提供了一个融合的多头注意内核API。与此同时,一些实现已经被开源了,以获得更多的性能提升。由于具有特殊的自回归解码机制,它们大致可以分为两类。一个是第一次迭代(即初始/预填充/上下文/提示符阶段),它并行地处理来自输入提示符的所有token。例如,xFormers使用CUTLASS将在线softmax trick扩展到整个注意力计算。另一个是用于以下迭代(即增量/解码/生成阶段),并且内核每次迭代只生成一个输出token。对于自回归解码,一种常见的做法是保存以前计算过的键和值,以便在生成一个新的token时只需要一个查询来计算,而不是重新运行整个序列。该领域优化的主要方向是最大限度地提高线程占用率和最小化设备上的高带宽内存(HBM)访问(即,使用共享内存或寄存器)。它们通常并行化批大小和磁头尺寸(例如,Faster transformer)以分配工作负载。有些方法进一步支持通过将KV缓存划分为块来并行化序列长度维度,但最终需要减少块级的结果,比如 FlashDecoding。随后的 FlashDecoding++通过引入一个预先已知的统一最大值来消除部分softmax的这种同步。为了更好地利用线程,有必要根据工作负载选择适当的并行维度。

采样优化:采样算法的选择会极大地影响LLM的产生质量。默认的贪婪采样总是选择概率最高的token。并行采样技术,如波束搜索,通过保持每次迭代中得分最高的序列的固定数量(即beam width),有效地解码近似最优序列。各种随机抽样技术(例如,top-k,top-p,temperature controlling)已经被提出,以为更多样化的输出引入随机性。然而,他们仍然面临着一些实际的系统挑战。一个是冗余KV缓存增加的内存压力,另一个是LLM的大量词汇表(即数万个)带来的采样效率问题。例如,LightSeq 提供了一个高效的分层实现,它将词汇表划分为k组,使用一些GPU指令检索每个组中的候选项,然后对这些候选项进行重新排序,以获得top-k token。

可变序列长度:LLM推理的另一个独特挑战是,序列的输入长度和输出长度都可以发生变化,而后者是事先未知的。加快推理速度的一种方法是一次处理一个批处理中的多个序列。然而,当一批序列具有可变的输入长度时,通常使用填充使它们都具有相同的长度用于批处理,浪费了计算和内存资源。为了缓解这些低效问题中的一些问题,可以采用各种策略。填充技术将序列存储在一个连续的内存空间中,不需要填充,在注意力计算之前只解包。锯齿张量进一步支持使用编译器生成的内核使用最小填充的计算。将序列绑定到一个更小的计算粒度中(例如,chunks)也是一种可能的解决方案,以减轻填充标记的内存使用。由于初始阶段和增量阶段的混合执行,桶装输入提示也给内存管理和请求调度带来了新的挑战。

自动编译:大多数现有的LLM推理系统使用特定于供应商的库作为其后端,如cuBLAS、cuDNN和CUTLASS,它们提供了优化的内核实现。为了进一步提高推理效率,他们还需要付出大量的努力,在NVIDIA GPU上为特定的LLM操作符(例如,attention)优化手动编写的内核。尽管有这些工作,使用自动DNN编译器的趋势仍然存在,如TVM(即,Unity ,Relax和TensorIR ),MLIR,JAX ,OpenAI Triton,TASO和TorchInductor。编译方法可以帮助发现可能更有效的运营商实现,更重要的是,促进对替代硬件平台的适应,包括移动和边缘设备、CPU、DL加速器和其他类型的GPU(例如,AMDGPU和Apple M2 Ultra)。

四、软件框架

生成式LLM服务需要一个完整的优化堆栈,并且最近的许多工作已经开始开发软件框架,以提供高效的LLM推理部署服务。下面,论文将重新讨论这些系统,并调查表2中几个具有代表性的基于GPU的开源LLM服务系统的全面分析。该分析不包含一些流行的相关项目,包括 1) 针对其他硬件的专门解决方案 (如PopTransformer, CTranslate2, lammap.cpp and ggml) and 2) 建立在其他系统之上的部署解决方案,如 OpenLLM(vLLM), xinferencer(ggml + vLLM + xFormers), LMDeploy(FasterTransformer), gpt-fast(PyTorch), DeepSpeed-MII和DeepSpeed-FastGen(DeepSpeed-Inference), RayLLM 和 RayServe (vLLM).

论文比较了这些最先进的LLM服务系统,并总结了它们在几个方面的差异。首先,这些系统大多支持张量并行性,以实现多GPU推理,提高系统性能。其中一些将来支持管道并行或卸载,以支持对多节点或资源受限环境的推断。其次,部分系统从Orca中学习并实现迭代级调度。第三,论文研究了这些系统的注意核心,并分别介绍了它们在初始阶段和增量阶段的实现。在初始阶段,他们通常采用批的一般矩阵乘法(GEMM)方法(例如,cuBLAS, torch, Relay),一些利用在线 softmax trick来减少HBM访问(例如,Flash-attention, xFormers)。增量阶段更具挑战性,因为每个token生成方案降低了计算强度。为了提高GPU的利用率,Faster transformer手动将注意力计算(如 linear projection, positional bias, dot product, softmax等)融合到一个高性能的内核模板中,并涉及几种内核优化技术,如碎片内存缓存、减少warp-shuffle instruction、具有张量核心和多精度支持的半矩阵乘法和积累(HMMA)。FlexFlow-Serve支持推测解码,并提供了一个基于树的并行解码内核,以验证来自多个序列(即来自多个小模型或不同波束或并行采样)的推测标记,具有零内存冗余和最大线程并行性。通过将KV缓存划分为页面,vLLM扩展了融合的多头注意(MHA)内核,以消除冗余内存的使用,特别是对于并行采样场景。LightLLM采用了一种后续的方法,即将KV缓存划分为更细粒度的token部分。

请注意,还有一些其他值得注意的方面。例如,即使是对于最流行的Flash和Paged attention力内核,它们通常也会在这些系统中以不同的方式实现。TGI直接导入原始的Flash/Paged注意库,LightLLM采用OpenAI Triton实现的内核,MLC-LLM通过TVM生成内核,TensorRT-LLM从Faster transformer的融合注意内核中进行修改,以支持Paged attention。另一个例子是关于输入感知的内核选择。在初始阶段,TensorRT-LLM根据上下文长度从cuBLAS和Flash attention中进行选择。除了注意计算外,对于线性投影算子,最近还有一种趋势是用一般矩阵向量积(GEMV)代替GEMM,以更有效地处理小批量(即1)的情况。这些系统还具有许多其他不同的特性,如编程语言(即C++、Python)、低精度支持(即FP16、INT8)、支持的硬件和模型。总之,这些不同的设计和实现的选择在很大程度上取决于它们的优先级优化目标。例如,vLLM使用paged attention以提高高吞吐量(Tpt),而FlexFlow-Serve利用推测来加速低延迟(Lat)。基本上,低延迟和高吞吐量是LLM服务系统中的双重优化目标,代表互补但经常冲突的目标,需要一个平衡策略来优化单个任务的快速响应和在指定时间框架内处理的任务量之间的权衡。最近的一些研究表明,进一步通过TTFT+TPOT×输出序列长度来分解响应延迟,其中TTFT表示到第一个token的时间,TPOT表示每个输出token的时间。前者由初始相位处理速度驱动,而后者直接依赖于增量解码过程中每次迭代的执行时间。区分这两个指标有利于LLM服务提供商,从而导致不同的系统设计选择和用户体验(例如,更快的应用程序响应能力,更长的提示)。此外,降低计算成本也是设计和实现一些LLM服务系统的一个重要和实际目标。虽然它不太可能有一个一刀切的解决方案,但论文相信,未来的LLM服务系统将不断集成这些不同的特性,从而不断提高系统效率和硬件利用率。

五、基准

建立一个全面的、可重复的基准来比较各种LLM服务系统的性能,如MLPerf,对该领域的学术和工业社区都是要付出的关键努力。它不仅将帮助LLM用户选择正确的系统解决方案,而且还将鼓励研究人员和开发人员跟上高级优化的步伐。不幸的是,尽管有之前的一些报告,但到目前为止,社区还没有启动一个足够令人信服的基准,考虑到所有的影响因素。这主要是因为有许多评估设置,包括模型配置、硬件环境和请求负载等。在有限数量的设置组合下进行测试,不能得出具有可信度的结论。例如,某些系统优化技术只能在高负载或低负载条件下实现性能优势,相反地,它们甚至可能是有害的。此外,在度量推理延迟时,如何排除与GPU推理无关的额外开销(如请求调度开销、固有的网络延迟等)。由于在系统设计上的差异,这也是一个具有挑战性的话题。此外,一个公平的基准测试需要考虑模型输出内容的严格对齐,这在许多测试中经常被忽视。

六、与其他调查的联系

论文对高效生成式LLM服务和推理的调查补充和扩展了该领域现有文献的范围,同时保持了一个独特的焦点。在相关工作中,一些研究在探索更通用的transformer模型和领域特定加速器的设计方面最接近。然而,论文的调查通过特别关注生成性LLM服务,这是一个微妙的领域,并不是其他研究的中心焦点。此外,一些研究深入探究了在GPU和新型加速器上的LLM推理效率的实验研究,提供了与论文关注的服务效率直接相关的有价值的经验见解。此外,LLMCarbon 还解决了LLM部署的一个日益重要的方面——其环境影响(例如,碳足迹)。虽然论文的调查从绩效的角度来看,论文的主要焦点是效率,但这些研究提供的环境视角在论文更广泛的讨论中是不可否认的相关和受尊重的。一些调查和基准测试为模型压缩和量化提供了有价值的见解。这些研究为间接支持论文对相关方向的探索奠定了基础。一些研究为理解LLM的有效性(如准确性、困惑程度、事实性等)提供了基本的背景,这超出了本调查的范围。论文的调查也承认了之前的调查对大规模DNN模型的分布式训练的贡献,因为它们告知了必须考虑LLM服务的背景。从本质上说,论文的调查坐落在一系列不同的研究中,借鉴并有助于对LLM的更全面的服务效率的理解,包括算法创新和系统优化。通过整合来自这些不同领域的见解,论文的目标是提供对该领域的最新进展和挑战的细致和全面的概述。

七、未来方向

随着我们站在LLM进步的最前沿,不仅了解这些技术的现状,而且预测和塑造它们的未来发展轨迹也变得越来越重要。特别是在生成式LLM服务领域,有一个巨大的未探索的可能性和新出现的挑战。这一领域的快速发展需要一种前瞻性的方法,其中确定创新和改进的潜在途径是至关重要的。这种远见不仅让我们为适应即将到来的技术转变做好了准备,而且还指导研究界解决最相关和最具影响力的领域。在此背景下,论文概述了未来研究和发展的几个有前途的方向,每个方向都提供了显著提高生成式llm服务效率的潜力。

硬件加速器的开发和增强。未来在提高生成式LLM服务效率方面的进展,主要得益于专门硬件加速器的开发和改进,并辅以协调硬件和软件优化的协同设计方法。例如,将更接近处理单元的内存集成起来,或优化芯片架构以更好地与LLM算法的数据流相结合,可以大幅降低延迟和能源消耗。这种方法在最近的GPU改进中得到了例证,比如NVIDIA的Hopper架构,它展示了HBM和SRAM容量、内存带宽、计算单元和等分带宽的改进,直接有利于llm的处理。持续创新在这一领域可能涉及设计硬件固有的调整生成LLM的计算模式,如优化特定需求的注意机制和张量操作普遍在这些模型,最终影响LLM服务系统的设计和实现。

高效和有效的解码算法。更有效的解码算法的发展可以大大提高服务效率。由于对更节省资源的方法来利用llm中封装的大量知识的需求,未来的工作可以探索传统自回归方法的替代方法,并在保持解码质量的同时解锁实时应用程序的生成速度。一个有希望的方向是广义推测推理,因为它能够保持相同的生成质量。具体来说,小的投机模型可以推广到任何其他形式的方法,这些方法可以比llm更有效地生成草案标记,如知识检索和用户定义函数。例如,最近出现了一些后续的工作,用早期退出或非自回归解码取代了草案模型。总之,高效解码算法的开发,如投机解码和底层系统的优化,为提高生成式llm的服务效率提供了一个重要的机会。

长上下文/序列场景优化。随着llm的应用继续扩展到更复杂的场景中,对处理更长的上下文或序列的需求正在稳步增长。为具有长序列工作负载的llm提供服务需要解决来自算法和系统两方面的挑战。就llm而言,当序列比训练中观察到的更长时,甚至使用相对位置编码或对较长的语料库进行微调后,它们也经常出现长度泛化失败。即使对于一些声称支持超长环境的模型,研究也发现它们遇到了“中间损失”的情况。目前的方法试图通过在保留相关信息的同时减少计算序列长度来缓解这种限制,如检索增强、序列压缩和缓存。对于LLM服务系统,较长的序列带来了关键的挑战,包括更多的内存消耗和KV缓存的访问,以及二次级的自注意计算复杂度的增加。

关于在复杂环境中进行部署的探索。随着llm应用的扩展,一个关键的未来方向是在各种复杂环境中探索和优化它们的部署。这种探索超越了传统的基于云的部署,包括边缘计算、混合计算(结合云和边缘计算)、分散计算以及使用现场实例等更实惠的资源等场景。每个环境都为LLM的服务带来了独特的挑战和机遇。例如,边缘计算允许通过处理更接近源的数据来更快的响应时间和减少带宽使用,但它也为有限的计算资源和存储容量带来了挑战。混合计算提供了一种平衡的方法,但需要高级管理来有效地分配计算任务。分散计算为众包计算资源提供了一个很有前途的途径,但它也带来了关于数据隐私和安全的额外考虑。LLM通过先发制人的资源提供服务,可以显著降低货币成本,但需要容错机制来处理其固有的不可预测性和可变性,以确保一致的性能和系统可靠性。成功地应对这些健壮、可伸缩的挑战将是LLM和高效的LLM应用的关键。

自动适应特定的要求。特定于应用程序的不同需求创造了广泛的创新LLM服务优化机会,如参数化微调、从外部向量存储检索、在线学习和知识更新、多模式工作负载,以及将不同LLM的能力链接在一起。这些独特的挑战还要求通过将优化空间扩展到整个LLM生命周期,将其自动和顺利地集成到现有的LLM服务基础设施中,包括数据采集和处理、AutoML和模型管理、资源分配和性能监控。

八、结论

高效的LLM服务是实现先进人工智能技术平民化的基本一步。本调查旨在为研究人员、从业者和开发人员提供对现有方法的全面了解,使他们能够在现实环境中部署llm时做出明智的决策。通过整合对算法和系统的最新研究成果,本文希望加快进展,促进追求高效LLM服务解决方案的创新。

本文转载自: 掘金

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

Spring AI,初体验

发表于 2024-04-26

解锁更多精彩,关注公众号:闲人张

本文主要介绍了spring ai的工具调用和结构化输出功能,通过具体的示例来体验整个调用及实现过程,同时对一些使用方法进行总结说明。

前言

目前调用AI的框架语言主要以Python为主,虽然也有一些Java语言实现的,但在使用和功能上仍有一些差距。而Spring做为Java的半壁江山,Spring AI自然也需要体验一下。 一些Java的调用框架:

  • langchain4j
  • semantic-kernel (依赖了azure-sdk-for-java)
  • azure-sdk-for-java

通过官方的架构交互,可以看到主要分为Prompt、ChatClient、ChatResponse

image.png

下面来看看Spring AI是如何实现工具调用和结构化输出的。

引入pom

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
    <repositories>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <releases>
                <enabled>false</enabled>
            </releases>
        </repository>
    </repositories>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>0.8.1-SNAPSHOT</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-azure-openai-spring-boot-starter</artifactId>
    </dependency>

这里使用azure的api,目前官方的版本是0.8.1,引入后我们可以看到内部也是引用了azure的sdk,目前依赖的版本是1.0.0-beta.7,感兴趣的同学其实也可以直接使用azure的sdk进行调用。

image.png

添加模型配置

在yml中添加模型的配置

1
2
3
4
5
6
7
8
9
spring:
  ai:
  azure:
    openai:
      api-key: 331321**********************
      endpoint: https://xxxx.openai.azure.com/
      chat:
        options:
          deployment-name: gpt-4-32k

经过以上配置,我们就可以直接使用spring-ai提供的api进行调用了。

工具调用

Spring AI 提供了灵活且用户友好的注册和调用自定义函数的方式。通常,自定义函数需要提供函数 name、 description和函数调用的参数。具体实现只需定义一个Bean,返回java.util.Function。

注册

我们通过一个具体的示例看下,mock一个根据日期获取课程的方法:

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
public class MockCourseService implements Function<MockCourseService.Request, MockCourseService.Response> {
    @Override
    public Response apply(Request request) {
        String[] courseList = null;
        switch (request.dayOfWeek) {
            case "星期一" -> courseList = new String[]{"java", "python"};
            case "星期二" -> courseList = new String[]{"c++", "js"};
            default -> courseList = new String[]{"php"};
        }
        return new Response(courseList);
    }

    @JsonClassDescription("获取课程入参")
    public record Request(@JsonProperty(required = true, value = "dayOfWeek") @JsonPropertyDescription("星期几,如:星期一")String dayOfWeek) {}
    public record Response(String[] courseList) {}

    @Configuration
    static class Config {
        //方式一
        @Bean
        @Description("根据星期几查询对应的课程")
        public Function<MockCourseService.Request, MockCourseService.Response> courseFunctionInfo1() {
            return new MockCourseService();
        }
        //方式二
        @Bean
        public FunctionCallback courseFunctionInfo2() {
            return FunctionCallbackWrapper.builder(new MockCourseService())
                    .withName("getCourseByDate")
                    .withDescription("根据星期几查询对应的课程")
                    .build();
        }

    }
}

可以看到需要实现Function接口,并且提供了两种方式来注册自定义函数。

  • 方式一的函数名称为courseFunctionInfo1,并且采用了@JsonClassDescription、@JsonProperty、@JsonPropertyDescription、@Description等注解对函数及其参数进行描述说明
  • 方式二的函数名称为getCourseByDate,通过spring ai 提供的FunctionCallback接口注册自定义函数,并使用FunctionCallbackWrapper包装器来设置函数名称和描述。

调用

我们定义一个接口来调用自定义函数:

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/function")
public ChatResponse function() {
    SystemMessage systemMessage = new SystemMessage("你是一个智能助手,请调用工具回答问题。");
    UserMessage userMessage = new UserMessage("今天星期二有哪些课?");
    Set<String> functions = Set.of("getCourseByDate","addressFunction");
    Prompt prompt = new Prompt(List.of(systemMessage,userMessage), AzureOpenAiChatOptions.builder().withFunctions(functions).build());
    System.out.println(prompt.toString());
    ChatResponse response = chatClient.call(prompt);
    System.out.println(response.getResult().getOutput().getContent());
    return response;
}

可以看到基本上都是一个思路,通过构建Prompt对象,然后调用chatClient.call(prompt)即可。通过debug,发现实际是将tool转换OPEN AI function的标准,然后调用openai的api。感兴趣的可以去查看源码以及打开日志来查看具体的调用过程。

image.png

运行后可以看到如下输出:

1
2
3
4
5
Prompt{messages=[SystemMessage{content='你是一个智能助手,请调用工具回答问题。', properties={}, messageType=SYSTEM}, UserMessage{content='今天星期二有哪些课?', properties={}, messageType=USER}], modelOptions=org.springframework.ai.azure.openai.AzureOpenAiChatOptions@11e70d12}

今天星期二你有以下课程:
1. c++
2. js

结构化输出

spring ai 提供了OutputParser来支持返回结果的格式化输出,并提供了以下的实现:

image.png

下面通过构建一个BeanOutputParser来看下调用的过程:

首先定义一个返回对象

1
2
3
4
5
6
@Data
public class ResultResponse {
    @JsonPropertyDescription("日期")
    String date;
    List<String> course;
}

可以使用@JsonPropertyDescription对参数进行描述

调用

借用之前定义的函数,我们让接口返回为定义的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 @GetMapping("/outputParse")
 public ResultResponse outputParse() {
     var outputParser = new BeanOutputParser<>(ResultResponse.class);
     String format = outputParser.getFormat();
     System.out.println("format: " + format);
     String systemPrompt = "你是一个智能助手,请调用工具回答问题。 {format}";
     PromptTemplate promptTemplate = new PromptTemplate(systemPrompt, Map.of("format", format));
     Message message = promptTemplate.createMessage();
     UserMessage userMessage = new UserMessage("今天星期二有哪些课");
     Set<String> functions = Set.of("getCourseByDate","addressFunction");
     Prompt prompt = new Prompt(List.of(message,userMessage), AzureOpenAiChatOptions.builder().withFunctions(functions).build());
     ChatResponse response = chatClient.call(prompt);
     return outputParser.parse(response.getResult().getOutput().getContent());
 }

可以看到,我们只需要定义一个BeanOutputParser,然后调用其format方法,通过对其输出,我们可以看到OutputParser实际上是将定义的结构化数据转换为了一段Prompt:

1
2
3
4
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Here is the JSON Schema instance your output must adhere to:
{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "course" : {
      "type" : "array",
      "items" : {
        "type" : "string"
      }
    },
    "date" : {
      "type" : "string",
      "description" : "日期"
    }
  }
}
1
2


查看最终结果可以看到完全是按照我们定义的结构化数据来输出的:

1
2
3
4
5
6
7
{
"date": "星期二",
"course": [
"c++",
"js"
]
}

以上就是本篇的全部内容,后续将会对spring ai 其他的功能做更深度的体验,欢迎关注!

解锁更多精彩,关注公众号:闲人张

本文转载自: 掘金

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

Java枚举类(Enum)和注解(Annotation)讲解

发表于 2024-04-26

前言

本文主要讲解Java的其他两个重要的技术点:枚举类(Enum)和注解(Annotation),这两个在平时开发中经常用于公共工程。

枚举类是一种特殊的类,用于定义一组常量(final variables)。通过使用enum关键字定义枚举类,每个枚举常量都是枚举类的一个实例,并且具有名称和值。枚举类可以直接使用其常量值,也可以通过调用其方法获取相关信息。

注解(Annotation)是Java 5引入的一个特性,它是一种元数据机制,用于提供有关代码的附加信息。注解不会直接影响代码的运行,但可以被编译器、运行时环境或框架使用,以实现各种功能。常见的注解示例包括@Override、@Deprecated、@SuppressWarnings等。要自定义注解,需要创建一个接口或抽象类,并使用@Target和@Retention注解来指定该注解可以用于哪些元素,并指定该注解在运行时是否可用

一、Java枚举类Enum

1.自定义枚举类

在Java中,枚举是一种特殊的类,它用于定义一组常量(final variables)。枚举类是通过关键字enum来定义的,每个枚举常量都是枚举类的一个实例,并且具有名称和值。

以下是一个自定义枚举类的示例:

1
2
3
javascript复制代码public enum Season {  
SPRING, SUMMER, AUTUMN, WINTER
}

在这个例子中,Season是一个枚举类,SPRING、SUMMER、AUTUMN和WINTER是它的四个常量。

2.使用枚举类

枚举类可以直接使用其常量值,也可以通过调用其方法获取相关信息。

例如,使用上述的Season枚举类,我们可以这样使用:

1
2
3
javascript复制代码Season currentSeason = Season.SUMMER;  
System.out.println(currentSeason.name()); // 输出 SUMMER
System.out.println(currentSeason.compareTo(Season.SUMMER)); // 输出 0

3.Enum类的主要方法

  • name():返回该枚举常量的名称。
  • valueOf(String name):返回指定名称的枚举常量。
  • compareTo(E o):比较该枚举常量与指定枚举常量的顺序。
  • values():返回所有可能的枚举常量。实现接口的枚举类

二、注解(Annotation)概述

注解是Java 5引入的一个特性,它是一种元数据机制,用于提供有关代码的附加信息。注解不会直接影响代码的运行,但可以被编译器、运行时环境或框架使用,以实现各种功能。

注解在语法上是一种接口的成员,可以是方法、构造函数、字段或类。它们通常被用于提供元数据,例如标记代码的特定部分、约束类型、配置运行时行为等。

1.常见的Annotation示例

  1. @Override:标记一个方法是重写父类的方法。
  2. @Deprecated:标记一个方法或类已经过时,建议不要使用。
  3. @SuppressWarnings:抑制编译器对特定警告的警告。
  4. @Autowired:来自Spring框架,自动装配bean依赖。
  5. @PreAuthorize和@PostAuthorize:来自Spring Security框架,用于安全性的注解。

2.自定义Annotation

要自定义Annotation,需要遵循以下步骤:

  1. 创建一个接口或抽象类,用作注解的基类。
  2. 使用@Target和@Retention注解来指定该注解可以用于哪些元素(例如方法、类等),并指定该注解在运行时是否可用。
  3. 为注解添加属性,以便提供更多信息。可以使用Java的基本类型、枚举类型、Class类型、字符串、集合等类型作为注解的属性。可以使用default来设置属性的默认值。
  4. 创建一个实现了该接口的类,并重写其方法。在需要使用该注解的地方使用该类即可。

下面是一个自定义Annotation的示例:

1
2
3
4
5
6
7
8
javascript复制代码import java.lang.annotation.*;  

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomAnnotation {
String value() default "default value";
int number() default 0;
}

这个自定义Annotation可以用于标记方法,并为其提供value和number两个属性。默认情况下,value为”default value”,number为0。在运行时,可以通过反射获取这些属性的值。

总结

Java枚举类(Enum)和注解(Annotation)都是Java语言中重要的特性,它们提供了一种方便的方式来定义常量、提供元数据信息和实现各种功能。一般开发中都会定义成公共的部分,其他工程共享调用,实现“低耦合”。

本文转载自: 掘金

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

iOS 应用瘦身 - App Thinning

发表于 2024-04-26

1 App Thinning

App Thinning 是一种优化移动应用程序以减小其安装包大小并提高性能的技术。它是苹果公司为iOS应用程序开发的一种方法,目的是使应用程序更适合各种不同类型的iOS设备。

App Thinning 主要包括技术:

  • Slicing (iOS, tvOS)
  • Bitcode
  • On-Demand Resources (iOS, tvOS)

1.1 Slicing

iOS 9.0 及更高版本的设备支持切片 App。

Slicing 是为不同的目标设备和操作系统版本创建和交付 app bundle 变体的过程。当用户安装 App 时,系统会下载并安装用户设备和操作系统版本的变体。

Xcode 在开发过程中模拟切片,当在设备或模拟器中构建和运行应用时,Xcode 会对应用进行切片;当创建归档时,Xcode 会包含 App 的完整版本,但允许从归档中导出变体。

工作流程:

  1. 在 Xcode 中,指定目标设备并且使用资源目录(Asset Catalogs)提供多种分辨率的图片。资源只有使用 Asset Catalogs 才能正确使 Slicing 作用于资源文件
  2. 编译并运行到模拟器或者真机设备。Xcode 针对运行设备自动构建”变体App“,减少编译时间,同时支持在本地测试不同的变体
  3. Archieve,为目标设备在本地导出一个变体。可在本地导出多种“变体App“,测试并修复配置错误
  4. 上传到 App Store Connect。App Store 根据上传的 .ipa 归档文件创建“变体App“,“变体App“的数量取决于 Xcode project 配置的 architecture 和资源
  5. 上传在 App Store Connect 中发布一个预览版进行测试。通过 TestFlight 下载预览版,TestFlight 为目标设备下载合适的“变体App“
  6. 发布 App。App Store 为用户设备下载合适的“变体App“

1.2 Bitcode

Bitcode 是编译程序的中间表示形式。上传到 App Store Connect 且包含位码的 App 将在 App Store 上重新进行编译和链接。

在未来 Apple 新推出了新的 CPU 架构或者以后 LLVM 推出了一系列优化,Apple Store 会自动重新优化应用程序二进制文件,而无需再次提交新版本提交到 App Store。

开启步骤:Build Settings -> Enable Bitcode -> YES

启用 Bitcode 注意事项:

  • App 依赖的静态库、动态库、Cocoapods 管理的第三方库,都需要开启 Bitcode
  • 最终生成的可执行文件是 Apple 自动生成的,同时会产生新的符号表文件(dSYMs),需要符号重映射以确保正确地解析崩溃日志和调试信息

1.3 On-Demand Resources

按需加载(On-Demand Resources,ODR)

App Store 将部分资源托管在 Apple 服务器上,并为您管理下载。App Store 还对按需资源进行切片,进一步优化了 App 的变体。

注意事项:

  • 当用户进入到某个业务时,根据需要在后台下载按需资源。
  • 当不再需要按需资源且磁盘空间不足时,操作系统会清除这些资源。

按需加载配置 & 请求资源

使用按需加载功能:

  1. 开启步骤:Build Settings -> Enable On-Demand Resources -> YES
  1. 选中 Resources Tag 标签 -> Download Only On Demand -> 新增“+”并命名资源标签

Resources Tag 介绍:

  • Initial install tags:初始安装标签。资源会随着 App 从 App Store 下载而下载,会影响 ipa 的大小
  • Prefetch tag order:预取标签顺序。会在 App 下载后,开始下载相应的资源,此种资源并不会影响 ipa 的大小。适用于游戏场景
  • Dowloaded only on demand:仅按需下载。资源会在必要的时候主动触发下载,开发者控制下载时机
  1. 下载按需资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
objectivec复制代码- (void)onDemandDownload {

// 创建资源请求,指定需要的资源标签
NSArray<NSString *> *tags = @[ @"ImageAssets" ];
NSBundleResourceRequest *request = [[NSBundleResourceRequest alloc] initWithTags:tags];

// 开始访问资源
[request beginAccessingResourcesWithCompletionHandler:^(NSError * _Nullable error) {
if (error) {
// 处理异常情况
[self handleResourceRequestError:error];
return;
}

// 资源下载完成,可以开始使用资源
dispatch_async(dispatch_get_main_queue(), ^{
// 在主线程中更新 UI 或执行其他操作
// 加载资源,例如显示图像
NSBundle *bundle = request.bundle;
UIImage *image = [UIImage imageNamed:@"exampleImage" inBundle:bundle compatibleWithTraitCollection:nil];
self.imageView.image = image;

// 使用完成后释放资源请求
[request endAccessingResources];
});
}];
}

- (void)handleResourceRequestError:(NSError *)error {
if (error.code == NSBundleOnDemandResourceOutOfSpaceError) {
// 处理空间不足错误
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Error"
message:@"Insufficient space on device. Please clear some space and try again."
preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alertController animated:YES completion:nil];
} else if (error.code == NSBundleOnDemandResourceExceededMaximumSizeError) {
// 处理资源超出最大内存限制错误
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Error"
message:@"Resource exceeds maximum memory limit. Please remove some resources and try again."
preferredStyle:UIAlertControllerStyleAlert];
[alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alertController animated:YES completion:nil];
} else if (error.code == NSBundleOnDemandResourceInvalidTagError) {
// 处理无效资源标签错误
NSLog(@"Invalid resource tag: %@", error.localizedDescription);
// 可以进一步确认正确的标签名称并修复 Bug
}
}

@end

本文转载自: 掘金

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

Spring框架宝典:彻底理解三级缓存策略 一、循环依赖概念

发表于 2024-04-26

一、循环依赖概念

在Spring应用中,循环依赖指的是两个或多个Bean之间相互引用,造成了一个环状的依赖关系。举例来说,如果Bean A依赖于Bean B,同时Bean B也依赖于Bean A,就形成了循环依赖。这种情况下,Spring容器在创建这些Bean时会陷入无限循环,导致应用启动失败或者出现其他不可预测的问题。

二、解决方案(三级缓存)

Spring容器在解决循环依赖问题时使用了三级缓存的机制。在创建Bean的过程中,Spring容器会利用三个缓存来处理实例化和依赖注入,确保即使在存在循环依赖的情况下也能正确创建Bean。

1、Spring三级缓存流程

让我们深入了解一下这个过程:

**

  1. 当 Spring IOC 容器扫描到一个 Bean 时,它会先将其实例化并放入一级缓存中。同时,会为这个 Bean 创建一个 ObjectFactory 对象,用于后续的依赖注入。
  2. 如果 Bean 依赖其他 Bean,Spring 在创建 Bean 实例时,会先检查一级缓存。如果 Bean 还不存在于一级缓存中,Spring 开始实例化该 Bean,并将其添加到三级缓存中。这时,Bean 具有自己的内存地址。
  3. 接下来,Spring 填充 Bean 的属性。如果属性依赖于其他 Bean,Spring 会从一级缓存中获取对应的 Bean。如果 Bean 不存在于一级缓存中,Spring 会从三级缓存中获取 ObjectFactory,执行工厂方法,创建 Bean 的早期引用,并将其放入二级缓存中。
  4. 在回溯过程中,当 Bean 成为“成品”时,它会从三级缓存中移除,并放入一级缓存中。这样,Bean 的初始化就完成了。
  5. 最终,所有 Bean 都进入一级缓存,准备供用户使用。

2、源码分解

Spring框架的IoC容器是Java开发者广泛使用的组件之一,它通过控制反转的方式管理Bean的生命周期。Spring在创建Bean的过程中,为了避免不必要的性能开销,引入了多级缓存机制。本文将从源码的角度分析Spring是如何创建Bean的,重点探讨Spring中缓存的使用。

Step1: Spring容器的初始化

Spring容器的初始化始于AbstractApplicationContext的refresh()方法的调用,该方法触发了容器的刷新操作。 AbstractApplicationContext是Spring加载上下文的入口。

org.springframework.context.support.AbstractApplicationContext#refresh()

这里最关键的步骤是obtainFreshBeanFactory(),它会创建一个新的DefaultListableBeanFactory,这个Factory在后续会完成具体的bean加载和创建工作。

Step2: Bean的定义和注册

在前面的obtainFreshBeanFactory()方法中,Spring会加载所有的BeanDefinition,这些BeanDefinition可能来自XML文件、注解扫描等。而加载BeanDefinition的过程,就是调用DefaultListableBeanFactory的registerBeanDefinition方法。

org.springframework.beans.factory.support.DefaultListableBeanFactory#registerBeanDefinition

registerBeanDefinition方法就是将加载的BeanDefinition存储到一个内部Map中,留待后续使用。

Step3: Bean的获取与创建

Step3-1:Bean的获取getBean()

当我们需要使用某个bean时,就会调用AbstractBeanFactory的getBean(),它是获取bean的入口。

org.springframework.beans.factory.support. AbstractBeanFactory#getBean()

Spring在获取一个bean实例时,首先会检查这个bean是否已存在于单例缓存容器singletonObjects中。

如果缓存存在,直接返回缓存的bean实例,避免重复创建。如果缓存不存在,则调用createBean方法创建一个新的bean实例。

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Override
public Object getBean(String name) throws BeansException {
// 检查单例缓存
Object sharedInstance = getSingleton(beanName);
if (sharedInstance != null) {
// 缓存存在,直接返回
return sharedInstance;
}
// 缓存不存在,则创建bean
return createBean(beanName, ...);
}

Step3-2:获取缓存中的Bean(getSingleton)

Spring会将创建的单例对象存入singletonObjects单例缓存中,确保下次获取该bean时可以直接从缓存中获取。

org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#getSingleton

如果单例缓存中不存在,就会尝试从单例缓存的子逻辑缓存中获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码// org.springframework.beans.factory.support.DefaultSingletonBeanRegistry

protected Object getSingleton(String beanName, boolean allowEarlyReference) {
// 检查是否在创建中
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
// 尝试从earlySingletonObjects中获取,earlySingletonObjects属于二级缓存
synchronized (this.singletonObjects) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
// 从三级缓存singletonFactories中获取
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
// 将三级缓存获取到的bean放入earlySingletonObjects二级缓存
this.earlySingletonObjects.put(beanName, singletonObject);
}
}
}
}
return singletonObject;
}

这里可以看到,Spring维护了三级缓存:

  1. singletonObjects: 单例bean缓存
  2. earlySingletonObjects: 早期曝光的单例bean缓存
  3. singletonFactories: 单例bean工厂缓存

当bean不存在于singletonObjects一级缓存时,Spring会先检查该bean是否正在创建中,如果是则尝试从earlySingletonObjects二级缓存获取。如果二级缓存也没有,那么就从singletonFactories三级缓存中获取。这样设计三级缓存主要是为了解决循环依赖的问题。

Step3-3:Bean的创建createBean

在AbstractAutowireCapableBeanFactory的createBean方法中,会先尝试从缓存中获取该bean,如果缓存不存在,才会正式调用createBeanInstance方法实例化该bean。

org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#createBean

createBean方法是创建bean的核心逻辑,包括了bean的实例化、属性注入、循环依赖处理、初始化方法回调等。

需要重点关注doCreateBean和populateBean两个方法。

doCreateBean方法的主要工作是实例化bean对象。Spring通过不同的InstantiationStrategy策略来实例化不同的bean,如反射创建、通过工厂方法创建、CGLIB创建等。

populateBean方法会完成属性注入的工作。包括依赖注入、自动装配和循环依赖的处理。循环依赖是Spring处理较为复杂的一个环节,通过三级缓存来完成。

Step3-4:创建的Bean存入缓存

进入doCreateBean方法真正地创建bean实例。

在AbstractAutowireCapableBeanFactory中,创建单例bean后,是通过调用addSingletonFactory方法将bean添加到三级缓存中。

org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#addSingletonFactory

至此spring bean创建与缓存的过程就差不多完成了。

3、三级缓存的必要性

Spring三级缓存解决循环依赖的一个重要原因是确保带有AOP及其它增强的Bean在返回给其他Bean之前必须完全初始化。因为AOP增强通常涉及到代理的创建,这需要在Bean的所有依赖都解决之后进行。如果提前返回一个未完成增强的Bean实例,那么它可能不会按预期工作,因为它缺少了如事务管理、安全检查等关键行为。

三级缓存中的singletonFactories存储的工厂对象允许Spring在Bean完全初始化并应用了所有AOP及其它增强之后,再返回Bean的实例。这样,即使在循环依赖的情况下,也能确保每个Bean都是完整且正确增强的,从而保持了应用的一致性和稳定性。

本文转载自: 掘金

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

1…131415…956

开发者博客

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