编辑 | blame | 历史 | 原始文档

多出库口轮询功能设计文档

日期: 2026-03-09
作者: Claude Code
状态: 待实施

概述

本设计旨在改进自动出库任务的目标地址配置功能,支持一个巷道配置多个出库口,并通过轮询算法实现负载均衡。

需求背景

当前系统中,TargetAddresses 配置采用一对一映射(巷道前缀 → 出库口地址)。但实际业务中,一个巷道可能有多个出库口,需要支持:

  1. 一对多关系:一个巷道可以配置多个出库口
  2. 负载均衡:通过轮询算法选择出库口,避免单点压力
  3. 向后兼容:保持对单出口配置的支持

技术方案

架构选择

采用 内存轮询计数器 + 独立服务类 的模式:

  1. RoundRobinService:独立的轮询服务类,管理轮询计数器
  2. 配置模型变更Dictionary<string, string>Dictionary<string, List<string>>
  3. 线程安全:使用 ConcurrentDictionary 保证多线程安全

核心设计

1. 配置格式变更

appsettings.json:
json { "AutoOutboundTask": { "TargetAddresses": { "GW": ["10081", "10082", "10083"], "CW": ["10080"] } } }

说明:
- 值类型从 string 改为 string[](JSON 数组)
- 支持一个巷道配置多个出库口
- 单个出库口也可用数组格式(只有一个元素)

2. 配置模型类

AutoOutboundTaskOptions.cs:
```csharp
public class AutoOutboundTaskOptions
{
public bool Enable { get; set; } = true;
public int CheckIntervalSeconds { get; set; } = 300;

public Dictionary<string, List<string>> TargetAddresses { get; set; }
    = new()
    {
        { "GW", new List<string> { "10081" } },
        { "CW", new List<string> { "10080" } }
    };

}
```

3. 轮询服务类

RoundRobinService.cs (新建):
```csharp
using System.Collections.Concurrent;

namespace WIDESEA_Core.Core
{
///


/// 轮询服务 - 线程安全的地址轮询选择
///
public class RoundRobinService
{
private readonly ConcurrentDictionary<string, int> _counters = new();

    /// <summary>
    /// 获取下一个地址(轮询)
    /// </summary>
    /// <param name="key">巷道前缀</param>
    /// <param name="addresses">地址列表</param>
    /// <returns>选中的地址</returns>
    public string GetNextAddress(string key, List<string> addresses)
    {
        if (addresses == null || addresses.Count == 0)
            return "10080";

        if (addresses.Count == 1)
            return addresses[0];

        // AddOrUpdate 是原子操作,线程安全
        int index = _counters.AddOrUpdate(
            key,
            0,                              // 首次使用,从 0 开始
            (k, old) => (old + 1) % addresses.Count  // 轮询:递增后取模
        );

        return addresses[index];
    }
}

}
```

关键特性:
- 使用 ConcurrentDictionary<string, int> 存储每个巷道的当前索引
- AddOrUpdate 方法保证原子性,无需额外锁
- 模运算实现循环轮询
- 线程安全,支持后台服务的并发调用

4. TaskService 方法修改

DetermineTargetAddress 方法:
```csharp
private string DetermineTargetAddress(
string roadway,
Dictionary<string, List> addressMap)
{
if (string.IsNullOrWhiteSpace(roadway))
return "10080";

// 查找匹配的巷道前缀
string matchedPrefix = null;
foreach (var kvp in addressMap)
{
    if (roadway.Contains(kvp.Key))
    {
        matchedPrefix = kvp.Key;
        break;
    }
}

if (matchedPrefix == null)
    return "10080";

var addresses = addressMap[matchedPrefix];
if (addresses == null || addresses.Count == 0)
    return "10080";

// 单个地址,直接返回
if (addresses.Count == 1)
    return addresses[0];

// 多个地址,使用轮询
return _roundRobinService.GetNextAddress(matchedPrefix, addresses);

}
```

依赖注入:
```csharp
public class TaskService
{
private readonly RoundRobinService _roundRobinService;

public TaskService(
    // ... 其他依赖
    RoundRobinService roundRobinService)
{
    // ... 其他赋值
    _roundRobinService = roundRobinService;
}

}
```

5. 服务注册

Program.cs:
csharp // 注册为单例,保证全局共享计数器 builder.Services.AddSingleton<RoundRobinService>();

数据流程

graph TD
    A[创建出库任务] --> B[获取库存巷道信息]
    B --> C[查找匹配的巷道前缀]
    C --> D{地址列表数量}
    D -->|0个| E[返回默认地址 10080]
    D -->|1个| F[直接返回该地址]
    D -->|多个| G[调用 RoundRobinService]
    G --> H[查询/更新计数器]
    H --> I[返回 addresses counter % count]
    I --> J[创建任务]
    F --> J
    E --> J

轮询示例

配置:
json { "TargetAddresses": { "GW": ["10081", "10082", "10083"] } }

调用序列:
| 次数 | 计数器值 | 返回地址 |
|------|---------|---------|
| 1 | 0 | 10081 |
| 2 | 1 | 10082 |
| 3 | 2 | 10083 |
| 4 | 0 | 10081 |
| 5 | 1 | 10082 |
| ... | ... | ... |

错误处理

  1. 配置错误
  • 地址列表为空 → 返回默认地址 "10080"
  • 巷道不匹配任何前缀 → 返回默认地址 "10080"
  1. 并发安全
  • 使用 ConcurrentDictionary 保证线程安全
  • AddOrUpdate 是原子操作
  1. 应用重启
  • 轮询位置重置为 0
  • 可接受的权衡(简化实现)

向后兼容性

旧配置格式

{
  "TargetAddresses": {
    "GW": "10081",
    "CW": "10080"
  }
}

新配置格式

{
  "TargetAddresses": {
    "GW": ["10081"],
    "CW": ["10080"]
  }
}

兼容性处理:
- JSON 配置绑定会自动处理两种格式
- 单个字符串会被解析为单元素数组
- 现有配置无需修改即可工作

性能影响

  1. 内存开销:每个巷道前缀一个整数计数器(约 50 字节)
  2. CPU 开销:模运算和字典查找,O(1) 复杂度
  3. 线程安全:无锁设计,ConcurrentDictionary 使用优化的同步机制

测试计划

单元测试

  1. RoundRobinService.GetNextAddress 轮询逻辑
  2. 单个地址直接返回
  3. 多个地址轮询返回
  4. 并发调用测试

集成测试

  1. 修改 appsettings.json 为多出口配置
  2. 创建多个出库任务
  3. 验证目标地址轮询分配
  4. 验证日志记录

手动测试

-- 准备测试数据
UPDATE Dt_StockInfo
SET OutboundDate = DATEADD(MINUTE, -5, GETDATE())
WHERE PalletCode IN ('TEST001', 'TEST002', 'TEST003')
  AND StockStatus = 1;

启动应用,观察日志:
info: 创建任务 TEST001,目标地址: 10081 info: 创建任务 TEST002,目标地址: 10082 info: 创建任务 TEST003,目标地址: 10083

部署注意事项

  1. 配置更新
  • TargetAddresses 的值从字符串改为数组格式
  • 确保每个数组至少有一个元素
  1. 依赖注入
  • 确保 RoundRobinService 已注册为单例
  • TaskService 构造函数需要注入该服务
  1. 回滚方案
  • 保留 appsettings.json 备份
  • 如遇问题可恢复为单出口配置

未来改进

  1. 权重轮询:支持为每个地址配置权重
  2. 健康检查:排除不可用的出库口
  3. 持久化:将轮询位置保存到 Redis/数据库
  4. 监控统计:记录每个地址的任务分配数量

实施清单

  • [ ] 修改 AutoOutboundTaskOptions.TargetAddresses 类型
  • [ ] 创建 RoundRobinService
  • [ ] 修改 TaskService.DetermineTargetAddress 方法
  • [ ] 在 Program.cs 注册 RoundRobinService
  • [ ] 更新 appsettings.json 配置示例
  • [ ] 编译验证
  • [ ] 手动测试
  • [ ] 更新设计文档