4、实时数据存储:Redis有序集合设计、增量更新策略、快照与增量合并
好,咱们接着聊订单簿的实时存储。前面几章我们把数据抓下来、清洗干净、还做了可视化,但有个问题一直悬着——这些数据到底该怎么存?
你想想看,订单簿数据每秒都在变,价格、数量、买卖方向……如果每次全量存一份,那硬盘很快就爆了。我早期做量化系统时就踩过这个坑,存了三天数据,磁盘直接报警。后来才明白,实时数据存储的核心思路就四个字:增量为王。
4.1 为什么选Redis有序集合?
其实市面上能用的内存数据库不少,但我个人习惯用Redis的有序集合(Sorted Set)来存订单簿。原因很简单——订单簿本身就是个天然的有序结构。
你看,买单按价格从高到低排,卖单按价格从低到高排。Redis的ZSet正好支持按score排序,我们把价格当score,数量当value,完美契合。
核心设计思路:
- 每个交易对建两个ZSet:
orderbook:btcusdt:bids和orderbook:btcusdt:asks - 价格作为score(浮点数),数量作为member(字符串)
- 使用ZADD命令增量更新,ZRANGEBYSCORE做范围查询
举个例子,一条买单数据进来:价格50000,数量0.5。我们直接执行:
ZADD orderbook:btcusdt:bids 50000 "0.5"
如果这个价格已经有数据了,ZADD会自动覆盖。说白了,这就是个天然的去重+排序结构。
4.2 增量更新策略
增量更新,说白了就是只传变化的部分,不传全量。我在项目中遇到过最头疼的事——某次交易所WebSocket断连重连,全量数据一下子涌进来,Redis CPU直接飙到100%。
所以增量更新策略必须设计好。我一般分三步走:
- 接收增量事件:交易所推送的增量数据,通常包含价格、数量、操作类型(新增/修改/删除)
- 判断操作类型:
- 数量为0 → 删除该价格档位(ZREM)
- 数量大于0 → 更新或新增(ZADD)
- 批量提交:用Pipeline合并多个操作,减少网络往返
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
def apply_incremental(symbol, side, price, quantity):
key = f"orderbook:{symbol}:{side}"
pipe = r.pipeline()
if quantity == 0:
# 删除该价格档位
pipe.zrem(key, str(price))
else:
# 更新或新增
pipe.zadd(key, {str(price): float(quantity)})
pipe.execute()
小技巧:我习惯把价格转成字符串存member,因为浮点数作为member容易有精度问题。score用浮点数没问题,Redis内部对score的精度处理很成熟。
4.3 快照与增量合并
增量更新跑久了,数据会越来越散。万一Redis重启了怎么办?总不能从头开始攒增量吧?
这时候就需要快照机制了。我常用的策略是:定期全量快照 + 持续增量日志。
具体做法是这样的:
- 每5分钟生成一次全量快照,存到另一个key里,比如
snapshot:btcusdt:20250101_120000 - 快照生成后,清空增量日志(或者标记一个检查点)
- 恢复时,先加载最近一次快照,再重放检查点之后的增量
嗯,这里要注意——快照生成期间,增量数据不能丢。我一般用双缓冲思路:
- 快照开始时,把增量写入一个临时缓冲区
- 快照完成后,把缓冲区里的增量合并进去
- 切换缓冲区,继续接收新增量
代码实现大概长这样:
def take_snapshot(symbol):
# 1. 暂停增量写入,切换到缓冲区
buffer_key = f"orderbook:{symbol}:buffer"
main_key = f"orderbook:{symbol}:main"
# 2. 读取当前全量数据
bids = r.zrange(f"orderbook:{symbol}:bids", 0, -1, withscores=True)
asks = r.zrange(f"orderbook:{symbol}:asks", 0, -1, withscores=True)
# 3. 写入快照
snapshot_key = f"snapshot:{symbol}:{int(time.time())}"
pipe = r.pipeline()
for member, score in bids:
pipe.zadd(f"{snapshot_key}:bids", {member: score})
for member, score in asks:
pipe.zadd(f"{snapshot_key}:asks", {member: score})
pipe.execute()
# 4. 合并缓冲区增量
merge_buffer(symbol, snapshot_key)
我曾经踩过的坑:快照和增量合并时,如果时间戳没对齐,会出现数据重复或丢失。我的解决方案是——每个增量事件都带一个全局递增的序列号,快照记录最后一个序列号,恢复时只重放大于该序列号的增量。
4.4 知识体系总览
说了这么多,咱们用一张图把整个逻辑串起来。我画了个流程图,你看一眼就明白了:
这张图把整个流程串起来了。数据从交易所进来,经过增量处理器判断类型,然后兵分三路——实时数据进ZSet、增量日志留底、定时快照做备份。最后恢复时,快照和增量一合并,完整数据就出来了。
4.5 实际项目中的取舍
说实话,上面这套方案在数据量不大的时候完全够用。但如果你要处理上百个交易对、每秒几万条更新,那还得做些优化。
我个人的经验是:
- 控制ZSet大小:订单簿深度一般只需要前100档,超出部分可以丢弃。用ZREMRANGEBYRANK定期清理
- 快照压缩:快照数据用gzip压缩后再存,能省70%的空间
- 异步写磁盘:Redis的AOF和RDB结合用,AOF做增量持久化,RDB做全量快照
避坑指南:我曾经在生产环境遇到过一个问题——增量日志越积越多,恢复时重放了半小时。后来加了快照频率自动调整逻辑:增量日志超过1万条就自动触发快照。效果立竿见影。
好了,关于Redis有序集合设计、增量更新和快照合并,核心内容就这些。你想想看,这套方案其实不复杂,但每一步都有坑。多动手试试,遇到问题再回来翻翻这部分,应该能帮你省不少时间。