DataPool 技术设计文档 —— 线程安全的数据中枢

# DataPool 技术设计文档

## 1. 概述
一句话描述:DataPool 是一个线程安全的单例数据中枢,通过 Key-Value 存储和事件订阅机制解耦数据生产者与消费者。

## 2. 设计动机
- 为什么需要 DataPool?(模块解耦、数据共享、事件驱动)
- 解决了什么问题?(多设备数据汇聚、UI 实时更新、日志过滤)

## 3. 核心特性
- ✅ 线程安全(ConcurrentDictionary)
- ✅ 事件驱动(订阅/取消订阅)
- ✅ 智能日志过滤(单次变化阈值 + 累计变化阈值)
- ✅ 数据质量标记(Quality:0/1/-1)
- ✅ 时间戳记录
- ✅ 回调异常隔离
- ✅ 取消订阅重试机制

## 4. 架构图
[数据生产者] → Write → [DataPool] → 订阅通知 → [数据消费者]
                ↓
           [日志记录]
        (阈值过滤后)

## 5. 核心 API
### 5.1 写入数据
void Write(string key, object value, int quality = 1)

### 5.2 读取数据
DataPoint Read(string key)

### 5.3 订阅变化
void Subscribe(string key, EventHandler<DataPoint> callback)

### 5.4 取消订阅
void Unsubscribe(string key, EventHandler<DataPoint> callback)

### 5.5 阈值配置
double ChangeThreshold { get; set; }  // 默认 1
void SetThreshold(string key, double threshold)

## 6. 智能日志过滤机制
- **单次变化触发**:数值变化 > ChangeThreshold
- **累计变化触发**:从上次记录日志后,累计变化 > ChangeThreshold
- **首次写入**:始终记录
- **质量变化**:始终记录
- **类型变化**:始终记录
using System.Collections.Concurrent;

namespace TestDataPool
{
    /// <summary>
    /// 数据点实体,包含值、时间戳和质量标记
    /// </summary>
    public class DataPoint
    {
        /// <summary>数据值</summary>
        public object Value { get; set; }

        /// <summary>数据产生的时间戳</summary>
        public DateTime Timestamp { get; set; } = DateTime.Now;

        /// <summary>
        /// 数据质量标记
        /// <para>0 = 初始值</para>
        /// <para>1 = 好</para>
        /// <para>-1 = 坏</para>
        /// </summary>
        public int Quality { get; set; } = 0;
    }

    /// <summary>
    /// 全局数据池(单例模式)
    /// <para>线程安全的数据存储中枢,支持订阅/取消订阅数据变化事件</para>
    /// </summary>
    public class DataPool
    {
        private static readonly Lazy<DataPool> _instance = new(() => new DataPool());

        /// <summary>获取 DataPool 单例实例</summary>
        public static DataPool Instance => _instance.Value;

        private readonly ConcurrentDictionary<string, DataPoint> _store = new();
        private readonly ConcurrentDictionary<string, EventHandler<DataPoint>> _subscriptions = new();

        // ==========================================
        // 变化阈值配置
        // ==========================================

        /// <summary>
        /// 全局变化阈值(默认 1)
        /// <para>当单次变化或累计变化超过此阈值时记录日志</para>
        /// </summary>
        public double ChangeThreshold { get; set; } = 1;

        private readonly ConcurrentDictionary<string, double> _customThresholds = new();

        // 存储每个 Key 的累计变化量(带符号)
        private readonly ConcurrentDictionary<string, double> _cumulativeDelta = new();

        private readonly LogHelper _log = LogHelper.GetLogger("DataPool");

        private DataPool()
        {
            _log.Info("DataPool 单例初始化完成");
        }

        // ==========================================
        // 阈值管理
        // ==========================================

        /// <summary>
        /// 为指定 Key 单独设置变化阈值(优先级高于全局阈值)
        /// </summary>
        /// <param name="key">数据键名</param>
        /// <param name="threshold">变化阈值</param>
        public void SetThreshold(string key, double threshold)
            => _customThresholds.AddOrUpdate(key, threshold, (k, old) => threshold);

        /// <summary>
        /// 移除指定 Key 的自定义阈值(恢复使用全局阈值)
        /// </summary>
        /// <param name="key">数据键名</param>
        public void RemoveThreshold(string key)
            => _customThresholds.TryRemove(key, out _);

        /// <summary>
        /// 获取指定 Key 的有效阈值(优先返回自定义阈值,否则返回全局阈值)
        /// </summary>
        private double GetThreshold(string key)
            => _customThresholds.TryGetValue(key, out var t) ? t : ChangeThreshold;

        // ==========================================
        // 写入数据
        // ==========================================

