3、事件模型设计:定义基础事件类、事件优先级、事件元数据、事件序列化与反序列化

好,咱们今天聊聊事件模型。这是整个事件驱动系统的地基。

你想想看,如果事件本身都定义不清楚,那后面的处理流程肯定乱套。我在早期做交易系统时,就吃过这个亏——事件字段随意加,优先级全靠拍脑袋,结果系统跑起来跟一锅粥似的。

所以,这一节我们得把事件模型彻底讲透。从基础类定义,到优先级策略,再到元数据管理和序列化,一步到位。

3.1 基础事件类:一切事件的根

所有事件,不管是什么类型,都得有个共同的爹。这个爹就是基础事件类。

我个人习惯,基础事件类只放最核心的字段:事件ID、事件类型、时间戳。别的都别往里塞,塞多了就耦合了。

// 基础事件抽象类
[Serializable]
public abstract class BaseEvent
{
    /// <summary>
    /// 全局唯一事件ID,用于追踪和去重
    /// </summary>
    public string EventId { get; set; }

    /// <summary>
    /// 事件类型标识,用于路由到对应的处理器
    /// </summary>
    public string EventType { get; set; }

    /// <summary>
    /// 事件产生的时间戳(UTC),用于时序判断
    /// </summary>
    public long Timestamp { get; set; }

    protected BaseEvent()
    {
        EventId = Guid.NewGuid().ToString("N");
        Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    }
}

这里有个细节:EventId 我用的是 GUID 的 N 格式,去掉横杠,纯 32 位字符串。为什么?因为字符串比较快,而且日志里看着清爽。

我的习惯:时间戳统一用 UTC 毫秒级长整型。别用 DateTime,跨时区容易出幺蛾子。我曾经在跨市场套利系统里,就因为时区没统一,导致事件顺序错乱,亏了一笔。

3.2 事件优先级:谁先谁后,得有个规矩

事件多了,总得有个先后顺序。比如行情事件肯定比日志事件紧急,风控事件又比行情事件紧急。

我建议用枚举来定义优先级,简单明了。

public enum EventPriority
{
    /// <summary>
    /// 致命级别:风控、止损、系统异常
    /// </summary>
    Critical = 0,

    /// <summary>
    /// 高优先级:行情Tick、订单状态变更
    /// </summary>
    High = 1,

    /// <summary>
    /// 普通优先级:策略信号、定时任务
    /// </summary>
    Normal = 2,

    /// <summary>
    /// 低优先级:日志、统计、非实时数据
    /// </summary>
    Low = 3
}

为什么 Critical 用 0?因为数字越小,优先级越高。这在排序时很方便,直接按数值升序排就行。

注意:优先级不是越多越好。我见过有人搞了 10 级优先级,结果自己都分不清该用哪一级。3-4 级足够了,再多就是过度设计。

3.3 事件元数据:给事件加个「身份证」

光有事件本身还不够。我们还需要一些额外的信息来描述这个事件——比如它从哪里来、经过了哪些处理、有没有重试过。

这些信息,就是元数据。

public class EventMetadata
{
    /// <summary>
    /// 事件来源模块(如:MarketData、RiskEngine、Strategy)
    /// </summary>
    public string Source { get; set; }

    /// <summary>
    /// 事件版本号,用于兼容性判断
    /// </summary>
    public int Version { get; set; } = 1;

    /// <summary>
    /// 重试次数,用于失败重试机制
    /// </summary>
    public int RetryCount { get; set; } = 0;

    /// <summary>
    /// 自定义标签,用于过滤或路由
    /// </summary>
    public Dictionary<string, string> Tags { get; set; } = new();

    /// <summary>
    /// 事件产生时的上下文快照(如:当前持仓、账户余额)
    /// </summary>
    public string ContextSnapshot { get; set; }
}

我个人特别看重 ContextSnapshot 这个字段。为什么?因为事件处理失败时,你可以回放当时的上下文,快速定位问题。这比光看日志有用多了。

