金融数据服务从零搭建:架构设计、核心模块与性能优化实战
发布时间:2026/9/28 17:59:09来源:尧图网络
1. 金融数据服务从零搭建的完整思路1.1 这个项目到底在做什么“financial-services”这个名字听起来很宽泛但落到实际工程里它通常指的是一套面向金融业务场景的数据服务层——把行情、账户、交易、风控这些模块的数据统一收口对上提供标准化的 API对下屏蔽不同数据源的差异。你可以把它理解成金融系统里的“数据总线”左边连着各种数据库、第三方行情源、内部核心系统右边连着前端页面、策略引擎、报表工具。我之所以挑这个方向来聊是因为大部分团队在初期都会犯同一个错误每个业务线各自直连数据库行情组查自己的表交易组查自己的表等到要做统一对账或者跨模块风控的时候发现数据口径完全对不上。financial-services 这类服务层的核心价值就是把“数据怎么取、取出来长什么样、谁能取”这三件事标准化。它适合谁参考如果你是后端工程师正在做金融类系统的服务化改造这篇可以直接抄架构如果你是数据工程师需要把散落的金融数据整合成统一接口这里的选型和踩坑记录能帮你省掉至少两周的试错时间哪怕你是刚入行的开发者想了解一个真实金融项目怎么从零搭起来下面的内容也能给你一个完整的全景图。1.2 为什么选择服务层而不是直接查库先说结论直接查库在业务初期确实快但它的隐性成本会在三个月后集中爆发。我经历过一个项目最开始六个业务模块各自连同一个 MySQL 实例后来要做实时风控需要同时读取账户余额、当日交易流水、持仓市值三个维度的数据结果发现三个模块对“当日”的定义都不一样——有的按自然日有的按交易日有的按结算日。这种口径不一致在直接查库的模式下几乎无法统一治理。服务层的做法是在中间加一层抽象所有数据请求必须经过统一的入口入口层负责鉴权、限流、缓存、口径转换。这样做的好处有三个。第一数据口径收敛到一个地方维护改一次全生效。第二底层数据源可以随时替换比如从 MySQL 换到 TiDB上层业务无感知。第三安全边界清晰数据库不再对业务方直接暴露所有访问都有审计日志。代价也很明显多了一层网络跳转延迟会增加。实测下来同机房内一次服务层调用的额外开销在 2 到 5 毫秒之间对于大部分金融业务来说完全可以接受但如果是高频交易场景这个延迟就需要单独优化比如把服务层部署到和交易引擎同一台物理机上走本地回环。1.3 整体架构的分层设计我把 financial-services 拆成四层从下往上依次是数据源层、数据接入层、服务层、接入层。数据源层就是各种存储和外部接口包括关系型数据库、时序数据库、Redis 缓存、第三方行情推送。数据接入层负责把不同来源的数据转换成内部统一格式这一层的关键是“适配器模式”——每种数据源对应一个适配器新增数据源只需要加一个适配器不动其他代码。服务层是核心里面按业务域拆成账户服务、行情服务、交易服务、风控服务四个模块。每个模块独立部署通过内部 RPC 互相调用。这样做的好处是故障隔离行情服务挂了不影响交易服务。接入层对客户端提供 HTTP 和 WebSocket 两种协议HTTP 用于请求-响应式的查询WebSocket 用于行情推送这类需要服务端主动推数据的场景。分层的关键原则是“单向依赖”上层可以调下层下层绝对不能反向调上层。我见过有团队为了图方便在数据接入层里直接调服务层的工具类结果后来想换掉服务层框架的时候发现根本动不了。这个坑一定要避开。2. 核心模块拆解与技术选型2.1 账户服务的设计要点账户服务管的是用户资金相关的数据包括余额、可用资金、冻结资金、出入金记录。这个模块最核心的要求是“强一致”一分钱都不能错。技术选型上数据库必须支持事务MySQL 的 InnoDB 或者 PostgreSQL 都可以但一定要用悲观锁或者乐观锁来保证并发安全。具体做法是在账户表上加一个 version 字段做乐观锁。每次更新余额的时候先读当前 version更新时带上WHERE version ?条件如果影响行数为零说明有并发冲突重试即可。这个方案在并发量不高的场景下够用但如果同一账户的并发写非常频繁乐观锁的重试率会飙升这时候就得换成悲观锁用SELECT ... FOR UPDATE锁住行再更新。注意用悲观锁的时候一定要控制事务粒度锁住行之后尽快提交不要在事务里做 RPC 调用或者复杂计算否则会拖长锁持有时间严重时导致连接池耗尽。账户服务还需要记录每一笔资金变动的流水这个流水表是只增不改的。我建议流水表按月份分表比如account_flow_202601、account_flow_202602这样单表数据量可控查询历史流水的时候也方便。分表的键就用账户 ID 加时间戳查询时先定位月份再查具体表。2.2 行情服务的接入与分发行情服务的特点是“写少读多、实时性要求高”。数据从第三方行情源推过来经过清洗后分发给大量客户端。这里的核心矛盾是行情源推送频率可能达到每秒几千条而每个客户端只关心自己订阅的那几只标的。我的做法是在服务层和客户端之间加一个“订阅路由”模块。行情源推来的数据先写入一个内存队列路由模块根据客户端的订阅关系把数据推给对应的 WebSocket 连接。订阅关系存在 Redis 里结构是subscription:{symbol} - [client_id1, client_id2, ...]路由模块每次从队列取数据时用 symbol 去 Redis 查订阅列表然后逐个推送。这个方案在订阅量不大的时候没问题但订阅关系复杂之后 Redis 查询会成为瓶颈。优化手段是在路由模块本地维护一份订阅关系的缓存Redis 只作为持久化和跨节点同步用。本地缓存用ConcurrentHashMap存定时从 Redis 刷新这样大部分查询都走内存速度极快。行情数据的格式我建议用 Protobuf 而不是 JSON。实测下来同样一条行情数据Protobuf 序列化后的大小只有 JSON 的三分之一左右序列化耗时也少一半。对于高频推送场景这个差距非常可观。客户端解析 Protobuf 需要引入对应的库但一次性的接入成本换来长期的带宽和性能收益很划算。2.3 交易服务的幂等与对账交易服务是整个系统里最复杂的模块因为它涉及资金和持仓的变动任何重复提交或者漏单都会造成严重后果。幂等设计是重中之重。我的做法是给每一笔交易请求分配一个全局唯一的request_id服务端收到请求后先查request_id是否已经处理过如果处理过就直接返回上次的结果不再重复执行。request_id的生成规则是“客户端 ID 时间戳 序列号”客户端保证同一笔交易重试时使用相同的request_id。服务端用一个独立的表来记录已处理的request_id这个表只需要保留最近 24 小时的数据过期清理即可。对账是另一个关键环节。每天收盘后交易服务需要和上游核心系统对账核对当日的成交记录、资金变动、持仓变化是否一致。对账的逻辑是从两个系统分别拉取当日的交易流水按request_id做全外连接找出只有一边有的记录以及两边都有但关键字段不一致的记录。这些差异记录会进入人工处理队列。实操心得对账的时间窗口不要设得太死我建议从收盘后半小时开始给上游系统留出足够的结算时间。另外对账任务要支持手动触发有时候上游数据延迟了自动对账跑出来全是差异手动重跑一次就正常了。2.4 风控服务的规则引擎风控服务需要实时判断每一笔交易是否触发风控规则比如单笔金额超限、当日累计交易次数超限、持仓集中度超限等。规则会经常变动所以不能硬编码在代码里要用规则引擎来管理。我选的是 Drools虽然它有点重但胜在成熟稳定社区资料多。规则文件用 DRL 格式编写每条规则定义触发条件和执行动作。比如“单笔金额超过 100 万”这条规则条件部分写$trade : Trade(amount 1000000)动作部分写$trade.setRiskLevel(HIGH)。规则引擎的性能是需要重点关注的。Drools 在规则数量少的时候很快但规则超过几百条之后每次事实插入和规则匹配的耗时会明显上升。优化手段是把规则分组不同类型的交易走不同的规则组避免每次匹配都遍历全部规则。另外可以用 Drools 的kie-base缓存机制把编译好的规则包缓存起来避免每次请求都重新编译。3. 实操搭建与关键环节实现3.1 环境准备与依赖安装先列一下我用的技术栈和版本你可以直接照抄JDK 17、Spring Boot 3.2、MySQL 8.0、Redis 7.2、RabbitMQ 3.12、Drools 8.44。操作系统用 Ubuntu 22.04 LTS这个版本长期支持社区资料也全。安装 JDK 用 apt 就行sudo apt update sudo apt install openjdk-17-jdk -y java -versionMySQL 和 Redis 建议用 Docker 跑省得配环境配半天docker run -d --name mysql-financial \ -e MYSQL_ROOT_PASSWORDyour_password \ -e MYSQL_DATABASEfinancial_services \ -p 3306:3306 \ mysql:8.0 docker run -d --name redis-financial \ -p 6379:6379 \ redis:7.2RabbitMQ 用来做异步消息比如交易完成后的通知、对账任务的触发。安装命令docker run -d --name rabbitmq-financial \ -p 5672:5672 -p 15672:15672 \ rabbitmq:3.12-management注意生产环境的密码不要用简单密码也不要写在代码里。用环境变量或者配置中心来管理比如 Spring Cloud Config 或者 Nacos。3.2 数据库表结构设计账户表的核心字段如下CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, balance DECIMAL(20, 8) NOT NULL DEFAULT 0, available DECIMAL(20, 8) NOT NULL DEFAULT 0, frozen DECIMAL(20, 8) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;金额字段用DECIMAL(20, 8)而不是FLOAT或DOUBLE因为浮点数有精度问题金融场景绝对不能接受。DECIMAL(20, 8)表示总共 20 位其中 8 位小数足够覆盖大部分金融场景。流水表按月份分表建表语句模板CREATE TABLE account_flow_202601 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, request_id VARCHAR(64) NOT NULL, change_amount DECIMAL(20, 8) NOT NULL, balance_after DECIMAL(20, 8) NOT NULL, flow_type VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id), KEY idx_account_time (account_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_request_id这个唯一索引很关键它保证了同一笔请求不会重复写入流水。即使应用层的幂等判断出了问题数据库层面还有一道防线。3.3 账户服务的核心代码实现账户服务的余额更新逻辑我用的是乐观锁加重试Service public class AccountService { private static final int MAX_RETRY 3; Autowired private AccountMapper accountMapper; public void updateBalance(Long accountId, BigDecimal amount, String requestId) { int retry 0; while (retry MAX_RETRY) { Account account accountMapper.selectById(accountId); BigDecimal newBalance account.getBalance().add(amount); if (newBalance.compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(余额不足); } int affected accountMapper.updateBalanceWithVersion( accountId, newBalance, account.getVersion()); if (affected 0) { return; } retry; } throw new BusinessException(并发冲突请重试); } }对应的 Mapper XMLupdate idupdateBalanceWithVersion UPDATE account SET balance #{newBalance}, version version 1 WHERE id #{accountId} AND version #{version} /update这段代码的逻辑是先读当前余额和版本号计算新余额然后用版本号做条件更新。如果更新影响行数为零说明在读取和更新之间有其他请求修改了这条记录版本号已经变了这时候重试即可。重试三次还失败就抛异常让上层处理。实操心得重试次数不要设太多三次足够了。如果三次都冲突说明这个账户的并发非常高继续重试只会浪费资源不如直接返回让客户端稍后再试。另外重试之间最好加一个短暂的随机延迟比如 10 到 50 毫秒避免多个请求同时重试再次冲突。3.4 行情推送的 WebSocket 实现WebSocket 服务端用 Spring 的WebSocketHandler来实现Component public class MarketDataWebSocketHandler extends TextWebSocketHandler { private final MapString, WebSocketSession sessions new ConcurrentHashMap(); private final MapString, SetString subscriptions new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { sessions.put(session.getId(), session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { String payload message.getPayload(); SubscribeRequest request JSON.parseObject(payload, SubscribeRequest.class); if (subscribe.equals(request.getAction())) { for (String symbol : request.getSymbols()) { subscriptions.computeIfAbsent(symbol, k - ConcurrentHashMap.newKeySet()) .add(session.getId()); } } } public void pushMarketData(String symbol, MarketData data) { SetString clientIds subscriptions.get(symbol); if (clientIds null || clientIds.isEmpty()) { return; } String json JSON.toJSONString(data); TextMessage message new TextMessage(json); for (String clientId : clientIds) { WebSocketSession session sessions.get(clientId); if (session ! null session.isOpen()) { try { session.sendMessage(message); } catch (IOException e) { sessions.remove(clientId); } } } } }这段代码里subscriptions存的是 symbol 到客户端 ID 集合的映射sessions存的是客户端 ID 到 WebSocket 会话的映射。推送时先查 symbol 对应的客户端列表再逐个发送。发送失败就移除会话避免无效连接占用资源。行情数据从 RabbitMQ 消费后调用pushMarketData方法RabbitListener(queues market.data.queue) public void onMarketData(MarketData data) { webSocketHandler.pushMarketData(data.getSymbol(), data); }注意WebSocket 的sendMessage方法不是线程安全的如果多个线程同时向同一个会话发送消息可能会抛IllegalStateException。解决办法是给每个会话加一个锁或者用ConcurrentWebSocketSessionDecorator包装一下。我一般用后者Spring 自带的很方便。3.5 风控规则的配置与热更新Drools 规则文件放在src/main/resources/rules/目录下比如risk-rules.drlpackage com.financial.risk import com.financial.risk.model.Trade; import com.financial.risk.model.RiskResult; rule 单笔金额超限 when $trade : Trade(amount 1000000) then $trade.setRiskLevel(HIGH); $trade.setRiskReason(单笔金额超过100万); end rule 当日累计交易次数超限 when $trade : Trade() $count : Number(intValue 100) from accumulate( Trade(userId $trade.userId, tradeDate $trade.tradeDate), count(1) ) then $trade.setRiskLevel(HIGH); $trade.setRiskReason(当日累计交易次数超过100次); end规则热更新的做法是监听规则文件的变化重新加载 KieContainer。我用的是 Spring 的Scheduled定时任务每 30 秒检查一次规则文件的最后修改时间如果有变化就重新编译加载Scheduled(fixedDelay 30000) public void reloadRules() { File ruleFile new File(rules/risk-rules.drl); long lastModified ruleFile.lastModified(); if (lastModified lastRuleLoadTime) { kieContainer.updateToVersion(kieContainer.getKieBase().getKiePackages()); lastRuleLoadTime lastModified; } }实操心得规则热更新在生产环境要谨慎最好加一个开关默认关闭需要的时候手动打开。因为规则写错了可能导致所有交易被拦截影响面太大。另外每次更新规则前先备份旧版本出问题可以快速回滚。4. 常见问题与排查技巧实录4.1 数据库连接池耗尽怎么排查连接池耗尽的典型表现是接口大面积超时日志里出现Connection is not available, request timed out。排查步骤分三步走。第一步看当前活跃连接数MySQL 里执行SHOW STATUS LIKE Threads_connected如果这个数字接近连接池上限说明确实耗尽了。第二步看是哪些 SQL 占着连接不放执行SHOW PROCESSLIST找出运行时间很长的查询。第三步检查代码里有没有忘记关闭连接的地方或者事务范围过大导致连接被长时间持有。我遇到过一次原因是账户服务里有个方法在事务中调用了外部 HTTP 接口那个接口响应特别慢导致事务一直不提交连接一直被占用。解决办法是把 HTTP 调用移到事务外面先查数据提交事务再调外部接口。避坑技巧连接池的maxWait参数不要设太大我一般设 3000 毫秒超过就快速失败避免请求堆积。另外leakDetectionThreshold设成 60000如果有连接超过 60 秒没归还日志里会打警告方便定位问题。4.2 行情推送延迟高的优化思路行情推送延迟高首先要定位延迟发生在哪个环节。从行情源到服务端、服务端内部处理、服务端到客户端这三个环节分别打时间戳对比一下就知道瓶颈在哪。如果是服务端内部处理慢常见原因是序列化耗时。JSON 序列化在数据量大时很慢换成 Protobuf 或者 FlatBuffers 能显著改善。另外检查一下推送逻辑里有没有同步的阻塞操作比如写数据库、调外部接口这些都应该异步化。如果是服务端到客户端的网络延迟那就要看客户端和服务端的网络质量了。同机房内延迟一般在 1 毫秒以内跨机房可能到 10 毫秒以上。这种情况可以考虑在客户端附近部署边缘节点行情数据先推到边缘节点再由边缘节点推给客户端。4.3 对账差异的常见原因对账差异的原因五花八门我整理了一个速查表差异类型常见原因排查方法一边有一边无消息丢失或重复消费检查 MQ 的确认机制和消费位点金额不一致精度处理不同对比两边的金额字段类型和舍入规则时间不一致时区或时间口径不同确认两边用的时区检查“当日”的定义状态不一致状态机流转不同步对比两边的状态变更日志最常见的是消息丢失导致的差异。RabbitMQ 默认是自动确认消费者收到消息就认为处理成功如果处理过程中抛异常消息就丢了。解决办法是改成手动确认处理成功后再basicAck处理失败就basicNack并重新入队。4.4 风控规则误拦截的处理风控规则误拦截是指正常交易被判定为高风险。这种情况通常是因为规则条件写得太宽泛或者数据口径有问题。比如“当日累计交易次数超过 100 次”这条规则如果tradeDate字段取的是自然日但业务上应该按交易日算那周末后的第一个交易日就会把周末的交易也算进去导致误拦截。处理办法是先在测试环境用历史数据回测规则看看拦截率是否在合理范围内。如果拦截率异常高就逐条规则排查条件。另外建议给每条规则加一个“灰度开关”新规则先只对部分用户生效观察一段时间没问题再全量放开。实操心得风控规则上线前一定要做回测用过去一个月的历史数据跑一遍看看会拦截多少笔交易。如果拦截率超过 1%就要仔细检查规则是否合理。我见过有团队新规则上线当天拦截了 30% 的交易就是因为没做回测。4.5 服务间调用的超时与重试financial-services 内部有多个服务互相调用超时和重试策略配不好会导致雪崩。我的经验是超时时间根据接口的 P99 耗时来设一般是 P99 的 1.5 倍。比如某个接口 P99 是 200 毫秒超时就设 300 毫秒。重试次数最多两次而且只对幂等接口重试非幂等接口绝对不能重试。重试还要加退避策略第一次重试等 100 毫秒第二次等 200 毫秒避免短时间内大量重试把下游打垮。Spring Retry 支持这些配置Retryable( value {RemoteException.class}, maxAttempts 3, backoff Backoff(delay 100, multiplier 2) ) public Result callRemoteService(Request request) { // ... }注意重试一定要配合熔断如果下游服务已经挂了重试只会让情况更糟。用 Resilience4j 或者 Sentinel 做熔断当失败率达到阈值时直接快速失败不再重试。5. 性能优化与扩展性考虑5.1 缓存策略的分层设计financial-services 的缓存分三层本地缓存、Redis 缓存、数据库。本地缓存用 Caffeine存的是变化频率低、访问频率高的数据比如用户基本信息、产品配置。Redis 缓存存的是跨节点共享的数据比如行情快照、订阅关系。数据库是最后一道防线所有数据最终都以数据库为准。缓存的更新策略我用的是“先更新数据库再删除缓存”而不是“先删除缓存再更新数据库”。因为后者在并发场景下容易出现数据不一致线程 A 删除缓存后还没更新数据库线程 B 读缓存发现没有就去数据库读到了旧数据并写回缓存然后线程 A 才更新数据库导致缓存里是旧数据。先更新数据库再删除缓存虽然也有小概率不一致但概率低得多。实操心得缓存一定要设过期时间即使你觉得数据不会变。我见过有团队缓存用户余额不设过期时间结果用户充值后缓存没更新用户看到余额没变就重复充值。过期时间根据业务容忍度来设余额这类数据建议 5 到 10 秒配置类数据可以设几分钟。5.2 数据库读写分离与分库分表当单库 QPS 超过 5000 或者单表数据量超过 1000 万行时就需要考虑读写分离和分库分表了。读写分离用 ShardingSphere 或者 MyCat 都可以配置主库写、从库读应用层不用改代码。分库分表要慎重因为它会带来分布式事务、跨库查询、全局 ID 等一系列问题。我的建议是能不拆就不拆先通过加索引、优化 SQL、加缓存来扛。实在扛不住了再拆而且优先考虑垂直拆分按业务域拆库其次才是水平拆分按数据行拆表。水平拆分的分片键选择很关键。账户表按user_id分片保证同一个用户的账户数据在同一个库这样账户相关的操作都是单库事务。流水表按时间分片因为流水查询基本都是按时间范围查按时间分片可以快速定位到具体表。5.3 异步化与消息队列的应用financial-services 里很多操作可以异步化比如交易完成后的通知、对账任务的触发、报表的生成。这些操作不需要实时返回结果放到消息队列里慢慢处理就行。RabbitMQ 的配置要点交换机用 topic 类型路由键用trade.completed、account.updated这种格式消费者按需绑定。消息的持久化要开启deliveryMode设为 2这样 RabbitMQ 重启消息也不会丢。消费者端用手动确认处理成功再 ack。注意消息队列不是万能的它只能保证最终一致性不能保证强一致。涉及资金的操作该用事务还得用事务不能全靠消息队列。5.4 监控与告警体系的搭建监控用 Prometheus 加 Grafana应用暴露/actuator/prometheus端点Prometheus 定时抓取。关键指标包括接口 QPS、P99 延迟、错误率、数据库连接池使用率、Redis 命中率、消息队列积压量。告警规则我设了这几条接口错误率超过 1% 持续 1 分钟告警P99 延迟超过 500 毫秒持续 5 分钟告警数据库连接池使用率超过 80% 告警消息队列积压超过 1000 条告警。告警渠道用钉钉或者企业微信的机器人直接推到群里。实操心得告警阈值不要设得太敏感否则天天被骚扰最后大家都不看告警了。我一般先观察一周的正常指标取 P99 的 1.5 倍作为告警阈值这样既能发现问题又不会频繁误报。6. 部署与运维的实战经验6.1 容器化部署的注意事项financial-services 的每个模块都打成 Docker 镜像用 Kubernetes 编排。Dockerfile 用多阶段构建第一阶段用 Maven 编译第二阶段只拷贝 JAR 包和 JRE这样镜像体积能小很多。FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]Kubernetes 的资源配置里requests和limits都要设。requests是调度依据limits是硬上限。JVM 的堆内存要设成limits的 70% 左右留出空间给堆外内存和系统。比如limits是 2GBJVM 参数就设-Xmx1400m。注意容器里的 JVM 要开启UseContainerSupport这样 JVM 才能正确识别容器的内存限制。JDK 10 以后默认开启但最好显式加上-XX:UseContainerSupport。6.2 灰度发布与回滚策略新版本上线用灰度发布先切 5% 的流量到新版本观察 30 分钟没问题再逐步扩大比例。灰度期间重点看错误率和延迟如果新版本的错误率比旧版本高立即回滚。回滚要快所以每次发布前都要保留旧版本的镜像。Kubernetes 的kubectl rollout undo可以快速回滚到上一个版本。另外数据库变更要兼容新旧两个版本比如加字段可以删字段和改字段类型不行因为回滚后旧版本代码不认识新字段。实操心得灰度发布期间不要做数据库变更等全量发布完成后再做。因为灰度期间新旧版本共存数据库变更可能导致旧版本报错。我一般把数据库变更拆成两步先加字段新旧版本都兼容等全量发布后再删旧字段。6.3 日志收集与问题定位日志用 ELK 收集应用把日志写到文件Filebeat 采集后推到 ElasticsearchKibana 做查询界面。日志格式用 JSON方便结构化查询。关键字段包括时间戳、日志级别、线程名、类名、traceId、消息。traceId 是全链路追踪的关键每个请求进来时生成一个 traceId放在 MDC 里日志框架自动带上。服务间调用时把 traceId 放在 HTTP Header 里传递这样在 Kibana 里用 traceId 一搜就能看到这个请求经过的所有服务。注意日志里不要打敏感信息比如密码、身份证号、银行卡号。这些信息要么脱敏要么干脆不打。我见过有团队把用户密码打到日志里被安全审计发现了整个团队被通报批评。6.4 容量规划与压测方法容量规划的依据是压测数据。用 JMeter 或者 wrk 对核心接口做压测逐步增加并发数观察 QPS 和延迟的变化。当延迟开始急剧上升时说明系统接近瓶颈了这个并发数就是单实例的容量上限。压测要在和生产环境配置一致的机器上做否则数据没有参考价值。压测数据要尽量模拟真实场景比如账户查询接口要准备足够多的用户 ID避免所有请求都打到同一条记录上。实操心得压测时一定要监控数据库和 Redis 的指标很多时候瓶颈不在应用层而在数据库。我压测过一个接口应用层 CPU 才 30%但数据库 CPU 已经 90% 了最后发现是 SQL 没走索引加了个索引 QPS 直接翻倍。7. 安全与合规的落地细节7.1 接口鉴权与权限控制financial-services 的接口鉴权用 JWT用户登录后服务端签发一个 token后续请求带上这个 token。token 里包含用户 ID 和角色信息服务端验证签名后解析出用户信息。token 的有效期设短一点比如 30 分钟过期后用 refresh token 换新的。权限控制用 RBAC 模型用户关联角色角色关联权限。接口上标注需要的权限拦截器里检查当前用户是否有这个权限。比如查询账户余额需要account:read权限转账需要account:transfer权限。注意JWT 的签名密钥要保管好泄露了别人就能伪造 token。密钥不要写在代码里用环境变量或者配置中心管理。另外 JWT 的 payload 是 Base64 编码的不是加密的所以不要在里面放敏感信息。7.2 敏感数据加密与脱敏数据库里的敏感字段要加密存储比如身份证号、银行卡号。加密用 AES-256密钥存在独立的密钥管理服务里应用通过接口获取。加密后的数据在查询时解密返回给前端时脱敏比如身份证号只显示前 6 位和后 4 位。脱敏在序列化层做用 Jackson 的JsonSerialize注解指定脱敏器public class IdCardSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException { if (value null || value.length() 10) { gen.writeString(value); return; } String masked value.substring(0, 6) **** value.substring(value.length() - 4); gen.writeString(masked); } }实操心得脱敏要在服务端做不能只靠前端。前端脱敏只是显示层面的接口返回的原始数据还是完整的抓包就能看到。服务端脱敏才是真正的安全。7.3 审计日志与操作留痕所有涉及资金变动的操作都要记录审计日志包括操作人、操作时间、操作类型、操作前后的值、请求 IP。审计日志单独存一个表只增不改保留至少 5 年。审计日志的写入要和业务操作在同一个事务里保证要么都成功要么都失败。如果审计日志写失败业务操作也要回滚因为无法追溯的操作是不允许的。注意审计日志的查询权限要严格控制只有审计角色才能查。普通用户和普通管理员都不能查审计日志防止有人篡改或删除日志掩盖操作痕迹。8. 从单体到微服务的演进路径8.1 什么阶段该拆微服务微服务不是越早拆越好。我的经验是团队规模在 10 人以下、业务模块在 5 个以下时单体应用完全够用而且开发效率更高。当团队超过 20 人多个团队同时改一个代码库频繁冲突时才需要考虑拆微服务。拆分的依据是业务边界不是技术边界。账户、行情、交易、风控这四个模块的业务边界很清晰适合拆成独立服务。但比如“用户管理”和“权限管理”虽然技术上可以拆但业务上关联太紧密拆了反而增加复杂度不如放在一个服务里。8.2 拆分过程中的数据一致性拆微服务最大的挑战是数据一致性。原来在单体里一个事务能搞定的事拆开后变成跨服务调用事务没了。解决办法是用 Saga 模式每个服务本地事务提交后发一个事件下一个服务监听事件后执行自己的本地事务如果某一步失败就发补偿事件回滚前面的操作。Saga 模式实现起来比较复杂对账是最后的兜底手段。即使 Saga 中间出了问题每天的对账任务也能发现差异并人工修复。所以我的建议是核心资金操作尽量用 Saga 保证一致性非核心操作可以接受最终一致靠对账兜底。8.3 服务治理的必备组件微服务拆开后服务治理的组件一个都不能少。服务注册发现用 Nacos 或者 Consul配置中心用 Nacos Config 或者 Apollo网关用 Spring Cloud Gateway 或者 Kong熔断限流用 Sentinel 或者 Resilience4j链路追踪用 SkyWalking 或者 Jaeger。这些组件不用一次性全上按需逐步引入。我的顺序是先上服务注册发现和配置中心这是基础然后上网关统一入口接着上熔断限流防止雪崩最后上链路追踪方便排查问题。实操心得服务治理组件本身也会成为故障点所以要做好高可用。Nacos 至少部署三个节点网关至少两个节点Sentinel 的规则要持久化到配置中心避免重启后规则丢失。9. 我踩过的那些坑9.1 浮点数精度问题早期做账户服务的时候金额字段用了DOUBLE类型结果对账时发现经常差几分钱。原因是浮点数在计算机里是二进制表示的有些十进制小数无法精确表示累加多次后误差就放大了。后来全部改成DECIMAL类型问题消失。这个坑的教训是金融场景里所有涉及金额的计算必须用BigDecimal或者DECIMAL绝对不能用float或double。Java 里用BigDecimal的时候构造方法要用字符串参数的不要用 double 参数的因为new BigDecimal(0.1)得到的是 0.1000000000000000055511151231257827021181583404541015625而不是 0.1。9.2 时区问题导致的对账差异有一次对账发现大量差异排查了半天发现是时区问题。应用服务器用的是 UTC 时间数据库用的是东八区时间两边对“当日”的理解差了 8 小时。晚上 8 点之后的交易在应用层算作第二天在数据库层还算当天。解决办法是统一时区应用、数据库、消息队列全部用 UTC 时间展示给用户的时候再转成当地时间。这样虽然麻烦一点但避免了跨时区的一致性问题。9.3 消息重复消费导致的重复入账RabbitMQ 的消息确认机制如果配成自动确认消费者收到消息就 ack如果处理过程中抛异常消息就丢了。但如果配成手动确认处理成功后 ack处理失败 nack 并重新入队又可能导致重复消费——比如处理成功了但 ack 的时候网络断了消息重新入队消费者又处理一遍。解决办法是消费端做幂等每条消息带一个唯一的 messageId消费前先查 messageId 是否处理过处理过就直接 ack 不再执行。messageId 存在 Redis 里设一个合理的过期时间比如 24 小时。9.4 缓存雪崩的预防缓存雪崩是指大量缓存同时过期所有请求都打到数据库导致数据库瞬间压力过大。预防办法是给缓存过期时间加一个随机值比如基础过期时间 10 分钟随机加 0 到 60 秒这样缓存不会同时过期。另一个办法是用多级缓存本地缓存过期时间短一点Redis 缓存过期时间长一点即使 Redis 缓存过期了本地缓存还能扛一阵。本地缓存和 Redis 缓存的更新通过消息队列同步保证最终一致。实操心得缓存雪崩的破坏力很大一定要提前预防。我一般会在缓存层加一个“空值缓存”查询数据库返回空的时候也缓存一个空值过期时间设短一点比如 30 秒。这样既能防止缓存穿透又不会缓存太久导致数据不一致。9.5 数据库死锁的排查与解决死锁的典型表现是日志里出现Deadlock found when trying to get lock。排查方法是执行SHOW ENGINE INNODB STATUS在输出里找到LATEST DETECTED DEADLOCK部分里面会显示两个事务分别持有什么锁、等待什么锁。解决死锁的根本办法是调整加锁顺序让所有事务按相同的顺序加锁。比如账户转账A 转 B 和 B 转 A 如果同时执行一个先锁 A 再锁 B一个先锁 B 再锁 A就会死锁。解决办法是给账户 ID 排序总是先锁 ID 小的再锁 ID 大的这样就不会死锁。注意死锁不可能完全避免只能减少发生频率。应用层要捕获死锁异常并重试重试一般都能成功。重试次数设 3 次超过就返回失败让用户重试。10. 后续扩展方向financial-services 这套架构搭好之后可以往几个方向扩展。第一个方向是增加更多数据源比如接入期货行情、期权行情、外汇行情只需要新增对应的适配器服务层和接入层不用改。第二个方向是增加策略回测功能用历史行情数据跑策略验证策略的有效性。第三个方向是增加实时风控大屏用 WebSocket 推送风控指标让风控人员实时看到当前的风险状况。我个人比较看好的是第二个方向因为策略回测是量化交易的核心需求而且它和现有的行情服务、交易服务能很好地复用。回测引擎可以独立部署从行情服务拉历史数据从交易服务拉交易规则跑完之后输出收益曲线和风险指标。这个功能做出来之后整个 financial-services 就从“数据服务”升级成了“策略平台”价值会大很多。最后再分享一个小技巧financial-services 的配置文件里所有涉及外部依赖的地址、端口、密码都抽成环境变量不要硬编码。这样同一份代码可以在开发、测试、生产环境无缝切换部署的时候只需要改环境变量不用改代码重新打包。我见过太多团队因为硬编码的地址导致上线时出问题这个习惯一定要养成。
网站建设高端定制企业官网