4. 交易日历的查询与使用

在交易系统中,交易日历不是一个静态的“节假日表”,而是贯穿行情接入、订单校验、清算交收、任务调度的基础时钟。它决定了“今天能不能下单”“这笔交易算哪一天”“结算日落在哪一天”。本节围绕交易日历的查询与使用,从API设计、缓存策略到性能优化逐层展开,帮助你把一个看似简单的查询接口做成稳定、低延迟、可扩展的基础服务。

一、核心原理:交易日历到底在回答什么问题

交易日历的本质是一组带属性的日期集合,常见查询语义包括:某日是否为交易日、某日属于哪个交易市场、两个日期之间的交易日序列、某日之后第N个交易日、某日之前第N个交易日、某笔交易对应的交收日(T+1、T+2)等。不同市场(A股、港股、、期货夜盘)规则不同,因此日历必须按“市场+日期”维度建模,而不是全局一张表。

从数据结构看,最有效的表示方式是“有序交易日数组 + 日期到索引的哈希映射”。交易日数组保证序列查询和偏移查询是O(1)或O(log n),哈希映射保证单日判断是O(1)。节假日、半日市、临时休市等特殊状态可作为附加属性挂在日期节点上。

交易日历查询:从请求到缓存的分层结构 调用方 交易/风控/清算 日历查询API isTradingDay / nextN 本地缓存 Caffeine / Map 持久层 DB / 文件 缓存键与失效策略 key = market + year TTL = 1天 + 版本号 临时休市 → 主动刷新 查询路径:先查本地缓存 → 未命中查持久层 → 回填缓存;写路径:日历变更后发布版本号,各节点按版本失效。

二、API设计:把查询语义收敛成稳定契约

API设计的第一原则是“语义明确、参数可组合、返回可预测”。建议按查询类型拆分为独立方法,而不是用一个万能接口塞入大量可选参数。典型接口如下:

// 单日判断
boolean isTradingDay(String market, LocalDate date);

// 区间交易日列表
List<LocalDate> tradingDaysBetween(String market, LocalDate start, LocalDate end);

// 偏移查询:从某日起第n个交易日,n可为负
LocalDate shiftTradingDay(String market, LocalDate base, int n);

// 交收日计算
LocalDate settlementDate(String market, LocalDate tradeDate, int tPlus);

// 批量查询,减少网络往返
Map<LocalDate, Boolean> batchIsTradingDay(String market, List<LocalDate> dates);

参数设计上,market必须显式传入,避免默认市场带来的隐性错误;日期统一使用LocalDate,不要用字符串或时间戳混用。返回结果建议封装为带版本号的对象,例如{version, market, data},便于调用方判断数据新鲜度。

对于高频调用,优先提供批量接口。一次批量查询100个日期,比100次单日查询减少99%的网络开销,且服务端可复用同一份日历数据。

三、缓存策略:多级缓存与失效机制

交易日历数据具有“读多写少、按年稳定、偶发变更”的特点,非常适合缓存。推荐采用本地缓存 + 分布式缓存 + 持久层的三级结构。本地缓存使用Caffeine或Guava Cache,设置合理的最大条目数和过期时间;分布式缓存使用Redis,key按calendar:{market}:{year}组织。

层级 存储介质 典型TTL 适用场景
L1本地缓存 Caffeine / Map 5~30分钟 单日判断、高频偏移查询
L2分布式缓存 Redis 1天 跨节点共享、批量查询
L3持久层 MySQL / 文件 永久 冷启动、缓存穿透兜底

失效策略是缓存设计的难点。交易日历的变更通常来自交易所临时公告,例如台风休市、临时调整。此时不能等待TTL自然过期,而应通过版本号机制主动失效:每次日历数据更新,版本号递增,各节点在查询时比对版本号,不一致则重新加载。版本号可存放在配置中心或Redis中,读取成本极低。

不要用“每天凌晨全量刷新”作为唯一失效手段。临时休市可能发生在盘中,若缓存未及时失效,会导致订单校验错误,属于高风险故障。

四、性能优化:从O(n)到O(1)的关键手段

性能优化的核心是减少重复计算和内存占用。首先,将某市场某年的交易日预计算为有序数组,并建立Map<LocalDate, Integer>索引。这样单日判断是O(1),偏移查询通过索引加减再取值,也是O(1)。区间查询则通过两次二分查找定位起止索引,再截取子数组,复杂度为O(log n + k)。

// 预计算结构示例
class MarketCalendar {
    List<LocalDate> tradingDays;          // 有序交易日
    Map<LocalDate, Integer> indexMap;     // 日期 -> 索引
    int version;
}

// 偏移查询 O(1)
LocalDate shift(LocalDate base, int n) {
    Integer idx = indexMap.get(base);
    if (idx == null) return null;
    int target = idx + n;
    if (target < 0 || target >= tradingDays.size()) return null;
    return tradingDays.get(target);
}

其次,控制内存占用。一个市场10年的交易日约2500条,每条LocalDate加索引开销约几十字节,整体不过百KB级别,完全可常驻内存。若市场数量多,可按需加载,避免启动时全量加载造成冷启动缓慢。

第三,批量查询合并。服务端收到批量日期后,先查本地缓存,未命中的日期合并为一次持久层查询,再统一回填。这样可将N次IO压缩为1次。

五、案例与复盘:一次临时休市引发的缓存故障

某交易系统在台风日收到交易所临时休市通知,但日历服务仍使用前一日加载的缓存,导致风控模块认为当天是交易日,继续接受订单,最终产生大量废单。复盘发现三个问题:一是缓存TTL设置为24小时,未考虑盘中变更;二是没有版本号机制,无法主动失效;三是调用方没有对“临时休市”状态做二次校验。

改进方案包括:引入版本号并在配置中心监听变更;将临时休市标记为高优先级事件,触发全节点缓存刷新;在API返回中增加status字段,区分正常交易日、周末、法定节假日、临时休市;调用方对临时休市状态执行拒绝下单逻辑。经过改造,同类故障未再发生。

交易日历的查询与使用,表面是日期判断,实质是“数据一致性 + 缓存时效性 + 接口契约”的综合工程。把API语义定清楚、把缓存失效做主动、把查询复杂度降到O(1),才能让这个基础服务在高频交易场景下稳定可靠。