3.4 事件序列化与反序列化:让事件「跑」起来

事件定义好了,得能存、能传、能恢复。这就涉及到序列化与反序列化。

我推荐用 JSON 作为默认序列化格式。原因很简单:可读性强,调试方便,而且几乎所有语言都支持。

public class EventSerializer
{
    private static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = false,
        // 忽略空值,减少传输体积
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    /// <summary>
    /// 将事件对象序列化为JSON字符串
    /// </summary>
    public static string Serialize(BaseEvent eventObj)
    {
        // 包装成传输对象,包含元数据
        var wrapper = new EventWrapper
        {
            Metadata = eventObj.Metadata ?? new EventMetadata(),
            Event = eventObj
        };
        return JsonSerializer.Serialize(wrapper, Options);
    }

    /// <summary>
    /// 将JSON字符串反序列化为事件对象
    /// </summary>
    public static BaseEvent Deserialize(string json, string eventType)
    {
        // 根据事件类型动态反序列化
        var type = Type.GetType($"TradingEngine.Events.{eventType}");
        if (type == null)
            throw new InvalidOperationException($"未知的事件类型: {eventType}");

        var wrapper = JsonSerializer.Deserialize<EventWrapper>(json, Options);
        var eventObj = JsonSerializer.Deserialize(json, type, Options) as BaseEvent;
        eventObj.Metadata = wrapper?.Metadata;
        return eventObj;
    }
}

/// <summary>
/// 传输包装类,用于携带元数据
/// </summary>
public class EventWrapper
{
    public EventMetadata Metadata { get; set; }
    public BaseEvent Event { get; set; }
}
核心要点:序列化时一定要带上元数据。这样反序列化时,你才知道这个事件是什么版本、从哪里来、重试了几次。没有元数据的序列化,就是裸奔。

3.5 整体事件模型架构图

说了这么多,咱们用一张图把整个事件模型串起来。这样你脑子里就有个全局印象了。

事件模型整体架构 BaseEvent EventId EventType Timestamp EventPriority Critical (0) High (1) Normal (2) / Low (3) EventMetadata Source / Version RetryCount / Tags ContextSnapshot EventSerializer Serialize(BaseEvent) → JSON Deserialize(JSON) → BaseEvent EventWrapper (事件 + 元数据) 具体事件示例 TickEvent : BaseEvent OrderEvent : BaseEvent RiskEvent : BaseEvent 传输层(消息队列 / Redis / gRPC)

这张图把整个事件模型分成了三层:

  • 底层:基础事件类 + 优先级 + 元数据,这是事件的「骨架」
  • 中间层:序列化模块,负责把事件「打包」和「拆包」
  • 上层:具体事件 + 传输层,这是事件真正发挥作用的地方

3.6 避坑指南:我踩过的那些坑

最后,分享几个我实际项目中遇到的坑,你遇到了可以少走弯路。

  1. 事件ID重复:我曾经用时间戳+自增ID生成事件ID,结果在高并发下出现了重复。后来统一改用 GUID,再也没出过问题。
  2. 序列化循环引用:事件对象里如果引用了其他对象,序列化时容易死循环。解决办法:用 [JsonIgnore] 标记不需要序列化的字段,或者用 ReferenceHandler.IgnoreCycles
  3. 元数据膨胀:有人喜欢往元数据里塞各种信息,结果一个事件光元数据就占了几 KB。记住:元数据只放跟事件处理流程相关的信息,别当数据库用。
  4. 版本兼容:事件结构会变。我建议在元数据里加个 Version 字段,反序列化时根据版本做兼容处理。否则旧事件反序列化新代码,直接崩给你看。
我的小技巧:在开发阶段,序列化时把 WriteIndented 设为 true,这样日志里的事件可读性极强。上线后再改成 false,减少带宽消耗。

嗯,事件模型设计就聊到这儿。你把这些基础打牢了,后面的事件总线、事件处理器、事件溯源,都会顺很多。


公众号:蓝海资料掘金营,微信deep3321