1. 金融数据服务从零搭建的完整思路
1.1 为什么我要自己动手做一套金融数据服务
先说清楚这个项目到底在干什么。financial-services这个名字听起来很泛,但落到实际开发场景里,它通常指的是一套面向金融业务的数据服务层——把行情数据、账户数据、交易记录、风控指标这些东西,通过统一的接口暴露给上层应用。你可以把它理解成一个“金融数据中转站”:上游对接各种数据源,中间做清洗、计算、缓存,下游给 Web 端、移动端、量化策略程序提供稳定的数据接口。
我做这套东西的起因很直接。之前团队里每个业务线都自己拉数据、自己算指标,同一个“账户可用余额”在不同页面能算出三个不同的值,排查起来极其痛苦。后来我们决定收口,把所有金融相关的数据访问都走一层统一的服务。这个决定带来的收益远超预期:数据口径统一了,缓存命中率上去了,上游数据源换了只需要改一个地方。
这套服务适合谁参考?如果你正在做以下任意一件事,这篇内容都对你有用:需要对接多个行情或账户数据源的后端开发;想给自己的量化策略搭一套稳定的数据获取层;在金融科技公司负责数据中台建设;甚至你只是想理解“一个生产级的金融数据服务应该长什么样”。我不假设你有金融背景,但假设你懂基本的后端开发和数据库操作。
1.2 整体架构选型与背后的取舍逻辑
架构这件事,没有最好的,只有最合适的。我最终落地的方案是分层架构 + 异步刷新 + 多级缓存,下面把每一层的选型理由讲透。
第一层是数据接入层。金融数据源有个特点:格式乱、频率高、偶尔抽风。行情数据可能是 WebSocket 推过来的,账户数据可能是定时轮询 REST 接口拿的,还有一些历史数据只能批量导入。我的做法是为每种数据源写一个独立的 Adapter,统一转换成内部标准格式后再往上传。这样做的好处是,上层完全不需要关心数据从哪来、什么协议,换数据源就像换一个插件。
第二层是计算与聚合层。很多金融指标不是直接拿来的,而是算出来的。比如“当日盈亏”需要当前市值减去成本,“年化收益率”需要根据持有天数折算。这些计算逻辑如果散落在各个业务代码里,迟早出问题。我把它们集中在这一层,每个指标一个纯函数,输入原始数据、输出计算结果,方便单测也方便复用。
第三层是缓存层。金融数据读多写少,而且对延迟敏感。我用了两级缓存:本地内存缓存扛住高频读取,Redis 做分布式共享缓存。为什么不全用 Redis?因为本地缓存没有网络开销,对于“最新价格”这种每秒被读几千次的数据,本地缓存能把 P99 延迟压到亚毫秒级。
第四层是接口层。对外提供 RESTful 接口和 WebSocket 推送两种形式。REST 用于查询类操作,WebSocket 用于实时推送。接口层还负责鉴权、限流、参数校验这些横切关注点。
这里有个关键取舍:我一开始想用微服务拆分,后来放弃了。原因是团队规模不大,微服务带来的运维复杂度远大于收益。最终是一个模块化单体,各层之间通过清晰的接口隔离,将来真要拆也不难。
2. 核心模块的细节拆解与实操要点
2.1 数据模型设计:字段命名和精度处理的血泪教训
金融数据对精度的要求是变态级的。我踩过的最大一个坑:用浮点数存金额,结果在对账时发现差了 0.01 元,排查了整整两天。后来全部改成用整数存“分”,或者用 Decimal 类型,问题才消失。
数据模型设计上,我遵循几条铁律。第一,金额一律用最小货币单位的整数存储,比如人民币存“分”,美元存“美分”。展示的时候再除以 100。第二,时间戳统一用 UTC 毫秒数,展示层再转本地时区。第三,所有涉及计算的字段必须标注精度和舍入规则,比如收益率保留 6 位小数、四舍五入。
下面是一个典型的账户数据模型示例:
from decimal import Decimal from dataclasses import dataclass from typing import Optional @dataclass class AccountSnapshot: account_id: str # 金额字段全部用整数存"分" total_asset_cents: int available_cents: int frozen_cents: int # 市值类字段用 Decimal 保证精度 market_value: Decimal # 时间戳统一 UTC 毫秒 snapshot_ts: int currency: str = "CNY" @property def total_asset(self) -> Decimal: return Decimal(self.total_asset_cents) / 100字段命名上,我建议用完整的英文单词,不要缩写。available_cents比avail_c好一万倍,三个月后你还能看懂。另外,每个字段都要有明确的业务含义注释,金融领域的字段歧义特别多,“余额”到底是可用余额还是总余额,不写清楚就是埋雷。
2.2 缓存策略:什么时候该缓存,什么时候不该缓存
缓存用得好是银弹,用不好是毒药。金融数据里,我把数据分成三类来区别对待。
第一类是准实时数据,比如最新行情价格。这类数据变化快,但读取频率极高。我的策略是本地缓存 200 毫秒,Redis 缓存 1 秒。200 毫秒的延迟对绝大多数场景可接受,但能把数据库压力降低几个数量级。
第二类是低频变化数据,比如账户基本信息、产品配置。这类数据可能几小时才变一次,缓存时间可以设到 5 到 10 分钟,并且用主动失效来保证一致性——数据变更时主动删缓存,而不是等过期。
第三类是计算结果数据,比如当日盈亏、持仓汇总。这类数据计算成本高,但依赖的原始数据变化频繁。我的做法是异步刷新:后台定时任务每隔几秒重算一次,结果写入缓存,接口只读缓存。这样接口响应极快,代价是数据有几秒的延迟。
import time from functools import wraps def local_cache(ttl_ms: int): """本地内存缓存装饰器,ttl 单位毫秒""" def decorator(func): cache = {} @wraps(func) def wrapper(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) now = time.time() * 1000 if key in cache: value, expire_at = cache[key] if now < expire_at: return value result = func(*args, **kwargs) cache[key] = (result, now + ttl_ms) return result return wrapper return decorator注意:本地缓存有个坑,多实例部署时各实例的缓存不一致。对于强一致要求的场景,本地缓存 TTL 要设得很短,或者干脆只用 Redis。我一般把本地缓存 TTL 控制在 500 毫秒以内,这样即使不一致,窗口期也极短。
2.3 数据源接入的容错设计
金融数据源的不靠谱程度,做过的都懂。接口超时、返回格式突变、限流封禁,这些都是家常便饭。我的接入层做了三层防护。
第一层是超时与重试。每个外部调用都设超时,一般 3 秒。超时后重试,但重试次数不超过 2 次,且用指数退避。为什么不多重试?因为金融数据时效性强,重试太多次拿到的是过期数据,还不如快速失败。
第二层是熔断。如果某个数据源连续失败超过阈值,直接熔断一段时间,期间所有请求快速失败,避免拖垮整个服务。我用的是一个简单的滑动窗口计数器,10 秒内失败超过 50% 就熔断 30 秒。
第三层是降级。数据源挂了怎么办?如果有缓存,返回缓存数据并标记为“可能过期”。如果没有缓存,返回明确的错误码,让上层决定怎么处理。绝对不能返回假数据,这在金融场景里是致命的。
class CircuitBreaker: def __init__(self, failure_threshold=0.5, window_sec=10, cooldown_sec=30): self.failure_threshold = failure_threshold self.window_sec = window_sec self.cooldown_sec = cooldown_sec self.records = [] # (timestamp, success) self.open_until = 0 def allow_request(self) -> bool: now = time.time() if now < self.open_until: return False # 清理过期记录 self.records = [r for r in self.records if now - r[0] < self.window_sec] return True def record(self, success: bool): now = time.time() self.records.append((now, success)) if len(self.records) >= 10: fail_rate = sum(1 for _, s in self.records if not s) / len(self.records) if fail_rate > self.failure_threshold: self.open_until = now + self.cooldown_sec3. 完整实操流程与关键环节实现
3.1 环境准备与依赖安装
先把基础环境搭起来。我用的技术栈是 Python 3.11 + FastAPI + Redis + PostgreSQL。选 FastAPI 是因为它异步性能好、自带 OpenAPI 文档,对金融数据这种 IO 密集场景很合适。数据库选 PostgreSQL 是因为它对 Decimal 类型和事务的支持非常成熟。
# 创建虚拟环境 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装核心依赖 pip install fastapi uvicorn[standard] redis asyncpg pip install sqlalchemy alembic pydantic-settings pip install httpx websockets # 用于数据源接入Redis 和 PostgreSQL 建议用 Docker 起,省得污染本机环境:
docker run -d --name fin-redis -p 6379:6379 redis:7-alpine docker run -d --name fin-pg -p 5432:5432 \ -e POSTGRES_PASSWORD=yourpassword \ -e POSTGRES_DB=financial \ postgres:16-alpine提示:生产环境一定要给 Redis 设密码,并且不要暴露到公网。金融数据服务的数据库连接串要走环境变量,绝对不能硬编码在代码里。
3.2 数据接入层的实现
数据接入层的核心是 Adapter 模式。我定义一个抽象基类,所有数据源适配器都实现它:
from abc import ABC, abstractmethod from typing import List class MarketDataAdapter(ABC): @abstractmethod async def fetch_quote(self, symbol: str) -> dict: """获取单个标的的最新行情""" ... @abstractmethod async def fetch_batch(self, symbols: List[str]) -> dict: """批量获取行情""" ...然后针对具体数据源实现。比如对接一个 REST 行情接口:
import httpx class RestQuoteAdapter(MarketDataAdapter): def __init__(self, base_url: str, api_key: str, breaker: CircuitBreaker): self.base_url = base_url self.api_key = api_key self.breaker = breaker self.client = httpx.AsyncClient(timeout=3.0) async def fetch_quote(self, symbol: str) -> dict: if not self.breaker.allow_request(): raise ServiceUnavailable("数据源熔断中") try: resp = await self.client.get( f"{self.base_url}/quote", params={"symbol": symbol}, headers={"X-API-Key": self.api_key}, ) resp.raise_for_status() self.breaker.record(True) return self._normalize(resp.json()) except Exception as e: self.breaker.record(False) raise def _normalize(self, raw: dict) -> dict: """把数据源格式转成内部标准格式""" return { "symbol": raw["code"], "price": Decimal(str(raw["last"])), "ts": int(raw["timestamp"]), }这里的关键点是_normalize方法。不同数据源返回的字段名千奇百怪,有的叫last,有的叫price,有的叫close。统一在这里转换,上层就永远看到一致的格式。
3.3 计算层的指标实现
计算层我坚持一个原则:每个指标一个纯函数,不依赖任何外部状态。这样测试起来极其简单,给输入、验输出就行。
以“持仓盈亏”为例,计算逻辑是当前市值减去持仓成本:
from decimal import Decimal, ROUND_HALF_UP def calc_position_pnl( quantity: int, cost_price: Decimal, current_price: Decimal, ) -> dict: """ 计算持仓盈亏 quantity: 持仓数量(股) cost_price: 成本价 current_price: 当前价 """ cost = cost_price * quantity market_value = current_price * quantity pnl = market_value - cost # 盈亏比例,保留 4 位小数 pnl_ratio = (pnl / cost).quantize( Decimal("0.0001"), rounding=ROUND_HALF_UP ) if cost != 0 else Decimal("0") return { "cost": cost, "market_value": market_value, "pnl": pnl, "pnl_ratio": pnl_ratio, }注意cost != 0的判断。我见过因为成本为零导致除零崩溃的线上事故,这种边界条件在金融计算里必须处理。另外quantize的舍入规则要明确,金融场景一般用ROUND_HALF_UP,但具体要看业务约定,不能想当然。
3.4 接口层的实现与限流
接口层用 FastAPI 实现,核心是查询接口和推送接口。查询接口示例:
from fastapi import FastAPI, HTTPException, Depends from pydantic import BaseModel app = FastAPI(title="Financial Data Service") class QuoteResponse(BaseModel): symbol: str price: Decimal ts: int @app.get("/api/v1/quote/{symbol}", response_model=QuoteResponse) async def get_quote(symbol: str): # 先查本地缓存 cached = local_quote_cache.get(symbol) if cached: return cached # 再查 Redis cached = await redis_client.get(f"quote:{symbol}") if cached: return QuoteResponse.parse_raw(cached) # 最后走数据源 try: quote = await adapter.fetch_quote(symbol) except ServiceUnavailable: raise HTTPException(status_code=503, detail="数据源暂不可用") await redis_client.setex(f"quote:{symbol}", 1, quote.json()) return quote限流我用的是令牌桶算法,基于 Redis 实现,保证多实例部署下限流准确:
async def check_rate_limit(user_id: str, limit: int = 100, window: int = 60): """滑动窗口限流,每分钟 limit 次""" key = f"ratelimit:{user_id}" now = time.time() pipe = redis_client.pipeline() pipe.zremrangebyscore(key, 0, now - window) pipe.zadd(key, {str(now): now}) pipe.zcard(key) pipe.expire(key, window) _, _, count, _ = await pipe.execute() if count > limit: raise HTTPException(status_code=429, detail="请求过于频繁")注意:限流的 key 一定要带用户维度,否则一个用户刷爆了会影响所有人。另外窗口大小和限制次数要根据实际业务调,行情查询可以放宽,交易类操作要收紧。
4. 常见问题排查与避坑经验实录
4.1 数据不一致问题的排查思路
数据不一致是金融数据服务最常见也最头疼的问题。我的排查套路是“三步定位法”。
第一步,确认不一致的范围。是个别账户还是全部?是特定时间段还是持续存在?范围能帮你快速缩小嫌疑模块。如果只有个别账户出问题,大概率是数据本身的问题;如果全部账户都错,那基本是计算逻辑或缓存的问题。
第二步,对比原始数据和计算结果。把数据源返回的原始数据、缓存里的数据、接口返回的数据三者拉出来对比。我遇到过一次缓存里存的是旧数据,原因是主动失效的代码在某个分支里没执行到。这种问题只能靠对比发现。
第三步,检查时间窗口。金融数据很多是带时间戳的,要确认各个环节用的时间基准是否一致。我踩过一个坑:数据源返回的是交易所时间,我本地用的是服务器时间,两者差了几秒,导致“最新价格”判断出错。后来统一用数据源的时间戳,问题解决。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 个别账户数据错误 | 数据源本身问题 | 对比原始数据 |
| 全部账户数据错误 | 计算逻辑或缓存问题 | 检查计算函数和缓存失效 |
| 数据偶尔不一致 | 并发写入或缓存竞态 | 检查锁和缓存更新顺序 |
| 数据延迟大 | 缓存 TTL 过长或刷新慢 | 检查缓存配置和刷新任务 |
4.2 性能瓶颈的定位与优化
金融数据服务的性能瓶颈通常出现在三个地方:数据库查询、外部数据源调用、序列化开销。
数据库查询慢,先看有没有加索引。金融数据查询经常是“按账户 + 时间范围”,这种组合索引一定要建。另外,避免在循环里查数据库,能批量就批量。我优化过一个接口,把循环查询改成一次批量查询,响应时间从 800 毫秒降到 30 毫秒。
外部数据源调用慢,主要是网络问题。我的做法是并发调用,用asyncio.gather同时拉多个数据源,而不是串行等待。但要注意控制并发数,别把数据源打挂了。
序列化开销容易被忽视。JSON 序列化在数据量大时很耗时,我试过用orjson替换标准库的json,性能提升明显。如果对性能要求极高,可以考虑用 Protobuf 或 MessagePack。
import asyncio import orjson async def fetch_multiple(symbols: list[str]) -> dict: """并发拉取多个标的行情""" tasks = [adapter.fetch_quote(s) for s in symbols] results = await asyncio.gather(*tasks, return_exceptions=True) output = {} for symbol, result in zip(symbols, results): if isinstance(result, Exception): # 单个失败不影响整体 output[symbol] = None else: output[symbol] = result return output4.3 上线前的检查清单
这套服务上线前,我整理了一份检查清单,每次发版都过一遍,能挡掉大部分低级问题。
- 金额字段是否全部用整数或 Decimal,有没有残留的 float
- 所有外部调用是否都有超时和熔断
- 缓存是否都有 TTL,有没有永不过期的 key
- 限流是否覆盖所有对外接口
- 敏感配置是否都走环境变量,代码里有没有硬编码的密钥
- 日志是否包含足够的排查信息,但又不能打印敏感数据
- 数据库连接池大小是否合理,有没有连接泄漏风险
- 是否有监控告警,关键指标(延迟、错误率、缓存命中率)是否可观测
提示:监控这块我强烈建议至少接入延迟和错误率两个指标。金融数据服务出问题时,第一时间能看到是哪个环节慢了、哪个接口错了,排查效率天差地别。
4.4 几个我踩过的具体坑
第一个坑:时区问题。有次对账发现某天的数据少了几笔,查了半天发现是跨时区的时间转换出了问题。后来所有时间戳统一用 UTC 存储,只在展示层转换,再没出过问题。
第二个坑:浮点数精度。前面提过,用 float 存金额导致对账差 0.01 元。这个坑的教训是,金融领域任何涉及金额的计算,都要用 Decimal 或整数,并且明确舍入规则。
第三个坑:缓存雪崩。有次 Redis 重启,所有缓存同时失效,请求全部打到数据库,直接把数据库打挂了。后来给缓存 TTL 加了随机抖动,避免同时过期,并且加了本地缓存做兜底。
第四个坑:并发更新。两个请求同时更新同一个账户的数据,后写的覆盖了先写的。解决办法是用乐观锁或分布式锁,保证更新的原子性。金融数据的并发更新一定要加锁,不能想当然。
这套financial-services从最初的想法到稳定运行,前后迭代了大概半年。最大的体会是,金融数据服务的难点不在技术本身,而在对业务细节的把握和对边界情况的处理。技术方案可以抄,但对数据的敬畏心和对一致性的坚持,得自己慢慢磨。