fix 音频并发优化
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 5m41s

This commit is contained in:
repair-agent 2026-03-03 17:21:46 +08:00
parent a3222d1fe5
commit 134ccb70f3
14 changed files with 647 additions and 9 deletions

4
=3.0.1 Normal file
View File

@ -0,0 +1,4 @@
Requirement already satisfied: opuslib in ./venv/lib/python3.14/site-packages (3.0.1)
[notice] A new release of pip is available: 25.3 -> 26.0.1
[notice] To update, run: /Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend/venv/bin/python3.14 -m pip install --upgrade pip

View File

@ -14,6 +14,8 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia
gcc \
default-libmysqlclient-dev \
pkg-config \
ffmpeg \
libopus-dev \
&& rm -rf /var/lib/apt/lists/*
# Install python dependencies

View File

@ -363,7 +363,11 @@ class DeviceViewSet(viewsets.ViewSet):
status_code=status.HTTP_404_NOT_FOUND
)
return success(data={'title': story.title, 'audio_url': story.audio_url})
return success(data={
'title': story.title,
'audio_url': story.audio_url,
'opus_url': story.opus_url,
})
@action(detail=False, methods=['post'], url_path='report-status',
authentication_classes=[], permission_classes=[AllowAny])

View File

@ -0,0 +1,112 @@
"""
批量将已有故事的 MP3 音频预转码为 Opus JSON 并上传 OSS
使用方法:
python manage.py convert_stories_to_opus
python manage.py convert_stories_to_opus --dry-run # 仅统计,不转码
python manage.py convert_stories_to_opus --limit 10 # 只处理前 10 个
python manage.py convert_stories_to_opus --force # 重新转码已有 opus_url 的故事
"""
import uuid
import logging
from datetime import datetime
import requests
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.stories.models import Story
from apps.stories.services.opus_converter import convert_mp3_to_opus_json
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = '批量将已有故事的 MP3 音频预转码为 Opus 帧 JSON'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run', action='store_true',
help='仅统计需要转码的故事数量,不实际执行',
)
parser.add_argument(
'--limit', type=int, default=0,
help='最多处理的故事数量0=不限)',
)
parser.add_argument(
'--force', action='store_true',
help='重新转码已有 opus_url 的故事',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
limit = options['limit']
force = options['force']
# 查找需要转码的故事
qs = Story.objects.exclude(audio_url='')
if not force:
qs = qs.filter(opus_url='')
qs = qs.order_by('id')
total = qs.count()
self.stdout.write(f'需要转码的故事: {total}')
if dry_run:
self.stdout.write(self.style.NOTICE('[dry-run] 仅统计,不执行转码'))
return
if total == 0:
self.stdout.write(self.style.SUCCESS('所有故事已转码,无需处理'))
return
# OSS 客户端
from utils.oss import get_oss_client
oss_client = get_oss_client()
oss_config = settings.ALIYUN_OSS
if oss_config.get('CUSTOM_DOMAIN'):
url_prefix = f"https://{oss_config['CUSTOM_DOMAIN']}"
else:
url_prefix = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}"
stories = qs[:limit] if limit > 0 else qs
success_count = 0
fail_count = 0
for i, story in enumerate(stories.iterator(), 1):
self.stdout.write(f'\n[{i}/{total}] Story#{story.id} "{story.title}"')
self.stdout.write(f' MP3: {story.audio_url[:80]}...')
try:
# 下载 MP3
resp = requests.get(story.audio_url, timeout=60)
resp.raise_for_status()
mp3_bytes = resp.content
self.stdout.write(f' MP3 大小: {len(mp3_bytes) / 1024:.1f} KB')
# 转码
opus_json = convert_mp3_to_opus_json(mp3_bytes)
self.stdout.write(f' Opus JSON 大小: {len(opus_json) / 1024:.1f} KB')
# 上传 OSS
opus_filename = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.json"
opus_key = f"stories/audio-opus/{opus_filename}"
oss_client.bucket.put_object(opus_key, opus_json.encode('utf-8'))
opus_url = f"{url_prefix}/{opus_key}"
story.opus_url = opus_url
story.save(update_fields=['opus_url'])
success_count += 1
self.stdout.write(self.style.SUCCESS(f' OK: {opus_url}'))
except Exception as e:
fail_count += 1
self.stdout.write(self.style.ERROR(f' FAIL: {e}'))
logger.error(f'Story#{story.id} opus convert failed: {e}')
self.stdout.write(f'\n{"=" * 40}')
self.stdout.write(self.style.SUCCESS(
f'完成: 成功 {success_count}, 失败 {fail_count}, 总计 {success_count + fail_count}'
))

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-03 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stories', '0004_story_is_default'),
]
operations = [
migrations.AddField(
model_name='story',
name='opus_url',
field=models.URLField(blank=True, default='', max_length=500, verbose_name='Opus音频URL'),
),
]

View File

@ -54,6 +54,7 @@ class Story(models.Model):
content = models.TextField('内容', blank=True, default='')
cover_url = models.URLField('封面URL', max_length=500, blank=True, default='')
audio_url = models.URLField('音频URL', max_length=500, blank=True, default='')
opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='')
has_video = models.BooleanField('是否有视频', default=False)
video_url = models.URLField('视频URL', max_length=500, blank=True, default='')
generation_mode = models.CharField(

View File

@ -0,0 +1,72 @@
"""
MP3 Opus 预转码服务
MP3 音频转为 Opus 帧列表JSON + base64 hw_service_go 直接下载播放
跳过实时 ffmpeg 转码大幅降低首帧延迟和 CPU 消耗
Opus 参数与 hw_service_go 保持一致16kHz, 单声道, 60ms/
"""
import base64
import json
import logging
import subprocess
import opuslib
logger = logging.getLogger(__name__)
SAMPLE_RATE = 16000
CHANNELS = 1
FRAME_DURATION_MS = 60
FRAME_SIZE = SAMPLE_RATE * FRAME_DURATION_MS // 1000 # 960 samples
BYTES_PER_FRAME = FRAME_SIZE * 2 # 16bit = 2 bytes per sample
def convert_mp3_to_opus_json(mp3_bytes: bytes) -> str:
"""
MP3 音频数据转码为 Opus JSON
流程: MP3 bytes ffmpeg(PCM 16kHz mono s16le) opuslib(60ms Opus )
Returns:
JSON 字符串包含 base64 编码的 Opus 帧列表
"""
# 1. ffmpeg: MP3 → PCM (16kHz, mono, signed 16-bit little-endian)
proc = subprocess.run(
[
'ffmpeg', '-nostdin', '-loglevel', 'error',
'-i', 'pipe:0',
'-ar', str(SAMPLE_RATE),
'-ac', str(CHANNELS),
'-f', 's16le',
'pipe:1',
],
input=mp3_bytes,
capture_output=True,
timeout=120,
)
if proc.returncode != 0:
stderr = proc.stderr.decode(errors='replace')
raise RuntimeError(f'ffmpeg 转码失败: {stderr}')
pcm = proc.stdout
if len(pcm) < BYTES_PER_FRAME:
raise RuntimeError(f'PCM 数据过短: {len(pcm)} bytes')
# 2. Opus 编码:逐帧编码
encoder = opuslib.Encoder(SAMPLE_RATE, CHANNELS, 'audio')
frames = []
for offset in range(0, len(pcm) - BYTES_PER_FRAME + 1, BYTES_PER_FRAME):
chunk = pcm[offset:offset + BYTES_PER_FRAME]
opus_frame = encoder.encode(chunk, FRAME_SIZE)
frames.append(base64.b64encode(opus_frame).decode('ascii'))
logger.info(f'Opus 预转码完成: {len(frames)} 帧, '
f'{len(frames) * FRAME_DURATION_MS / 1000:.1f}s 音频')
return json.dumps({
'sample_rate': SAMPLE_RATE,
'channels': CHANNELS,
'frame_duration_ms': FRAME_DURATION_MS,
'frames': frames,
}, separators=(',', ':')) # 紧凑格式,减少体积

View File

@ -85,13 +85,43 @@ def generate_tts_stream(story):
# 更新故事记录
story.audio_url = audio_url
story.save(update_fields=['audio_url'])
except Exception as e:
logger.error(f'OSS upload failed: {e}')
yield _sse_event('error', {'message': f'音频上传失败: {str(e)}'})
return
# Opus 预转码MP3 → Opus 帧 JSON上传 OSS
yield _sse_event('stage', {
'stage': 'opus_converting',
'progress': 80,
'message': '正在预转码 Opus 音频...',
})
try:
from apps.stories.services.opus_converter import convert_mp3_to_opus_json
opus_json = convert_mp3_to_opus_json(audio_data)
opus_filename = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.json"
opus_key = f"stories/audio-opus/{opus_filename}"
oss_client.bucket.put_object(opus_key, opus_json.encode('utf-8'))
if oss_config.get('CUSTOM_DOMAIN'):
opus_url = f"https://{oss_config['CUSTOM_DOMAIN']}/{opus_key}"
else:
opus_url = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{opus_key}"
story.opus_url = opus_url
logger.info(f'Opus 预转码上传成功: {opus_url}')
except Exception as e:
logger.warning(f'Opus 预转码失败(不影响 MP3 播放): {e}')
# 预转码失败不阻断流程MP3 仍可正常使用
story.save(update_fields=['audio_url', 'opus_url'])
yield _sse_event('done', {
'stage': 'done',
'progress': 100,

View File

@ -0,0 +1,193 @@
# 故事音频预转码方案 — MP3 → Opus 预处理
> 创建时间2026-03-03
> 状态:待实施
## Context
**问题**:当前 hw_service_go 每次播放故事都实时执行 `MP3下载 → ffmpeg转码 → Opus编码`ffmpeg 是 CPU 密集型操作,压测显示 0.5 核 CPU 下 5 个并发就首帧延迟 4.5s。
**方案**:在 TTS 生成 MP3 后,立即预转码为 Opus 帧数据JSON 格式)并上传 OSS。hw_service_go 播放时直接下载预处理好的 Opus 数据,跳过 ffmpeg首帧延迟从秒级降到毫秒级。
**预期效果**
- hw_service_go 播放时 **零 CPU 转码开销**
- 首帧延迟从 ~2s 降到 ~200ms
- 并发播放容量从 5-10 个提升到 **100+**(瓶颈变为网络/内存)
**压测数据参考**(单 Pod, 0.5 核 CPU, 512Mi
| 并发故事数 | 首帧延迟 | 帧数/故事 | 错误 |
|-----------|---------|----------|------|
| 2 | 2.0s | 796 | 0 |
| 5 | 4.5s | 796 | 0 |
| 10 | 8.7s | 796 | 0 |
| 20 | 17.4s | 796 | 0 |
详见 [压测报告](../rtc_backend/hw_service_go/test/stress/REPORT.md)
---
## 改动概览
| 改动范围 | 文件 | 改动大小 |
|---------|------|---------|
| DjangoStory 模型 | `apps/stories/models.py` | 小(加 1 个字段) |
| DjangoTTS 服务 | `apps/stories/services/tts_service.py` | 中(加预转码逻辑) |
| Django故事 API | `apps/devices/views.py` | 小(返回新字段) |
| Django迁移文件 | `apps/stories/migrations/` | 自动生成 |
| GoAPI 响应结构体 | `hw_service_go/internal/rtcclient/client.go` | 小 |
| Go播放处理器 | `hw_service_go/internal/handler/story.go` | 中(分支逻辑) |
| Go新增 Opus 下载 | `hw_service_go/internal/audio/` | 中(新函数) |
**总改动量:中等偏小**,核心改动集中在 3 个文件。
---
## 详细方案
### Step 1: Story 模型加字段
**文件**`apps/stories/models.py`
```python
# 在 Story 模型中新增
opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='')
```
`opus_url` 存储预转码后的 Opus JSON 文件地址。为空表示未转码(兼容旧数据)。
然后 `makemigrations` + `migrate`
### Step 2: TTS 服务中增加预转码
**文件**`apps/stories/services/tts_service.py`
在 MP3 上传 OSS 成功后(第 88 行 `story.save` 之前),增加:
1. 调用 ffmpeg 将 MP3 bytes 转为 PCM16kHz, mono, s16le
2. 用 Python opuslib或 subprocess 调 ffmpeg 直出 opus编码为 60ms 帧
3. 将帧列表序列化为紧凑格式上传 OSS
4. 保存 `story.opus_url`
**Opus 数据格式JSON + base64**
```json
{
"sample_rate": 16000,
"channels": 1,
"frame_duration_ms": 60,
"frames": ["<base64帧1>", "<base64帧2>", ...]
}
```
> 一个 5 分钟故事约 5000 帧 × ~300 bytes/帧 ≈ 1.5MB JSON压缩后 ~1MB对 OSS 存储无压力。
**转码实现**subprocess 调 ffmpeg + opuslib
```python
import subprocess, base64, json, opuslib
def convert_mp3_to_opus_frames(mp3_bytes):
"""MP3 → PCM → Opus 帧列表"""
# ffmpeg: MP3 → PCM
proc = subprocess.run(
['ffmpeg', '-i', 'pipe:0', '-ar', '16000', '-ac', '1', '-f', 's16le', 'pipe:1'],
input=mp3_bytes, capture_output=True
)
pcm = proc.stdout
# Opus 编码:每帧 960 samples (60ms @ 16kHz)
encoder = opuslib.Encoder(16000, 1, opuslib.APPLICATION_AUDIO)
frame_size = 960
frames = []
for i in range(0, len(pcm) // 2 - frame_size + 1, frame_size):
chunk = pcm[i*2 : (i+frame_size)*2]
opus_frame = encoder.encode(chunk, frame_size)
frames.append(base64.b64encode(opus_frame).decode())
return json.dumps({
"sample_rate": 16000,
"channels": 1,
"frame_duration_ms": 60,
"frames": frames
})
```
上传路径:`stories/audio-opus/YYYYMMDD/{uuid}.json`
### Step 3: Django API 返回 opus_url
**文件**`apps/devices/views.py``stories_by_mac` 方法)
```python
return success(data={
'title': story.title,
'audio_url': story.audio_url,
'opus_url': story.opus_url, # 新增
})
```
### Step 4: Go 服务适配
**文件**`hw_service_go/internal/rtcclient/client.go`
```go
type StoryInfo struct {
Title string `json:"title"`
AudioURL string `json:"audio_url"`
OpusURL string `json:"opus_url"` // 新增
}
```
**文件**`hw_service_go/internal/audio/` — 新增函数
```go
// FetchOpusFrames 从 OSS 下载预转码的 Opus JSON 文件,解析为帧列表
func FetchOpusFrames(ctx context.Context, opusURL string) ([][]byte, error)
```
**文件**`hw_service_go/internal/handler/story.go` — 修改播放逻辑
```go
// 优先使用预转码 Opus
var frames [][]byte
if story.OpusURL != "" {
frames, err = audio.FetchOpusFrames(ctx, story.OpusURL)
} else {
// 兜底:旧数据无预转码,走实时转码
frames, err = audio.MP3URLToOpusFrames(ctx, story.AudioURL)
}
```
### Step 5: 历史数据迁移(可选)
写一个 management command 批量转码已有故事:
```bash
python manage.py convert_stories_to_opus
```
---
## 兼容性
- **旧故事**`opus_url` 为空hw_service_go 自动 fallback 到实时 ffmpeg 转码,无影响
- **新故事**TTS 生成时自动预转码hw_service_go 直接下载 Opus 数据
- **App 端**:无任何改动,`audio_url`MP3仍然存在供 App 播放器使用
---
## 依赖
- Django 端需安装 `opuslib`Python Opus 绑定):`pip install opuslib`
- Django 服务器需有 `ffmpeg`(已有,用于 TTS 后处理等)
- 如果不想引入 opuslib 依赖,可以用 `ffmpeg -c:a libopus` 直接输出 opus但需要自行按 60ms 分帧
---
## 验证方法
1. 本地创建一个故事 + TTS → 检查 `opus_url` 是否生成
2. `curl /api/v1/devices/stories/?mac_address=...` 确认返回含 `opus_url`
3. hw_service_go 本地启动,连接测试页面触发故事 → 确认跳过 ffmpeg
4. 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级

View File

@ -0,0 +1,66 @@
package audio
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// opusJSON 是 Django 预转码上传的 Opus JSON 文件结构。
type opusJSON struct {
SampleRate int `json:"sample_rate"`
Channels int `json:"channels"`
FrameDurationMs int `json:"frame_duration_ms"`
Frames []string `json:"frames"` // base64 编码的 Opus 帧
}
// FetchOpusFrames 从 OSS 下载预转码的 Opus JSON 文件,解析为原始帧列表。
// 跳过 ffmpeg 实时转码,大幅降低 CPU 消耗和首帧延迟。
func FetchOpusFrames(ctx context.Context, opusURL string) ([][]byte, error) {
httpCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, opusURL, nil)
if err != nil {
return nil, fmt.Errorf("audio: build opus request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("audio: download opus json: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("audio: opus json status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB 上限
if err != nil {
return nil, fmt.Errorf("audio: read opus json: %w", err)
}
var data opusJSON
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("audio: parse opus json: %w", err)
}
if len(data.Frames) == 0 {
return nil, fmt.Errorf("audio: opus json has no frames")
}
frames := make([][]byte, 0, len(data.Frames))
for i, b64 := range data.Frames {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("audio: decode frame %d: %w", i, err)
}
frames = append(frames, raw)
}
return frames, nil
}

View File

@ -43,14 +43,27 @@ func HandleStory(conn *connection.Connection, client *rtcclient.Client) {
}
log.Printf("%s playing: %s", tag, story.Title)
// 3. 下载 MP3 并转码为 Opus 帧CPU 密集,在当前 goroutine 中执行)
frames, err := audio.MP3URLToOpusFrames(ctx, story.AudioURL)
if err != nil {
log.Printf("%s audio convert error: %v", tag, err)
return
// 3. 获取 Opus 帧:优先使用预转码数据,否则实时 ffmpeg 转码
var frames [][]byte
if story.OpusURL != "" {
frames, err = audio.FetchOpusFrames(ctx, story.OpusURL)
if err != nil {
log.Printf("%s fetch pre-converted opus failed, fallback to ffmpeg: %v", tag, err)
frames = nil // 确保 fallback
} else {
log.Printf("%s loaded %d pre-converted frames (~%.1fs)", tag, len(frames),
float64(len(frames)*audio.FrameDurationMs)/1000)
}
}
if frames == nil {
frames, err = audio.MP3URLToOpusFrames(ctx, story.AudioURL)
if err != nil {
log.Printf("%s audio convert error: %v", tag, err)
return
}
log.Printf("%s converted %d frames (~%.1fs)", tag, len(frames),
float64(len(frames)*audio.FrameDurationMs)/1000)
}
log.Printf("%s converted %d frames (~%.1fs)", tag, len(frames),
float64(len(frames)*audio.FrameDurationMs)/1000)
// 4. 通知硬件:句子开始(发送故事标题)
if err := conn.SendJSON(map[string]any{

View File

@ -15,6 +15,7 @@ import (
type StoryInfo struct {
Title string `json:"title"`
AudioURL string `json:"audio_url"`
OpusURL string `json:"opus_url"` // 预转码 Opus JSON 地址,为空表示未转码
}
// Client 是 RTC 后端的 HTTP 客户端,复用连接池。

View File

@ -0,0 +1,121 @@
# hw_service_go 并发压力测试报告
> 测试时间2026-03-03
> 测试目标:`wss://qiyuan-rtc-api.airlabs.art/xiaozhi/v1/`
> Pod 配置:单 PodCPU 100m~500mlimitsMemory 128Mi~512Milimitsreplicas: 1
---
## 一、测试环境
| 项目 | 配置 |
|------|------|
| 服务 | hw_service_goWebSocket + Opus 音频推送) |
| 部署 | K8s 单 Pod1 副本 |
| CPU limits | 500m0.5 核) |
| Memory limits | 512Mi |
| 硬编码连接上限 | 500 |
| 测试工具 | Go 压测工具(`test/stress/main.go` |
| 测试客户端 | macOS从公网连接线上服务 |
---
## 二、测试结果
### 2.1 连接容量测试(空闲连接)
```
go run main.go -url wss://..../xiaozhi/v1/ -conns 200 -stories 0 -duration 30s
```
| 指标 | 结果 |
|------|------|
| 目标连接 | 200 |
| 成功连接 | 200 |
| 握手成功 | 200 |
| 错误 | 0 |
**结论200 个空闲连接毫无压力,内存不是瓶颈。**
### 2.2 并发播放压力测试
每个"活跃故事"会触发Django API 查询 → MP3 下载 → ffmpeg 转码 → Opus 编码 → WebSocket 推帧。
| 并发故事数 | 总连接 | 首帧延迟 | 帧数/故事 | 错误 | 状态 |
|-----------|--------|---------|----------|------|------|
| 2 | 10 | **2.0s** | 796 | 0 | 轻松 |
| 5 | 10 | **4.5s** | 796 | 0 | 正常 |
| 10 | 20 | **8.7s** | 796 | 0 | 吃力但稳 |
| 20 | 30 | **17.4s** | 796 | 0 | 极限 |
### 2.3 关键发现
1. **帧数始终稳定 796/故事** — 音频完整交付,零丢帧,服务可靠性极高
2. **首帧延迟线性增长** — 约 0.85s/并发,纯 CPU 瓶颈(多个 ffmpeg 进程争抢 0.5 核)
3. **Pod 未触发 OOMKill** — 512Mi 内存对 20 并发播放也够用
4. **全程零错误** — 无连接断开、无握手失败、无帧丢失
---
## 三、瓶颈分析
```
单个故事播放的资源消耗链路:
Django API (GET) → MP3 下载 (OSS) → ffmpeg 转码 (CPU密集) → Opus 编码 → WebSocket 推帧
主要瓶颈
每个并发故事启动一个 ffmpeg 子进程
多个 ffmpeg 共享 0.5 核 CPU
```
| 资源 | 是否瓶颈 | 说明 |
|------|---------|------|
| **CPU** | **是** | ffmpeg 转码是 CPU 密集型0.5 核被多个 ffmpeg 进程分时使用 |
| 内存 | 否 | 20 并发播放未触发 OOM512Mi 充足 |
| 网络 | 否 | Opus 帧约 4-7 KB/s/连接,带宽远未饱和 |
| 连接数 | 否 | 空闲连接 200+ 无压力,硬上限 500 |
---
## 四、容量结论
### 当前单 Pod0.5 核 CPU, 512Mi, 1 副本)
| 指标 | 数值 |
|------|------|
| 空闲连接上限 | **200+**(轻松) |
| 并发播放(体验好,首帧 < 5s | **~5 ** |
| 并发播放(可接受,首帧 < 10s | **~10 ** |
| 并发播放(极限,首帧 ~17s | **~20 个** |
| 瓶颈资源 | CPUffmpeg 转码) |
---
## 五、扩容建议
| 方案 | 变更 | 预估并发播放(首帧 < 10s | 成本 |
|------|------|------------------------|------|
| **提 CPU** | limits 500m → 1000m | ~20 个 | 低 |
| **加副本** | replicas 1 → 2 | ~10 个(负载均衡) | 中 |
| **两者都做** | 1000m CPU + 2 副本 | **~40 个** | 中 |
| 垂直扩容 | 2000m CPU + 1Gi 内存 | ~40 个 | 中 |
> **推荐方案**replicas: 2 + CPU limits: 1000m兼顾高可用与并发能力。
---
## 六、测试命令参考
```bash
cd hw_service_go/test/stress
# 空闲连接容量
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 200 -stories 0 -duration 30s
# 并发播放(逐步加压)
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 10 -stories 2 -duration 60s
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 10 -stories 5 -duration 60s
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 20 -stories 10 -duration 90s
go run main.go -url wss://TARGET/xiaozhi/v1/ -conns 30 -stories 20 -duration 120s
```

View File

@ -39,3 +39,4 @@ alibabacloud-endpoint-util==0.0.4
darabonba-core==1.0.5
volcengine-python-sdk[ark]>=5.0.9
edge-tts>=6.1.0
opuslib>=3.0.1