
简介这份运营级Java综合交易所源码以zip压缩包形式发布包体约325.36MB面向具备Java部署能力的开发者、技术团队及金融科技学习者。系统支持13国语言PC端与H5端一体化设计自带客服系统目前开放外汇与区块链两大交易模块并包含C2C、理财、质押、交割、永续等业务功能不含期货覆盖常见数字资产交易场景。包内为完整前后端工程及客服系统组件部署依赖MySQL 5.6与Redis可直接搭建测试环境进行二次开发由于上游未给出文件总数和类型清单实际内容以源码、配置及前端资源为主整体结构围绕交易核心展开便于按模块理解C2C、理财、质押、交割等业务逻辑。目前已有128人学习下载适合想低成本获取可运行交易系统原型、研究多语言国际化实现或扩展交易所功能的Java开发者。1. 一套 Java 写的运营级交易所股票、区块链、外汇同台跑还内置客服拿到一套能同时跑股票、区块链币币和外汇的 Java 源码第一反应是找它的订单撮合模块和资金账本在哪。拆过单市场的项目都知道股票和外汇的账户体系、行情频率、手续费模式差得远区块链现货还要单独处理充提币和链上确认硬塞进一个系统里很容易变成四不像。这套运营级综合交易所源码的看点是三类业务共用同一套撮合引擎、统一账本和客服后台13 国语言做在框架层而不是硬编码页面。适合需要快速搭起多资产交易平台验证业务的团队也适合想研究主流交易所核心模块怎么组织的 Java 工程师。下面按架构、账本、行情接入、部署避坑的顺序拆开讲。2. 先拆架构模块边界、撮合引擎与账本一致性从哪下手2.1 工程结构看系统分层gateway、market、trade、account 四块我拿到源码第一件事是看根目录的 pom.xml 和 modules 标签。运营级系统和练手项目最大的区别是模块边界清楚每块能独立部署、独立扩缩容。这套源码按常规组织方式拆成四个核心模块gateway 管接入market 管行情trade 管订单和撮合account 管钱包账户。客服系统挂在账号体系下消息走 gateway 的 WebSocket 通道。先别急着跑把根 pom.xml 的 modules 列出来核对每块是不是独立的 Spring Boot Application而不是互相耦合的 jar。我见过不少源码把 service 层直接塞在 controller 里号称微服务其实是一个单体包结构这也不算硬伤但扩容和灰度发布时会很痛苦。另一个要看的是中间件Redis 用在哪几个模块、MySQL 的分库分表到了没到账本层、消息队列是 Kafka 还是 RocketMQ这些决定团队能不能自己运维。模块职责典型内容gateway接入层登录、鉴权、限流、WebSocket 网关market行情系统K 线生成、订单簿快照、行情推送trade交易核心撮合引擎、订单生命周期、手续费计算account资金账本钱包余额、冻结、流水、充提币确认模块之间通信用 REST 还是异步事件从接口调用关系能看出来。订单提交走 REST 进 trade 模块成交回报应该走 Kafka 或 RocketMQ 通知 account 和 market而不是同步 RPC 等结账完成——那样会把撮合的 TPS 拉低一个数量级。源码里如果看到下单接口直接 RPC 调用 account 扣款再本地撮合属于还有优化空间的实现能跑但不耐压。注意部署文件里如果只有 docker-compose.yml 而缺少数据库初始化脚本大概率要在 docs/sql 或 resource/sql 下找建表语句找不到就要做好补表的心理准备。2.2 撮合引擎内存订单簿、快照恢复与延迟指标撮合引擎这部分运营级项目都有一个共同选择内存撮合。所有订单簿和活跃订单放在 JVM 内存里数据库只做最终落库和账本流水撮合过程中不碰数据库。为什么因为磁盘 IO 和行锁会让撮合吞吐掉到每秒几百笔内存撮合配无锁队列能上千。代价是宕机恢复要依赖快照所以源码里一般会有定期的订单簿快照加事务日志重放机制。看撮合代码时先找订单簿的数据结构。Java 里常用两组优先队列买盘价格降序、卖盘价格升序同价格按到达序号排。两个细节决定性能一是比较器是否包含 sequence 字段不加它的话同价订单不能保证先来先成交用户会投诉“我挂的单为什么被后面的人先成交”二是订单数量级上去后 PriorityQueue 的入队出队是否成为瓶颈有的实现会在热点交易对上改成红黑树或跳表来避免频繁堆操作。绝大多数场景 PriorityQueue 够用先跑通再优化是正路。public class MatchingEngine { // 买盘价格降序同一价格按到达顺序排 private final PriorityQueueOrder buys new PriorityQueue( Comparator.comparing(Order::getPrice).reversed() .thenComparing(Order::getSequence)); // 卖盘价格升序同一价格按到达顺序排 private final PriorityQueueOrder sells new PriorityQueue( Comparator.comparing(Order::getPrice) .thenComparing(Order::getSequence)); public ListTrade match(Order incoming) { ListTrade trades new ArrayList(); while (!incoming.isFilled()) { Order counter incoming.isBuy() ? sells.peek() : buys.peek(); if (counter null || !priceCross(incoming, counter)) break; long qty Math.min(incoming.getRemaining(), counter.getRemaining()); counter.reduce(qty); incoming.reduce(qty); trades.add(new Trade(symbol, counter.getPrice(), qty, System.currentTimeMillis())); if (counter.getRemaining() 0) { if (incoming.isBuy()) sells.poll(); else buys.poll(); } } if (incoming.getRemaining() 0) { if (incoming.isBuy()) buys.add(incoming); else sells.add(incoming); } return trades; } }这是撮合引擎的最小骨架。priceCross 处理方向买单一侧只匹配卖盘价格小于等于自身出价的单子卖单只匹配买盘价格大于等于自身报价的单子。成交价取对手单的价格这在限价单场景叫 maker 优先避免频繁改价。getRemaining() 返回剩余未成交数量撮合循环里每次取 min 保证双方都不超量最后剩余部分重新进订单簿。运营级实现会进一步处理撤单中断、盘口冷启动、策略交易等细节但看懂这一个循环再去看源码里带各种优化的版本就不会迷路。快照恢复是内存撮合的命门。源码里如果没有订单簿快照和事件重放机制宕机后订单簿和账本会对不上用户余额和持仓都得手动核对。我一般会查 trade 模块有没有定时任务把订单簿序列化到 Redis 或本地文件以及启动时是否先加载快照再消费增量事件。2.3 订单生命周期状态机、撤单竞态与 T1 结算适配订单状态机决定账本和用户感知的一致性。标准链路是 NEW 到 PARTIALLY_FILLED 再到 FILLED或者转向 CANCELLED / EXPIRED。源码里每个状态转移都应该收敛到 OrderService 的固定方法受理、成交回调、撤单、过期。如果直接把状态字段 set 成新值却缺少校验高并发下会出现“已成交的订单被当成可撤单处理”的假撤单。前置状态事件后置状态账本动作NEW部分成交PARTIALLY_FILLED成交部分解冻并结转入账PARTIALLY_FILLED继续成交FILLED剩余冻结解冻、资产入账NEW / PARTIALLY_FILLED撤单CANCELLED剩余冻结全部释放NEW / PARTIALLY_FILLED超时EXPIRED同上撤单和成交同时发生的竞态是测试中最容易复现的 bug用户看到订单还挂着点击撤单接口返回成功但成交回报也同时到达。源码如果对外部 API 先判断状态再更新没有对订单行加锁或使用乐观锁这条竞态就堵不住。我用的一条铁律撤单接口必须带 order_id、user_id、expected_status 做条件更新如UPDATE orders SET statusCANCELLED WHERE id? AND user_id? AND status IN (NEW,PARTIALLY_FILLED)影响行数为 0 说明状态已变直接返回“订单已成交”。股票 T1 与区块链 T0 在这里的映射是“可卖数量”的差异。股票版的成交不会立刻增加 available 余额而是先进待解禁数量区块链现货则是实时解冻。源码如果在 wallet_ledger 的业务类型里区分了 TST_AVAILABLE 和 SPOT_AVAILABLE说明它是按交易类型而不是代码分支处理差异这是较成熟的映射方式。3. 13 国语言与统一账本i18n 框架和多资产资金模型如何匹配3.1 语言切换Spring MessageSource、动态加载与回退策略13 国语言最先要解决的是配置文件混乱。有人把文案直接写在 HTML 或前端 JS 里那不可能支持多语言。这套源码走的是 Spring 标准 MessageSource 方案每个语言一个 messages_xx_YY.properties后端根据请求头或用户设置返回对应文案前端不做翻译只负责把后端返回的 key 对应的文案原样展示。Configuration public class I18nConfig { Bean public MessageSource messageSource() { ReloadableResourceBundleMessageSource source new ReloadableResourceBundleMessageSource(); source.setBasenames( classpath:i18n/messages_zh_CN, classpath:i18n/messages_en_US, classpath:i18n/messages_ja_JP, // 其余语言按 i18n 目录实际文件补兜底包最后 classpath:i18n/messages ); source.setDefaultEncoding(UTF-8); source.setFallbackToSystemLocale(false); source.setUseCodeAsDefaultMessage(true); source.setCacheMillis(300_000); return source; } }setBasenames 的顺序决定了查找优先级请求带 Accept-Language: zh-CN 时先查 messages_zh_CN.properties找不到 key 就回退到兜底 messages.properties。setFallbackToSystemLocale(false) 必须设否则服务器跑在系统 locale 为 en_US 的容器里时所有语言缺失的 key 都会静默变成英文上线后某个阿拉伯语用户看到一半英文一半阿拉伯文排查起来极其费劲。setCacheMillis(300_000) 让语言包修改后 5 分钟自动生效运营期加文案时改完 properties 等五分钟刷新即可不需要重启服务。语言包编码有个隐性坑properties 文件如果存成 GBK 或带 BOM 的 UTF-8阿拉伯语、俄语这类文字会乱码。规范做法是统一存无 BOM 的 UTF-8并在打包脚本里强制校验。检查源码时可以把 i18n 目录下每个语言的 key 数量横向对比缺 key 的范围集中在哪个模块——通常客服系统最容易被忽略用户用西班牙语提问、客服却在阿拉伯语界面读不到消息标题体验直接崩。3.2 统一账本钱包余额、资金流水与充值提币确认股票、区块链、外汇的资产不需要做三套账本。统一模型是 wallet_balance 存“用户 币种 可用 冻结”wallet_ledger 存每一笔变更流水。股票里的币种是 USD、CNY区块链里的币种是 BTC、ETH、USDT外汇里的是交易对本币和报价币全部落在同一个 currency 字段上不需要为每种资产类型建不同的账户表。CREATE TABLE wallet_balance ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, currency VARCHAR(16) NOT NULL COMMENT USD/BTC/ETH/USDT..., available DECIMAL(36, 18) NOT NULL DEFAULT 0, frozen DECIMAL(36, 18) NOT NULL DEFAULT 0, updated_at BIGINT NOT NULL, UNIQUE KEY uk_user_currency (user_id, currency) ); CREATE TABLE wallet_ledger ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, account_id BIGINT UNSIGNED NOT NULL, change_amount DECIMAL(36, 18) NOT NULL, balance_after DECIMAL(36, 18) NOT NULL, biz_type VARCHAR(32) NOT NULL COMMENT TRADE/BLOCKCHAIN_DEPOSIT..., ref_id VARCHAR(64) NOT NULL COMMENT 订单号或链上交易哈希, created_at BIGINT NOT NULL, UNIQUE KEY uk_ref (biz_type, ref_id) );wallet_ledger 的 uk_ref 唯一索引是防御资金重复入账的第一道防线充值入账时 biz_type 传 BLOCKCHAIN_DEPOSIT、ref_id 传链上交易哈希同一笔哈希在数据库层面就不可能写两遍。balance_after 一定要存没有它发生异常时只能靠重新累加流水推余额没法直接比对账实差异。金额精度用 DECIMAL(36, 18)BTC 的 satoshi 是 1e-8外汇 pip 精度各不相同统一到 18 位小数可以覆盖所有币种换来的是代码里不用到处写精度转换。区块链充提币和股票、外汇的最大差异在“确认”。股票充值来自券商划转外部数据权威区块链充值是等链上确认数到达阈值源码里一般有一个 DepositConfirmService 定时扫描节点 API 的未确认交易达到确认数后才调账本入账。常见的错误实现是节点同步不到位就放行——确认数要求 3节点只同步了 1 个块就回调会造成双花风险。运营级做法是确认数阈值做成按币种配置测试网调低、主网调高上线初期宁高勿低。账本对账任务也是必检项。运营级系统通常有每日对账定时任务遍历 wallet_balance.available frozen按用户和币种聚合 wallet_ledger.balance_after 的最大值并比较不一致就告警。这个任务要从上线第一天就开跑满 30 天能发现很多只在特殊行情波动时才暴露的边界问题。4. 三市联动行情源适配、订单路由与外部交易差异的落地位置4.1 行情源统一抽象一个接口接股票、区块链、外汇三个市场的行情来源完全不同。区块链走交易所 WebSocket 或自建全节点股票接券商行情网关或第三方数据商外汇接流动性提供商的报价流。协议五花八门但下游消费方只关心一件事某个交易对的最新价、买一卖一、时间戳。所以要使源码的 market 模块有统一 MarketDataProvider 接口每种行情源一个实现类内部做协议适配对外产出统一 Tick。public interface MarketDataProvider { void subscribe(ListString symbols); void onTick(ConsumerTick consumer); void disconnect(); } public record Tick( String symbol, // BTC/USDT 或 AAPL 或 EUR/USD long timestamp, // epoch millisUTC long price, // 最小精度整数表示 long bidPrice, long askPrice ) {}price 用 long 不用 double 是刻意设计行情源返回的价格直接乘上该 symbol 的价格精度BTC/USDT 精度 1e-2、AAPL 精度 1e-2、EUR/USD 精度 1e-5统一转成整型参与撮合计算避免浮点误差。这个精度值放在 symbol_meta 表或配置文件里存着每个交易对的 pricePrecision 和 qtyPrecisionK 线模块和撮合模块共用。检查时重点看一致性如果行情接口的价格精度和撮合模块不一致同一笔订单在盘口显示和成交回报里会出现“价差 0.01”的肉眼可见差异。三路行情源的连接管理各有一个容易踩的坑。区块链 WebSocket 断线后要重新订阅股票网关一般要求固定 IP 白名单外汇报价流是高频推送如果直接拿 Java 的 ObjectMapper 解析 JSON延迟会超过 10ms。合格实现会针对不同数据源区分 JSON、Protobuf 或字节缓冲截取字段。源码统一走 JSON 也没关系先确认 disconnect 和 reconnect 幂等——重连后订阅全集而不是依赖服务端恢复订阅是这类系统最常见的隐性 bug。4.2 订单路由与结算差异从统一入口到各市场规则映射订单路由的核心很简单订单带 marketType 字段进 OrderRouter 后分发到对应撮合引擎。真正的复杂度在结算规则的差异映射。区块链现货实时到账股票涉及 T1 可卖数量和交易日历外汇涉及隔夜利息 swap 和杠杆保证金。这些差异如果塞进撮合引擎里撮合会变成一团乱麻成熟做法是把差异全部下沉到 accountService 的结算层撮合引擎只负责生成成交回报。public void route(Order order) { MatchingEngine engine engines.get(order.getMarketType()); if (engine null) { throw new UnsupportedMarketException(order.getMarketType()); } accountService.freeze(order.getUserId(), order.getMarketType(), order.getFrozenAmount()); try { ListTrade trades engine.match(order); accountService.settle(order.getMarketType(), trades); } catch (RuntimeException e) { accountService.unfreeze(order.getUserId(), order.getMarketType(), order.getFrozenAmount()); throw e; } }freeze 和 settle 之间的失败补偿最容易被忽视。freeze 成功、match 抛异常时如果忘记 unfreeze用户资金被冻结但对应订单已经没了客服工单会淹没后台。所以 try/catch 里第一个动作就是解冻宁可解冻重复解冻接口幂等也不要漏解冻。settle 接收 marketType 而不是直接按币种处理T1 和 T0 的差异由 accountService 内部按市场规则分发形成清楚的映射表。业务类型成交后资金动作是否支持 T1 卖出额外费用区块链现货可用余额实时增加是实时网络手续费股票增加持仓但当日不可卖否佣金、印花税外汇按杠杆占用保证金视规则swap 隔夜利息这张表在代码里的对应物是 market_type_rule 配置表或 MarketSettlementRule 枚举包含结算方式、最小下单量、手续费率、杠杆倍数上限。很多二次开发团队把规则写死在 if/else 里加一个交易对就要发版这是和运营级源码拉开差距的地方。4.3 客服系统与交易模块的联动消息可靠性和工单状态自带客服系统在这个场景里必须和交易模块联动。用户咨询“我明明充值了为什么没到账”时客服端要能看到该用户的充值记录、链上确认状态、账本流水。源码的客服模块一般由四部分组成WebSocket 在线聊天、工单系统、操作日志和后台关联查询。聊天走 gateway 的 WebSocket 网关工单落 MySQL用户 ID 直接关联 account 模块数据。消息可靠性用“先落库再推送 ack 补拉”的模式。服务端收到客服回复的消息先写 chat_message 表再推给用户用户端收到后回 ack服务端标记已读。用户重连后网关从 Redis 拉取该用户 30 天内未 ack 的消息重新推送。这套逻辑和区块链充值的“先入账再发通知”是同一套路都要求事件先落盘再消费。5. 部署避坑跑运营级交易所最容易翻车的 5 个位置5.1 并发下单死锁锁顺序不一致就全体卡死现象压测进行 5 分钟后下单接口 QPS 突然从 800 掉到 0线程 dump 显示 Thread A 持有 user_10086 的锁等 user_10087Thread B 持有 user_10087 等 user_10086两个线程互相持有对方需要的锁。原因账本模块加锁是“先锁 A 后锁 B”撮合的结算路径可能以“先处理 B 单再处理 A 单”的顺序获取锁两边顺序相反。Java 的 synchronized 不会自动解除死锁线上只能重启但重启后同样的问题还会复现。解决账户锁全局按 user_id 升序获取任何服务、任何线程都不允许打破这个顺序。我在 AccountService 入口封装了一个 AccountLocks 工具内部维护 ConcurrentHashMap 到 ReentrantLock 的映射对外只暴露 lockAll(List userIds) 方法由工具类排序后加锁后续同事写底层方法也不容易绕过它。验证方式是压测时用jstack 进程ID抓两次线程快照确认没有互相等待的锁对。5.2 资金流水重复入账先查后插的充值逻辑现象应用发布后后台对账发现某个用户 BTC 余额比实际多了一倍查 wallet_ledger 看到同一个链上交易哈希入账了两次ref_id 完全相同。原因充值确认服务的代码是“先查 wallet_ledger 有没有这笔 ref_id没有就插入新流水并加余额”。两个线程同时执行查询时都查不到于是都执行了插入数据库表没有唯一约束重复就产生了。解决wallet_ledger 的 biz_type ref_id 上唯一索引插入用INSERT ... ON DUPLICATE KEY UPDATE让数据库拦重复。如果源码没这个索引加一条 DDLALTER TABLE wallet_ledger ADD UNIQUE KEY uk_ref (biz_type, ref_id);然后把业务代码改成受影响行数为 0 时直接返回“已入账”。从那以后我对所有带“先查后写”的资金逻辑都会条件反射式地上唯一约束。5.3 时区与语言包13 国用户看到的 K 线错位现象阿拉伯和北美用户反馈日线 K 线的日期边界不对美东用户显示收盘时间是凌晨 4 点而不是当地 16 点同一个 K 线用户在手机和网页上看到的日期不一致。原因后端用 LocalDateTime.now() 存行情和 K 线时间戳服务器时区是 UTC8展示层根据浏览器时区格式化但数据库里存的“本地时间”已经把 UTC8 当作绝对时间用户时区一变换日期边界就算错了。解决数据库时间列只存 BIGINT epoch millis 或 MySQL 的 TIMESTAMP内部按 UTC 存展示时才转时区。Java 代码里时间传递全部用 Instant 或 long展示层通过用户偏好时区格式化。这条要写进项目规范因为 13 国语言环境下任何一个“本地时间”都会成为运营事故。5.4 客服消息丢失WebSocket 断线发生在推送和落库之间现象用户和客服聊到一半手机从 Wi-Fi 切到 4G 导致连接断开重连后消息列表里缺了中间某一条客服回复双方都以为对方没收到。原因服务端收到客服回复先通过 WebSocket 推送给用户然后写数据库标记已发送。断线恰好发生在推送之后、落库之前消息就从用户视角消失了数据库里也没有记录。解决顺序反过来先写 chat_message 表落库再推送客户端收到后回 ack 标记已读。重连时网关查 Redis 里该用户最近 30 天未 ack 的消息列表补推。注意落库和推送不能在同一个事务里——推送是外部 IO不能占用事务时长。正确做法是消息先走 MQ 到网关网关落库后再推送落库失败和推送失败分开补偿。5.5 语言包乱码与回退失效多语言界面一半英文一半本地文现象俄语、阿拉伯语用户看到界面部分文案变成乱码框或者某些模块显示英文、另一些模块显示本地语言同一个页面里语言混着来。原因properties 文件带 BOM 或被转成 GBK 存储Spring 加载后 key 匹配不到另外 setFallbackToSystemLocale 没配置容器系统 locale 是 en_US缺失的 key 全走了系统 locale 兜底而不是统一走默认包。解决语言包统一存无 BOM 的 UTF-8打包脚本里加 file 命令校验编码Maven resources 插件强制 encoding 为 UTF-8。代码里把 setFallbackToSystemLocale(false) 加上setUseCodeAsDefaultMessage(true) 打开这样缺翻译时前端能看到 key 字符串而不是混入英文。上线前用语言切换脚本逐个语言验证每个模块的关键文案不用等用户拍照反馈。6. 上线前最后三件事压测指标、权限审计与冷热钱包切换把交易模块联调通、界面跑顺离上线还差好几步。我每次部署这套源码都会强制过三件事少一件都不敢开实盘。压测不是看 QPS 峰值。我用 wrk 打 10 分钟 1000 并发下单同时盯三个指标撮合引擎订单队列的 P99 延迟是否超过 500mswallet_ledger 表的行锁等待率是否升高GC 日志里 Full GC 是否在压测期间出现。前两个不过说明锁粒度太粗第三个不过要看 jstat -gcutil 里 Eden 区晋升情况。压测报告三页纸作为上线评审的第一张附件。权限审计经常被演示版糊弄。把代码里所有接口扫一遍重点找充提币、撤单、客服后台、用户资产明细四个域。充提币接口必须有二次验证撤单接口必须在 Service 层校验订单归属用户不能只靠前端传 orderId 就撤客服后台若没有独立 RBAC普通用户登录后可能直接打开客服管理页面。我用脚本扫描所有 RequestMapping 路径对比权限配置表高危接口逐一确认是否有 PreAuthorize 或 RequiresPermissions。冷热钱包切换是区块链资产的保命设计。不能把所有币都放热钱包我按“热钱包余额超过阈值的部分转冷钱包”做成定时任务阈值按币种配置BTC 主网 5 个起步USDT 可以大一些。转出时私钥签名在独立离线机器完成转出交易的 txid 记录到 wallet_transfer_log服务端等链上确认后再更新内部地址映射。#!/bin/bash # 每 10 分钟检查热钱包余额超过阈值就触发转冷钱包 for symbol in BTC ETH USDT; do balance$(curl -s http://wallet-admin/api/v1/hot/$symbol/balance) threshold_keyTHRESHOLD_$symbol threshold${!threshold_key:-10} if awk BEGIN{exit !($balance $threshold)}; then curl -X POST http://wallet-admin/api/v1/hot/$symbol/transfer-out \ -H Content-Type: application/json \ -d {\amount\:\$balance\,\to\:\$COLD_WALLET_$symbol\} fi done这个脚本里的钱包管理服务只接收转账指令真正私钥放在签名服务里同一笔转账请求带唯一 requestId 防重。转出后等链上确认确认数够了才扣减热钱包余额。部署这套源码前我会先在测试网跑一次“充值入账到转出冷钱包”全流程确认数值和状态流转都对才考虑上主网。从那以后我每次部署交易所都强制把这三件事走一遍确认报告归档了才把域名切到生产。希望帮到你。本文还有配套的精品资源点击获取