一、引言
CosyVoice是通义实验室依托大规模预训练语言模型,深度融合文本理解和语音生成的新一代生成式语音合成大模型,支持文本至语音的实时流式合成。可以应用于:
- 智能设备/机器人播报的语音内容,如智能客服机器人、智能音箱、数字人、语音助手等。
- 音视频创作中需要将文字转为语音播报的场景,如小说阅读、新闻播报、影视解说、剧本配音等。
随着开发者开始接入阿里云百炼的语音合成、语音识别等服务,如何高效、稳定地通过 java SDK 调用百炼的语音服务变得至关重要。目前阿里云百炼上已经上线的最新语音模型包括:cosyvoice-v1(语音合成),sambert (语音合成),paraformer-realtime-v2(语音识别),paraformer-v2(离线文件转写),sensevoice-v1(离线文件转写),其中前两个模型为实时调用,使用 websocket 协议,后两个模型为异步调用,使用 https 协议。由于离线任务不具备高实时性要求,因此不在本文的探讨范围。
本文会以 cosyvoice 大模型语音合成服务为例,详细介绍如何通过 SDK 高并发调用百炼语音服务。请在使用时一定仔细阅读《异常处理》章节,避免引入新的错误。
⚠️注意!
在 dashscope-sdk-java 版本大于等于 2.16.9,才支持针对高并发场景的优化,因此请使用最新版本的 SDK 进行集成。
二、一些基础知识
什么是 Websocket 连接
WebSocket 是一种用于在客户端(比如浏览器)和服务器之间建立持久性双向通信的协议。简单来说,它允许客户端和 ws 服务器和服务器之间保持一个打开的连接,从而实现实时数据传输。
WebSocket的特点:
- 双向通信:与传统的HTTP请求-响应模式不同的是,WebSocket允许服务器主动向客户端发送数据,反之亦然。这使得实时应用(如聊天应用、在线游戏等)更加高效。
- 持续连接:建立WebSocket连接后,双方可以通过同一个连接进行多次交互,而不需要每次都重新建立连接。这减少了连接建立的开销,提高了性能。
- 低延迟:由于是持久连接,WebSocket可以迅速发送和接收消息,这对于需要快速反应的应用非常重要。
- 支持文本和二进制数据:WebSocket可以传输文本数据(如JSON、HTML等)和二进制数据(如图像、音频等),适用范围广泛。
WebSocket 双向通信、持续连接的特点非常适合实时上传音频,接收识别结果的实时语音识别和实时上传文本,接收音频片段的语音合成。因此在实时语音服务中,使用 websocket 作为服务协议。
WebSocket的工作流程:
- 建立连接:客户端通过HTTP请求向服务器发起一个WebSocket连接。当服务器接受请求后,会返回一个特殊的响应,表示双方成功建立了WebSocket连接。
- 数据传输:一旦连接建立,客户端和服务器可以随时发送和接收数据,而不需要重新建立连接。
- 断开连接:当需要关闭连接时,任意一方都可以发送一个关闭请求,连接就会被优雅地关闭。
基于安全考虑,使用了 WebSocket Secure(简称 wss)连接,在 WebSocket 连接基础上增加了 SSL/TLS 加密层,确保传输的安全性。(注:在下文中,将不区分 wss 连接和 ws 连接)。
WebSocket 的缺点:
- 虽然 WebSocket 连接可以在建立连接之后显著的降低网络延迟,但是在创建连接时却需要较久的时间。这是由于在建立连接时,首先需要进行三次握手建立 tcp 连接以及一次升级协议握手。此外证书验证也会占用额外的时间。
- WebSocket保持持久连接,这意味着服务器端的资源会被占用。对于高并发的应用,服务器可能需要更多的内存和处理资源,管理大量的连接可能会增加服务器的负担。
三、高并发场景下的优化方案
1. 连接复用
简单的讲:
由于 WebSocket 连接具有持续连接的特点,为了避免重复创建连接的开销,百炼网关支持将同一个 WebSocket 连接复用在多个任务中。
具体点:
百炼网关为每一个连接分配一个 conn_id,并为每一个基于 WebSocket 连接的任务分配一个 task_id。一个 conn_id 可以对应多个 task_id。在每一个任务中,百炼规定了 "run-task" 到 "task-finished" 的任务生命周期,在一个任务结束后,可以立刻开始下一个任务的生命周期。
有了连接复用后,可以在同一个 websocket 连接中连续进行多次的实时语音任务。
举个例子:
在下图的日志中,同一个 WebSocket 连接(相同 conn_id)在半小时内复用了 1160 次,执行了 1160 次 cosyvoice-v1 的语音合成任务。
2. 连接池
简单的讲:
连接池机制是一种管理 WebSocket 连接的方式。连接池是一个固定容积的池子,每次需要创建新的 WsbSocket 连接时,直接从中取出一个就行,在用完后再放回去。
具体点:
在百炼 java SDK 中使用了 okhttp3 提供的连接池。连接池可以替使用者管理连接的存放和取用、回收以及资源限制。下面详细介绍连接池的特性。
- 连接的建立与释放:
- 连接池初始时为空。
- 当客户端需要与服务器建立 WebSocket 连接时,可以从连接池中获取一个可用的连接。如果池中没有可用连接,则会创建一个新的连接并返回给客户端。
- 当不再需要该连接时,它不会被立即关闭,而是返回到连接池中,以备后续使用,从而节省了连接的创建和销毁开销。
- 线程安全:
- 在高并发的应用中,多个线程可能会同时请求连接,因此连接池需要是线程安全的,以避免状态不一致和数据竞争问题。
- 资源管理
- 此外,连接池会管理连接的最大数量,包括总连接数和单个 IP 最大连接数。防止连接过多造成资源的浪费。
- 当取用连接数大于连接池容积时,会阻塞等待直到有其他任务释放连接。
使用方法:
在 java SDK 中已集成了连接池,不需要额外代码。可以通过三个环境变量配置 java SDK 中连接池的大小:
3. 对象复用和对象池
简单的讲:
在面向对象的编程语言中,对象对应着资源,对象的创建和销毁对应着资源的申请和释放。在高并发的场景中,会频繁的创建和销毁 SDK 对象以及申请、释放对应的连接。对象池和连接池类似,是一个用来管理 SDK 对象的固定容积的池子,每次需要创建新任务时,直接从中取出一个,在用完后再放回去。
具体点:
百炼所有的语音语音 SDK 类都支持对象复用,可以重复的使用同一个对象创建无数次任务。由于 SDK 内部没有集成对象池,因此我们使用apache.commons.pool2 提供的对象池管理 SDK 对象。
- 对象的创建与复用:
- 连接池初始时为空。
- 当应用需要一个对象时,它会从对象池中获取一个已有的对象。如果对象池中没有可用的对象,它可能会创建一个新的对象。
- 使用完对象后,应用并不销毁它,而是将其返回到对象池中,以便下次使用。
- 线程安全:
- 在高并发的应用中,多个线程可能会同时请求对象,因此对象池需要是线程安全的,以避免状态不一致和数据竞争问题。
- 资源管理:
- 对象池需要管理池的大小,包括最大和最小对象数量,以避免资源的过度消耗。
- 当取用对象数大于对象池容积时,会阻塞等待直到有其他任务释放对象。
使用方法:
以 cosyvoice-v1 对应的audio.ttsv2.SpeechSynthesizer 对象为例,介绍如何创建和使用对象池。
步骤一:配置三方依赖
以 maven 为例,通过在 pom.xml 中添加下述对象池依赖:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>the-latest-version</version> </dependency>
步骤二:创建全局对象池
package com.alibaba.example; import com.alibaba.dashscope.audio.ttsv2.SpeechSynthesizer; import org.apache.commons.pool2.BasePooledObjectFactory; import org.apache.commons.pool2.PooledObject; import org.apache.commons.pool2.impl.DefaultPooledObject; import org.apache.commons.pool2.impl.GenericObjectPool; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import java.util.concurrent.locks.Lock; class SpeechSynthesizerObjectFactory extends BasePooledObjectFactory<SpeechSynthesizer> { private String dashScopeApiKey; public SpeechSynthesizerObjectFactory() { super(); } public SpeechSynthesizer create() throws Exception { return new SpeechSynthesizer(); } public PooledObject<SpeechSynthesizer> wrap(SpeechSynthesizer obj) { return new DefaultPooledObject<>(obj); } } class CosyvoiceObjectPool { public static GenericObjectPool<SpeechSynthesizer> synthesizerPool; public static String COSYVOICE_OBJECTPOOL_SIZE_ENV = "COSYVOICE_OBJECTPOOL_SIZE"; public static int DEFAULT_OBJECT_POOL_SIZE = 500; private static Lock lock = new java.util.concurrent.locks.ReentrantLock(); public static int getObjectivePoolSize() { try { Integer n = Integer.parseInt(System.getenv(COSYVOICE_OBJECTPOOL_SIZE_ENV)); System.out.println("Using Object Pool Size In Env: "+ DEFAULT_OBJECT_POOL_SIZE); return n; } catch (NumberFormatException e) { System.out.println("Using Default Object Pool Size: "+ DEFAULT_OBJECT_POOL_SIZE); return DEFAULT_OBJECT_POOL_SIZE; } } public static GenericObjectPool<SpeechSynthesizer> getInstance() { lock.lock(); if (synthesizerPool == null) { // 您可以在这里设置对象池的大小。或在环境变量COSYVOICE_OBJECTPOOL_SIZE中设置。 // 建议设置为服务器最大并发连接数的1.5到2倍以应对突发流量。 int objectPoolSize = getObjectivePoolSize(); SpeechSynthesizerObjectFactory speechSynthesizerObjectFactory = new SpeechSynthesizerObjectFactory(); GenericObjectPoolConfig<SpeechSynthesizer> config = new GenericObjectPoolConfig<>(); config.setMaxTotal(objectPoolSize); config.setMaxIdle(objectPoolSize); config.setMinIdle(objectPoolSize); synthesizerPool = new GenericObjectPool<>(speechSynthesizerObjectFactory, config); } lock.unlock(); return synthesizerPool; } }
请参考上面的代码创建你的对象池,它可以放在项目中一个独立的 java 文件中管理。
在上面的代码中,创建了一个对象池的封装类:CosyvoiceObjectPool。它具有如下特点:
- 单例。每一个服务器上只需要一个对象池管理所有的对象。
- 通过静态方法 getInstance 以线程安全的方式获取对象池并借用或返还对象。
- 通过环境变量“COSYVOICE_OBJECTPOOL_SIZE”在服务启动前配置对象池大小,避免启动顺序带来的配置失效。
步骤三:使用对象池
将之前使用创建对象的方式调用语音服务的代码替换成从对象池中获取对象,记得要在最后将对象归还给对象池。
这里以最简单的同步调用语音合成为例:
public void run() { SpeechSynthesizer synthesizer = null; long startTime = System.currentTimeMillis(); try { class ReactCallback extends ResultCallback<SpeechSynthesisResult> { ReactCallback() {} public void onEvent(SpeechSynthesisResult message) { if (message.getAudioFrame() != null) { try { byte[] bytesArray = message.getAudioFrame().array(); System.out.println("收到音频,音频文件流length为:" + bytesArray.length); } catch (Exception e) { throw new RuntimeException(e); } } } public void onComplete() {} public void onError(Exception e) { System.out.println(e.getMessage()); e.printStackTrace(); } } // 将your-dashscope-api-key替换成您自己的API-KEY String dashScopeApiKey = "your-dashscope-api-key"; SpeechSynthesisParam param = SpeechSynthesisParam.builder() .model("cosyvoice-v1") .voice("longxiaochun") .format(SpeechSynthesisAudioFormat .MP3_22050HZ_MONO_256KBPS) // 流式合成使用PCM或者MP3 .apiKey(dashScopeApiKey) .build(); try { synthesizer = CosyvoiceObjectPool.getInstance().borrowObject(); synthesizer.updateParamAndCallback(param, new ReactCallback()); for (String text : textArray) { synthesizer.streamingCall(text); } Thread.sleep(20); synthesizer.streamingComplete(60000); requestId = synthesizer.getLastRequestId(); } catch (Exception e) { System.out.println("Exception e: " + e.toString()); synthesizer.getDuplexApi().close(1000, "bye"); } } catch (Exception e) { throw new RuntimeException(e); } finally { if (synthesizer != null) { try { // Return the SpeechSynthesizer object to the pool CosyvoiceObjectPool.getInstance().returnObject(synthesizer); } catch (Exception e) { e.printStackTrace(); } } } long endTime = System.currentTimeMillis(); timeCost = endTime - startTime; System.out.println("[线程 " + Thread.currentThread() + "] 语音合成任务结束。耗时 " + timeCost + " ms, RequestId " + requestId); }
关键代码:
- 第 45 行代码:从对象池中获取 SpeechSynthesizer 对象。
- 第 46 行代码:重制 SpeechSynthesizer 对象,并更新用于语音合成的配置和回调。
- 第 55 行代码:在出现异常时,需要中断出现错误的 websocket 连接后再归还对象。
- 第 63 行代码:在对象 SpeechSynthesizer 使用完成后归还到对象池中,供其他任务复用。
⚠️注意!
每一个对象会在创建时从连接池中获取连接,并在销毁时归还连接。因此对象的生命周期和连接的生命周期并不一致,连接的生命周期会更长。
四、对比实验
通过使用优化和不使用优化对比实验验证上述优化方案的效果。实验配置如下:
- 服务器规格:x86 8269CY(Cascade Lake)4 核心 8G 内存
- SDK 版本:2.16.9
- 测试策略:固定线程数为 100,每一个线程循环执行 Cosyvoice 语音合成任务,合成固定文本。
优化版本使用了前文中对象池和连接池调用,无优化版本不使用对象池、并且会在每次任务结束后手动关闭 WebSocket 连接。
监测工具
- 通过 top 工具监测 CPU 利用率
- 通过 sysstat 工具,sar 指令监测网络上/下行数据
- 通过 ss -nt | wc 命令统计当前 TCP 连接数(WebSocket 连接和 TCP 连接一一对应)
测试结果:
|
无优化 |
优化 |
CPU |
33% |
20% |
任务最大耗时 |
12 秒 |
5秒 |
任务平均耗时 |
5587ms |
3974ms |
网络负载(下行) |
7M |
4M |
5分钟完成任务数 |
5433 |
7602 |
测试结论:
经过优化后具有如下优势:
- 机器负载降低,相同机器规格可以支持更高的并发数。实际测量单机最大并发数可以从 50 提升至 100。
- 任务耗时降低。避免了创建 WebSocket 连接和 SDK 对象的耗时。
- 由于避免了反复创建连接,因此具有更低的网络负载。
- 由于优化后单位时间完成任务数更多,因此实际有效网络负载更高。这说明大量的网络带宽在优化前被用于反复握手、建连和丢包后重发。
- 避免服务启动后,瞬时创建过多 WebSocket 造成的卡顿。
- 无优化版本的最大耗时显著高于平均耗时,这是由于测试中途瞬时创建过多 WebSocket 造成的卡顿。
五、异常处理
在复用对象和连接优化方案后,在带来性能提升的同时也会带来更多的异常情况。百炼语音任务的异常包括服务端报错和客户端报错,接下来会分别介绍两种报错的底层原理和异常处理。
服务端报错:
服务端常见报错:常见报错包括 InvalidParameter,Internal Error 等。分别是由于客户端的错误调用或服务端出现自身问题(如资源不足)引发。
服务端报错处理:服务端报错后会下发 TaskFailed 消息,并立刻主动中断 WebSocket 连接。此时得益于 SDK 内部的自动重连机制,用户不需要额外处理这一类的异常,SDK 会在下一次复用这个已关闭连接时自动重连。
客户端报错:
客户端常见报错:非法输入、非法调用、调用中途客户业务代码抛出异常等。由于客户端报错和业务代码密切相关,SDK 内部无法处理所有的异常情况。
客户端报错处理:需要客户端主动捕获可能出现的异常,并且在此时关闭 WebSocket 连接后再将对象归还到对象池中。
主动关闭连接方法如下:
// cosyvoice-v1 synthesizer.getDuplexApi().close(1000, "bye"); // paraformer-realtime-v2 recognizer.getDuplexApi().close(1000, "bye"); // sambert-xxx-v1 synthesizer.getSyncApi().close(1000, "bye");
常见异常状况:
由于对象池和连接池引发的常见异常情况及解决方案请参考百炼文档。
更多调用SDK时遇到的异常请参考github项目。
👉阿里云百炼详情了解可点击此官网链接:阿里云百炼官网介绍
👉阿里云百炼控制台页面可点击此链接直接进入:阿里云百炼控制台
六、联系方式
我们在 GitHub 分享了百炼语音 SDK 示例代码。提供了丰富的示例,涵盖语音识别、语音合成、语音对话等多种功能。通过这些示例,您可以快速学习如何使用 SDK,并将其集成到您的应用程序中,打造出更智能、更人性化的用户体验。
如果您在调用百炼语音服务时遇到任何问题,或是有更多的开发建议或需求,欢迎通过钉钉群联系: