Version: 0.5.0.dev.260310

refactor: ♻️ 调整 Outbox 写入时序并移除 Kafka 首包同步投递逻辑

* 将 `outbox` 表写入逻辑后置到 LLM 请求之后,减少主链路阻塞
* 删除 Codex 生成的 Kafka 首包同步投递抽象逻辑,简化消息发送流程
* 优化 SSE 首字到达时间,整体降低约 1s 延迟
* 当前在请求 LLM 之前的流程全部为 Redis 操作,显著降低 IO 开销

docs: 📊 保留 SSE 链路性能打点逻辑

* 保留原有 SSE 全链路打点计时代码,便于后续性能排查与分析
* 当前默认注释,如需使用可手动启用进行性能调试
This commit is contained in:
Losita
2026-03-10 23:10:09 +08:00
parent 959049db42
commit 912a6d8cfe
6 changed files with 138 additions and 123 deletions

View File

@@ -15,11 +15,7 @@ import (
"gorm.io/gorm"
)
// AgentAsyncPipeline 负责 outbox 的“异步可靠链路”:
// 1) 业务侧写 outboxpending
// 2) dispatch loop 扫描并投递 Kafkapublished
// 3) consume loop 消费并落库consumed
// 4) 任一步失败按重试策略回到 pending 或 dead。
// AgentAsyncPipeline 负责 outbox 扫描、Kafka 投递与消费落库。
type AgentAsyncPipeline struct {
outboxRepo *dao.OutboxDAO
producer *kafkabus.Producer
@@ -56,9 +52,6 @@ func NewAgentAsyncPipeline(outboxRepo *dao.OutboxDAO, cfg kafkabus.Config) (*Age
}, nil
}
// Start 启动两个后台协程:
// - startDispatchLoop扫描 pending 并投递 Kafka
// - startConsumeLoop消费 Kafka 并执行业务落库
func (p *AgentAsyncPipeline) Start(ctx context.Context) {
if p == nil {
return
@@ -66,8 +59,6 @@ func (p *AgentAsyncPipeline) Start(ctx context.Context) {
log.Printf("Kafka async pipeline starting: topic=%s brokers=%v retry_scan=%s batch=%d", p.topic, p.brokers, p.scanEvery, p.scanBatch)
if err := kafkabus.WaitTopicReady(ctx, p.brokers, p.topic, 30*time.Second); err != nil {
// 首次部署常见情况broker 已起来但 topic/partition 尚未可用。
// 这里明确打印,避免“消息堆积但控制台无提示”的观感。
log.Printf("Kafka topic not ready before consume loop start: %v", err)
} else {
log.Printf("Kafka topic is ready: %s", p.topic)
@@ -89,25 +80,22 @@ func (p *AgentAsyncPipeline) Close() {
}
}
// EnqueueChatHistoryPersist 是业务侧入口:
// 1) 先写 outbox
// 2) 立刻尝试“首发投递”一次(非阻塞主流程);
// 3) 失败后由扫描器按 next_retry_at 继续重试。
// EnqueueChatHistoryPersist 仅把消息写入 outbox。
//
// 关键设计:
// 1) 不再在请求路径里做“首次同步投递 Kafka”
// 2) 投递统一由 startDispatchLoop 异步扫描执行;
// 3) CreateChatHistoryMessage 会设置 next_retry_at=now扫描器下一轮即可捞取。
//
// 这样可以把请求链路成本收敛到“写 outbox”避免 Kafka 写入延迟污染首字和主链路时延。
func (p *AgentAsyncPipeline) EnqueueChatHistoryPersist(ctx context.Context, payload model.ChatHistoryPersistPayload) error {
if p == nil {
return errors.New("Kafka 异步链路未初始化")
}
outboxID, err := p.outboxRepo.CreateChatHistoryMessage(ctx, p.topic, payload.ConversationID, payload, p.maxRetry)
if err != nil {
return err
}
if err = p.dispatchOne(context.Background(), outboxID); err != nil {
log.Printf("outbox 消息 %d 首次投递失败,等待扫描重试: %v", outboxID, err)
}
return nil
_, err := p.outboxRepo.CreateChatHistoryMessage(ctx, p.topic, payload.ConversationID, payload, p.maxRetry)
return err
}
// startDispatchLoop 定时扫描 pending 且到期的消息,逐条尝试投递。
func (p *AgentAsyncPipeline) startDispatchLoop(ctx context.Context) {
ticker := time.NewTicker(p.scanEvery)
defer ticker.Stop()
@@ -134,11 +122,6 @@ func (p *AgentAsyncPipeline) startDispatchLoop(ctx context.Context) {
}
}
// dispatchOne 执行单条 outbox 投递:
// 1) 读取 outbox 行;
// 2) 组装 Envelope
// 3) 写 Kafka
// 4) 回写 published。
func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) error {
outboxMsg, err := p.outboxRepo.GetByID(ctx, outboxID)
if err != nil {
@@ -147,7 +130,6 @@ func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) er
}
return err
}
// 终态不再重复投递。
if outboxMsg.Status == model.OutboxStatusConsumed || outboxMsg.Status == model.OutboxStatusDead {
return nil
}
@@ -159,7 +141,6 @@ func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) er
}
raw, err := json.Marshal(envelope)
if err != nil {
// 序列化都失败通常是坏数据,直接死信。
markErr := p.outboxRepo.MarkDead(ctx, outboxMsg.ID, "序列化 outbox 包装失败: "+err.Error())
if markErr != nil {
log.Printf("标记 outbox 死信失败(id=%d): %v", outboxMsg.ID, markErr)
@@ -178,7 +159,6 @@ func (p *AgentAsyncPipeline) dispatchOne(ctx context.Context, outboxID int64) er
return nil
}
// startConsumeLoop 持续从 Kafka 拉取消息并处理。
func (p *AgentAsyncPipeline) startConsumeLoop(ctx context.Context) {
for {
select {
@@ -202,11 +182,9 @@ func (p *AgentAsyncPipeline) startConsumeLoop(ctx context.Context) {
}
}
// handleMessage 先解析 Envelope再按 biz_type 分发到具体处理器。
func (p *AgentAsyncPipeline) handleMessage(ctx context.Context, msg segmentkafka.Message) error {
var envelope kafkabus.Envelope
if err := json.Unmarshal(msg.Value, &envelope); err != nil {
// 包装体坏数据,提交 offset 跳过,避免阻塞分区。
_ = p.consumer.Commit(ctx, msg)
return fmt.Errorf("解析 Kafka 包装失败: %w", err)
}
@@ -219,7 +197,6 @@ func (p *AgentAsyncPipeline) handleMessage(ctx context.Context, msg segmentkafka
case model.OutboxBizTypeChatHistoryPersist:
return p.consumeChatHistory(ctx, msg, envelope)
default:
// 未知业务类型直接死信并提交,避免反复重试无意义数据。
_ = p.outboxRepo.MarkDead(ctx, envelope.OutboxID, "未知业务类型: "+envelope.BizType)
if err := p.consumer.Commit(ctx, msg); err != nil {
return err
@@ -228,10 +205,6 @@ func (p *AgentAsyncPipeline) handleMessage(ctx context.Context, msg segmentkafka
}
}
// consumeChatHistory 执行“聊天记录持久化”消费逻辑。
// 提交策略说明:
// 1) 成功落库后提交;
// 2) 失败时先回写 outbox便于重试/排障),再提交,避免分区阻塞。
func (p *AgentAsyncPipeline) consumeChatHistory(ctx context.Context, msg segmentkafka.Message, envelope kafkabus.Envelope) error {
var payload model.ChatHistoryPersistPayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {