股票行情类应用对实时性要求很高,但用户的网络环境并不总是可靠。地铁里、电梯中、信号弱的角落,一旦断网,行情页面就会变成一片空白,用户只能干等着。为了解决这个问题,业界普遍的做法是在本地建立一层离线缓存,把最近一次拉取到的行情数据落盘保存,断网时直接从本地读取展示,并在界面上提示数据时间戳。SQLite作为移动端和桌面端最流行的嵌入式关系型数据库,凭借零配置、单文件、支持事务和索引的特性,成为实现行情离线缓存的首选方案。本文将从表结构设计、读写优化、缓存更新与清理三个层面,完整讲解整套实现思路。

一、行情缓存表结构设计与建表实践
设计缓存表之前,先要明确股票行情数据的特点:每只股票一个交易日内会产生大量快照,每条快照包含价格、成交量、涨跌幅等字段。离线缓存的目标不是保存全部历史,而是保存每个股票代码最新的一条行情快照,以及可选的分时数据。推荐用股票代码作为主键实现"覆盖式"缓存,即同一只股票新行情到达时直接替换旧记录,表容量天然收敛。
下面是一个典型的建表语句,使用symbol作为主键,并用updated_at记录落库时间,方便后续做缓存过期判断:
CREATE TABLE IF NOT EXISTS quote_cache (
symbol TEXT PRIMARY KEY, -- 股票代码,如 600519.SH
name TEXT, -- 股票名称
price REAL, -- 最新价
open REAL, -- 今开
high REAL, -- 最高
low REAL, -- 最低
pre_close REAL, -- 昨收
volume REAL, -- 成交量
amount REAL, -- 成交额
updated_at INTEGER NOT NULL -- 数据落库时间戳(毫秒)
);
-- 分时数据表,按股票代码与时间联合主键
CREATE TABLE IF NOT EXISTS minute_line (
symbol TEXT NOT NULL,
ts INTEGER NOT NULL,
price REAL,
volume REAL,
PRIMARY KEY (symbol, ts)
);
如果把自选股列表也缓存下来,断网时就能完整还原用户的自选页。可以额外建一张watchlist表,结构与服务端返回的字段保持一致。这样即使完全离线,应用也能展示股票名称、最新价、涨跌幅等核心信息,只是标注“数据截至某时刻”即可。
二、批量写入与高效读取的代码实现
行情推送通常是一次性到达几十甚至几百只股票的数据,如果逐条执行INSERT,每条语句都会触发一次磁盘事务,性能会非常糟糕。正确做法是使用事务加预编译语句进行批量写入,并采用INSERT OR REPLACE语义实现覆盖更新。以Python为例:
import sqlite3, time
def save_quotes(conn: sqlite3.Connection, quotes: list[dict]):
"""批量覆盖写入行情缓存"""
sql = """INSERT OR REPLACE INTO quote_cache
(symbol, name, price, open, high, low, pre_close, volume, amount, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
now = int(time.time() * 1000)
rows = [(
q["symbol"], q["name"], q["price"], q["open"], q["high"], q["low"],
q["pre_close"], q["volume"], q["amount"], now
) for q in quotes]
with conn: # 使用事务,全部成功才提交
conn.executemany(sql, rows)
def load_quotes(conn: sqlite3.Connection, symbols: list[str]):
"""按代码列表批量读取缓存"""
placeholders = ",".join("?" * len(symbols))
sql = f"SELECT * FROM quote_cache WHERE symbol IN ({placeholders})"
return conn.execute(sql, symbols).fetchall()
读取时要注意SQLite的一个细节:IN查询的占位符数量上限默认是999(新版为32766),如果自选股数量极大,需要分批查询。另外建议在连接初始化时执行PRAGMA journal_mode=WAL开启写前日志模式,这样读写可以并发进行,界面线程读缓存时不会被后台写入阻塞,这对行情应用这种“边写边读”的场景尤为关键。
在Android等移动平台上,同样建议把写入操作放到后台线程,配合SQLiteStatement预编译复用,单次写入五百条行情数据耗时可以控制在几十毫秒以内,完全不会影响界面刷新。
三、缓存更新策略、过期判断与容量清理
离线缓存最大的价值在于断网兜底,但缓存数据本身有时效性问题。股票行情在收盘后基本不再变化,盘中则秒级刷新,因此不能简单地用固定时间判断过期。比较合理的策略是:结合交易时间判断缓存有效性。读取缓存时,先检查updated_at是否落在当前交易日内,如果是非交易时段且缓存是当日收盘后写入的,直接认为有效;如果是交易时段但缓存时间距当前超过阈值(比如30秒),则标记为“延迟数据”并在界面提示。
示例判断逻辑如下:
import time
def is_cache_fresh(updated_at: int, in_trading_hours: bool) -> bool:
"""判断行情缓存是否仍然可用"""
age = (time.time() * 1000 - updated_at) / 1000
if in_trading_hours:
return age < 30 # 盘中要求30秒内
return age < 12 * 3600 # 非交易时段放宽到12小时
容量控制方面,quote_cache表以股票代码为主键天然有界,一般几万条以内完全无压力。真正会膨胀的是分时线和历史K线这类时序数据,需要定期清理。可以每天开盘前执行一次清理任务,删除N天之前的分时数据:
DELETE FROM minute_line WHERE ts < strftime('%s', 'now', '-7 days') * 1000;
清理后建议顺手执行VACUUM回收磁盘空间,或者改用auto_vacuum模式自动整理。如果应用还支持多市场切换,可以在symbol前加市场前缀统一管理。
四、方案对比与选型建议
除了SQLite,常见的本地缓存方案还有纯文件缓存(JSON直接写文件)和内存缓存(如Redis、进程内字典)。文件缓存实现最简单,但几十只股票的行情一起更新时,整文件重写的开销明显,而且无法按股票代码精准查询。内存缓存速度最快,但进程退出数据就丢失,起不到离线兜底作用。SQLite恰好处于两者之间:支持按主键毫秒级查询、支持事务保证写入原子性、单文件便于备份迁移。对于行情这种结构化、高频更新、需要按需检索的数据,SQLite是综合性价比最高的选择。
总结一下实现要点:主键覆盖式更新控制缓存规模、事务批量写入保证性能、WAL模式支持读写并发、结合交易时段判断缓存新鲜度、定期清理时序数据控制容量。按照这套方案落地,即使完全断网,股票应用也能给用户展示最近一次的完整行情,网络恢复后再无缝切换回实时数据,体验上的提升立竿见影。