CPU缓存失效与缓存一致性问题
我在之前学习操作系统的时候在想,我一个搞软件层面开发的为什么要了解别人设计好的东西,直接调用接口不行吗(反正我又不去手搓一个操作系统),这种荒谬的想法一直延续到今天。直到这次在设计一个高频交易的系统时,我才意识到这些知识有多重要。
我先来大致介绍一下该系统,该系统的全名为来财智投LCST数字资产交易系统。附上仓库地址,该系统支持现金交易、币币交易,采用Disruptor高性能框架进行订单撮合,并且通过WebSocket实时推送供用户查看当前K线与盘口数据(更详细的架构可以查看前面的链接),想要做到低延迟的同时保证资金链路的安全稳定是一个巨大的挑战。
回归正题,下面我们就来聊聊我是怎么遇到CPU缓存失效问题的(为什么对操作系统转变态度哈哈)。
行业内主流的货币交易系统都有这么一个功能,就是需要在页面实时显示盘口数据供用户查看,用户根据该盘口数据就可大致判断该币种的当前价值,从而做出对自己有利的判断(博主在这提醒大家:劳动是创造价值的唯一源泉),当某个用户进行下单时,盘口数据会发生相应的变化,因此,盘口数据的即时响应就显得非常重要。
所谓盘口:及买单数据与卖单数据。想象你去校园集市上买二手手机,你出价1000一台OppoA5,但是此时集市并没有卖OppoA5或者价格低于你的期望,所以你的该委托就会被放到盘口中(这是一个买单),现在又有一个人他打算出价500卖出一辆小米su7,该委托也被放到集市的盘口中(这是一个卖单)。盘口展示了一个物品目前大致的价值。
前面我们提到盘口的实时性很重要,那么在我的该系统中是如何架构的呢?

通过上面的架构我们可以发现,前端通过websocket技术与chan-service服务进行通信,chan-service会实时将数据推送到前端,那么问题来了,chan-service中的数据是从哪里来的呢?我们来看下面的设计图:

因为委托单的吃单挂单操作都是在撮合服务中完成的,所以撮合服务中总是有当前最新的数据。
我们来看撮合服务提供给外界获取盘口数据的接口代码:
@GetMapping("/plates")
@Override
public Map<String, List<DepthItemVo>> querySymbolDepth(@RequestParam String symbol) {
for (EventHandler<OrderEvent> eventHandler : eventHandlers) {
OrderEventHandler orderEventHandler = (OrderEventHandler) eventHandler;
if(orderEventHandler.getSymbol().equals(symbol)) {
HashMap<String, List<DepthItemVo>> symbolDepthMap = new HashMap<>();
symbolDepthMap.put("bids",orderEventHandler.getOrderBooks().getBuyTradePlate().getItems());
symbolDepthMap.put("asks",orderEventHandler.getOrderBooks().getSellTradePlate().getItems());
return symbolDepthMap;
}
}
return Collections.emptyMap();
}
// 这里只展示部分字段内容
class OrderBooks {
/**
* 买入的盘口数据
*/
private TradePlate buyTradePlate;
/**
* 卖出的盘口数据
*/
private TradePlate sellTradePlate;
}
这段代码有很多的问题,但是这里我们先不讨论,我们先来看看他的工作流程,当接收到一个请求时,他会去遍历事件处理器(这是架构设计的问题,我将一个事件处理器分别与一个交易对进行绑定,这样做到多交易对间并发执行,而单个交易对内的撮合则是串行处理,做到高并发的同时减少并发安全问题),查询与当前交易对相关联的事件处理器,然后从其对应的账本OrderBooks中获取盘口数据,放入新建的Map容器中。
接下来我们再看看当订单进行撮合时,盘口数据是怎么更新的:
// OrderEventHandler对象
@Override
public void onEvent(OrderEvent orderEvent, long l, boolean b) throws Exception {
Order order = (Order) orderEvent.getSource();
if (!order.getSymbol().equals(this.symbol)) { // 不处理非当前交易对的事件
return;
}
log.info("开始接收订单事件," + orderEvent);
// 撮合(匹配订单/吃单
MatchServiceFactory.getInstance(MatchStrategy.LIMIT_MATCH).match(orderBooks,(Order) orderEvent.getSource());
log.info("订单处理完毕...");
}
// MatchService的实现类
/**
* 吃单
*
* @param orderBooks 该交易对下的账本
* @param order 当前需要处理的订单
*/
@Override
public void match(OrderBooks orderBooks, Order order) {
// 这里省略一些订单匹配的操作
// 。。。。
// 更新盘口
TradePlate tradePlate = order.getOrderDirection() == OrderDirection.BUY.getCode() ?
orderBooks.getBuyTradePlate() : orderBooks.getSellTradePlate();
}
我们发现当新来一个订单时(用户下单请求),该订单会被其所在交易对的事件处理器OrderEventHandler所接收,然后该事件处理器对其进行撮合操作,撮合之后会对盘口数据造成变化,所以我们需要更新盘口数据。
以上便是该功能的大体流程,那问题在哪呢?我再进行测试的时候发现一个bug,就是当我在进行下单操作后,发现页面中的盘口数据并没有发生变化,观察浏览器控制台输出,发现从ws中订阅的消息也是没有变化(实际上是有的,只不过等个一两分钟,延迟太差了)。那这是为什么呢?经过调试发现,订单被创建后,很长一段时间并没有进入撮合操作,这是为什么?
说到这里,我们来回顾一下CPU的缓存一致性问题,现在的CPU一般是多核的,当你有一个线程A在核A运行,并且还有一个线程B在核B运行,此时线程A需要查询一个数据data,便将其放入到其CPU缓存中,然后线程B同时又要修改该数据data,此时核A中的缓存data必须失效并且同步到主内存中,这就导致了缓存一致性协议(MESI) 的额外开销。
那在我们的代码中,那里出现了这个问题。我们发现获取盘口数据的接口,他需要遍历OrderEventHandler进行获取盘口数据,然后OrderEventHandler又在撮合中修改了盘口数据,造成CPU缓存长期失效,并且获取盘口数据的接口定时任务执行的非常频繁,长期抢占CPU,导致撮合时间很短,从而导致虽然一直在获取盘口数据,但是的但却没有被正确的进行处理,盘口数据不变,过了很长一段时间,订单才撮合成功。
如何解决呢:
方向一:由于每次获取盘口数据都要遍历一次集合,所以我们将EventHandler不再使用数组存储,而是使用ConcurrentHashMap实现O(1)无锁高效查询。
方向二:我们在获取盘口数据的时候是直接获取的账本中的盘口,导致CPU缓存失效。因此我们将盘口数据保存在缓存中,撮合后写入缓存,获取盘口数据时直接查询缓存数据即可。
相关代码如下:
/**
* 盘口数据缓存管理类
*/
@Slf4j
@Component
public class DepthCacheManager {
// 创建缓存实例 - 多级缓存(本地内存 + Redis)
@CreateCache(name = "market.depth.", expire = 2, timeUnit = TimeUnit.SECONDS,
localLimit = 100, localExpire = 2,
cacheType = CacheType.BOTH) // 本地 + 远程两级缓存
private Cache<String, DepthSnapshot> depthCache;
// 符号到事件处理器的快速映射
private final ConcurrentHashMap<String, OrderEventHandler> handlerMap = new ConcurrentHashMap<>();
/**
* 撮合线程调用:更新缓存
*/
public void updateDepthSnapshot(String symbol, TradePlate buyPlate, TradePlate sellPlate) {
DepthSnapshot snapshot = new DepthSnapshot(
copyDepthItems(buyPlate.getItems()), // 创建快照,避免后续修改
copyDepthItems(sellPlate.getItems()),
System.currentTimeMillis()
);
// JetCache 自动处理多级缓存
depthCache.put(symbol, snapshot);
}
/**
* 接口线程调用:查询缓存
*/
public Map<String, List<DepthItemVo>> getSymbolDepth(String symbol) {
try {
DepthSnapshot snapshot = depthCache.get(symbol);
if (snapshot != null) {
Map<String, List<DepthItemVo>> result = new HashMap<>(2);
result.put("bids", snapshot.getBids());
result.put("asks", snapshot.getAsks());
return result;
}
}catch (Exception e) {
// 降级处理
log.warn("Get depth cache failed: {}", e.getMessage());
}
return Collections.emptyMap();
}
/**
* 注册事件处理器
*/
public void registerHandler(OrderEventHandler handler) {
handlerMap.put(handler.getSymbol(), handler);
}
/**
* 获取事件处理器(如果需要直接访问)
*/
public OrderEventHandler getHandler(String symbol) {
return handlerMap.get(symbol);
}
/**
* 深度拷贝,防止返回源对象引用,其他线程修改导致值变化
*/
private List<DepthItemVo> copyDepthItems(List<DepthItemVo> original) {
if (original == null) {
return Collections.emptyList();
}
return original.stream()
.map(this::copyDepthItem)
.collect(Collectors.toList());
}
/**
* 拷贝单个DepthItemVo
*/
private DepthItemVo copyDepthItem(DepthItemVo original) {
if (original == null) {
return null;
}
return DepthItemVo.builder()
.price(original.getPrice())
.volume(original.getVolume())
.build();
}
}
以上还有很多处细节我会在之后继续讨论, 如果有误敬请纠正

4181

被折叠的 条评论
为什么被折叠?