        /// <summary>
        /// 写入数据到数据池
        /// </summary>
        /// <param name="key">数据键名,格式建议 "{设备ID}.{属性名}"</param>
        /// <param name="value">数据值</param>
        /// <param name="quality">数据质量:1=好,0=初始,-1=坏</param>
        /// <remarks>
        /// <para>写入后会触发订阅此 Key 的所有回调</para>
        /// <para>单次变化或累计变化超过阈值时记录日志</para>
        /// </remarks>
        public void Write(string key, object value, int quality = 1)
        {
            try
            {
                var oldPoint = ReadInternal(key);
                bool shouldLog = ShouldLogChange(key, oldPoint, value, quality);

                // ===== 新增:累计变化检测 =====
                double delta = 0;
                if (oldPoint != null)
                {
                    try
                    {
                        double oldVal = Convert.ToDouble(oldPoint.Value);
                        double newVal = Convert.ToDouble(value);
                        delta = newVal - oldVal;
                    }
                    catch { /* 忽略非数值类型 */ }
                }

                if (oldPoint != null && Math.Abs(delta) > 0.0000001)
                {
                    _cumulativeDelta.AddOrUpdate(key, delta, (k, old) => old + delta);
                }

                _cumulativeDelta.TryGetValue(key, out double cumulative);
                double threshold = GetThreshold(key);
                bool cumulativeExceed = Math.Abs(cumulative) > threshold;

                if (cumulativeExceed)
                    shouldLog = true;
                // ===== 新增结束 =====

                var point = new DataPoint { Value = value, Timestamp = DateTime.Now, Quality = quality };
                _store.AddOrUpdate(key, point, (k, old) => point);

                // 触发订阅回调,每个订阅者独立 try-catch
                if (_subscriptions.TryGetValue(key, out var handler) && handler != null)
                {
                    foreach (EventHandler<DataPoint> singleHandler in handler.GetInvocationList())
                    {
                        try
                        {
                            singleHandler?.Invoke(this, point);
                        }
                        catch (Exception ex)
                        {
                            _log.Error($"订阅回调异常 [{key}]: {ex.Message}");
                        }
                    }
                }

                // 记录日志
                if (shouldLog)
                {
                    string oldVal = oldPoint?.Value?.ToString() ?? "null";

                    // 判断触发原因
                    if (cumulativeExceed && oldPoint != null)
                    {
                        _log.Info($"写入: {key} = {value} (累计变化: {cumulative:+0.000;-0.000;0}, 旧值: {oldVal})");
                    }
                    else if (oldPoint != null && Math.Abs(delta) > threshold)
                    {
                        _log.Info($"写入: {key} = {value} (单次变化: {delta:+0.000;-0.000;0}, 旧值: {oldVal})");
                    }
                    else
                    {
                        _log.Info($"写入: {key} = {value} (旧值: {oldVal})");
                    }

                    // 记录后重置累计变化
                    _cumulativeDelta.AddOrUpdate(key, 0, (k, old) => 0);
                }
            }
            catch (Exception ex)
            {
                _log.Error($"写入失败 [{key}]: {ex.Message}");
            }
        }

        /// <summary>
        /// 判断数据变化是否值得记录日志(仅单次变化判断,累计判断在 Write 中额外处理)
        /// </summary>
        private bool ShouldLogChange(string key, DataPoint oldPoint, object newValue, int newQuality)
        {
            // 首次写入 -> 记录
            if (oldPoint == null) return true;

            // 质量变化 -> 记录
            if (oldPoint.Quality != newQuality) return true;

            // 类型变化 -> 记录
            if (oldPoint.Value?.GetType() != newValue?.GetType()) return true;

            try
            {
                double oldDouble = Convert.ToDouble(oldPoint.Value);
                double newDouble = Convert.ToDouble(newValue);
                double threshold = GetThreshold(key);
                return Math.Abs(newDouble - oldDouble) > threshold;
            }
            catch
            {
                return true; // 无法转换,保守记录
            }
        }

        // ==========================================
        // 读取数据
        // ==========================================

        /// <summary>
        /// 内部读取方法,不记录日志(用于 Write 中判断变化)
        /// </summary>
        private DataPoint ReadInternal(string key)
        {
            _store.TryGetValue(key, out var point);
            return point;
        }

        /// <summary>
        /// 读取数据点
        /// </summary>
        /// <param name="key">数据键名</param>
        /// <returns>
        /// <para>如果 Key 存在,返回对应的 DataPoint</para>
        /// <para>如果 Key 不存在,返回 null</para>
        /// </returns>
        public DataPoint Read(string key)
        {
            try
            {
                _store.TryGetValue(key, out var point);
                if (point != null)
                {
                    _log.Info($"读取数据: Key={key}, Value={point.Value}, Quality={point.Quality}");
                }
                else
                {
                    _log.Info($"读取数据: Key={key} 不存在,返回 null");
                }
                return point;
            }
            catch (Exception ex)
            {
                _log.Error($"读取数据失败: Key={key}, 异常: {ex.Message}");
                return null;
            }
        }

        // ==========================================
        // 订阅 / 取消订阅
        // ==========================================

        /// <summary>
        /// 订阅指定 Key 的数据变化事件
        /// </summary>
        /// <param name="key">要订阅的数据键名</param>
        /// <param name="callback">数据变化时的回调方法</param>
        /// <remarks>
        /// <para>当该 Key 被 Write 时,所有订阅者的回调会被依次执行</para>
        /// <para>如果 callback 为 null,会记录错误日志并忽略</para>
        /// </remarks>
        public void Subscribe(string key, EventHandler<DataPoint> callback)
        {
            if (callback == null)
            {
                _log.Error("订阅失败: callback 为 null");
                return;
            }

            try
            {
                _subscriptions.AddOrUpdate(key, callback, (k, old) => old + callback);
                _log.Info($"订阅成功: {key}");
            }
            catch (Exception ex)
            {
                _log.Error($"订阅失败 [{key}]: {ex.Message}");
            }
        }

        /// <summary>
        /// 取消订阅指定 Key 的数据变化事件
        /// </summary>
        /// <param name="key">要取消订阅的数据键名</param>
        /// <param name="callback">要移除的回调方法</param>
        /// <remarks>
        /// <para>必须传入与 Subscribe 时相同的委托实例才能成功移除</para>
        /// <para>如果 callback 为 null,会记录错误日志并忽略</para>
        /// <para>如果该 Key 没有其他订阅者,会自动从订阅字典中移除该 Key</para>
        /// <para>极端并发下会自动重试最多 3 次</para>
        /// </remarks>
        public void Unsubscribe(string key, EventHandler<DataPoint> callback)
        {
            if (callback == null)
            {
                _log.Error("取消订阅失败: callback 为 null");
                return;
            }

            const int maxRetries = 3;
            int retryCount = 0;

            while (retryCount < maxRetries)
            {
                try
                {
                    if (!_subscriptions.TryGetValue(key, out var existing))
                    {
                        _log.Info($"取消订阅: {key} 未找到订阅,忽略");
                        return;
                    }

                    var newHandler = existing - callback;
                    if (newHandler == null)
                    {
                        if (_subscriptions.TryRemove(key, out _))
                        {
                            _log.Info($"取消订阅: {key},已移除所有回调");
                            return;
                        }
                    }
                    else
                    {
                        if (_subscriptions.TryUpdate(key, newHandler, existing))
                        {
                            _log.Info($"取消订阅: {key},移除指定回调");
                            return;
                        }
                    }

                    retryCount++;
                    if (retryCount < maxRetries)
                    {
                        _log.Info($"取消订阅并发冲突 [{key}],第 {retryCount} 次重试...");
                        Thread.Sleep(1);
                    }
                }
                catch (Exception ex)
                {
                    _log.Error($"取消订阅异常 [{key}]: {ex.Message}");
                    return;
                }
            }

            _log.Error($"取消订阅失败 [{key}],已达到最大重试次数 {maxRetries}");
        }

        // ==========================================
        // 辅助方法
        // ==========================================

        /// <summary>
        /// 获取数据池中所有已存储的 Key
        /// </summary>
        /// <returns>所有 Key 的集合</returns>
        public IEnumerable<string> GetAllKeys() => _store.Keys;
    }

}

测试这个datapool的代码

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("=======================================");
        Console.WriteLine("开始 DataPool 独立单元测试");
        Console.WriteLine("=======================================");

        RunAllTests();

        Console.WriteLine("=======================================");
        Console.WriteLine("所有测试执行完毕,按任意键退出...");
        Console.ReadKey();
    }

    static void RunAllTests()
    {
        Test_BasicReadWrite();
        Test_SubscriptionTrigger();
        Test_Unsubscribe();
        Test_ConcurrentStress();
    }

    // ============================================
    // 测试 1:基础读写
    // ============================================
    static void Test_BasicReadWrite()
    {
        var pool = DataPool.Instance;
        string key = "TEST.Basic";

        pool.Write(key, 3.14159);
        var point = pool.Read(key);

        if (point != null && point.Value is double d && Math.Abs(d - 3.14159) < 0.0001)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine($"✅ 基础读写测试通过: 读取到 {d}");
            Console.ResetColor();
        }
        else
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"❌ 基础读写测试失败: 读取到 null 或值不正确");
            Console.ResetColor();
        }
    }

    // ============================================
    // 测试 2:订阅通知(写数据时触发回调)
    // ============================================
    static void Test_SubscriptionTrigger()
    {
        var pool = DataPool.Instance;
        string key = "TEST.Subscription";

        var tcs = new TaskCompletionSource<bool>();

        EventHandler<DataPoint> handler = (s, e) =>
        {
            if (e.Value is int val && val == 999)
            {
                tcs.TrySetResult(true);
            }
        };

        pool.Subscribe(key, handler);
        pool.Write(key, 999);

        bool triggered = tcs.Task.Wait(2000);

        if (triggered)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("✅ 订阅通知测试通过: 回调被成功触发");
            Console.ResetColor();
        }
        else
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine("❌ 订阅通知测试失败: 回调未触发");
            Console.ResetColor();
        }

        pool.Unsubscribe(key, handler);
    }

    // ============================================
    // 测试 3:取消订阅(取消后不再触发回调)
    // ============================================
    static void Test_Unsubscribe()
    {
        var pool = DataPool.Instance;
        string key = "TEST.Unsubscribe";

        bool wasCalled = false;

        EventHandler<DataPoint> handler = (s, e) =>
        {
            wasCalled = true;
            Console.WriteLine($"回调被意外触发,值={e.Value}");
        };

        pool.Subscribe(key, handler);
        pool.Write(key, 1);
        Thread.Sleep(100);

        if (!wasCalled)
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine("❌ 预检失败:订阅后回调未触发,测试无法继续");
            Console.ResetColor();
            pool.Unsubscribe(key, handler);
            return;
        }

        wasCalled = false;
        pool.Unsubscribe(key, handler);
        pool.Write(key, 2);
        Thread.Sleep(100);

        if (!wasCalled)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("✅ 取消订阅测试通过: 取消后回调未被触发");
            Console.ResetColor();
        }
        else
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine("❌ 取消订阅测试失败: 取消后回调仍被触发(内存泄漏风险)");
            Console.ResetColor();
        }
    }

    // ============================================
    // 测试 4:并发压力测试(多线程同时写同一个key)
    // ============================================
    static void Test_ConcurrentSameKeyStress()
    {
        var pool = DataPool.Instance;
        string key = "TEST.Stress";

        int writeCount = 5000;
        int completedWrites = 0;
        bool hasError = false;

        var tasks = new List<Task>();
        for (int t = 0; t < 5; t++)
        {
            tasks.Add(Task.Run(() =>
            {
                try
                {
                    for (int i = 0; i < writeCount; i++)
                    {
                        pool.Write(key, i);
                        Interlocked.Increment(ref completedWrites);
                    }
                }
                catch (Exception ex)
                {
                    hasError = true;
                    Console.WriteLine($"并发线程抛出异常: {ex.Message}");
                }
            }));
        }

        Task.WaitAll(tasks.ToArray());

        if (!hasError && completedWrites == 5 * writeCount)
        {
            var final = pool.Read(key);
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine($"✅ 并发压力测试通过: 共写入 {completedWrites} 次,最终值={final?.Value}");
            Console.ResetColor();
        }
        else
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"❌ 并发压力测试失败: 写入次数={completedWrites}, 期望={5 * writeCount}, 有异常={hasError}");
            Console.ResetColor();
        }
    }


    /// <summary>
    /// 测试 5:并发压力测试(多线程同时写不同的key)这是真实的使用场景,验证 DataPool 在高并发下的稳定性和线程安全性。
    /// </summary>
    static void Test_ConcurrentStress()
    {
        var pool = DataPool.Instance;
        int threadCount = 5;
        int writeCountPerThread = 5000;
        bool hasError = false;

        var tasks = new List<Task>();

        for (int t = 0; t < threadCount; t++)
        {
            // 每个线程使用独立的 Key
            string key = $"TEST.Stress_Thread_{t}";

            tasks.Add(Task.Run(() =>
            {
                try
                {
                    for (int i = 0; i < writeCountPerThread; i++)
                    {
                        pool.Write(key, i);
                    }
                }
                catch (Exception ex)
                {
                    hasError = true;
                    Console.WriteLine($"线程 {key} 抛出异常: {ex.Message}");
                }
            }));
        }

        Task.WaitAll(tasks.ToArray());

        if (!hasError)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine($"✅ 并发压力测试通过: {threadCount} 个线程各写入 {writeCountPerThread} 次,无异常");

            // 可选:验证每个 Key 的值是否正确(最后一个写入的值应为 writeCountPerThread - 1)
            for (int t = 0; t < threadCount; t++)
            {
                string key = $"TEST.Stress_Thread_{t}";
                var point = pool.Read(key);
                if (point != null)
                {
                    Console.WriteLine($"   Key={key}, 最终值={point.Value}, 质量={point.Quality}");
                }
                else
                {
                    Console.WriteLine($"   Key={key} 不存在!");
                }
            }
            Console.ResetColor();
        }
        else
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"❌ 并发压力测试失败: 有线程抛出了异常");
            Console.ResetColor();
        }
    }
}

 

 
posted @ 2026-08-07 11:20  ckrgd  阅读(8)  评论(0)    收藏  举报