简介:这是一套基于PHP与MySQL、采用ThinkPHP框架开发的微交易系统源码,面向希望快速搭建小额短期资产价格预测平台的开发者与创业者。系统涵盖交易下单、账户管理、数据处理与用户交互等核心模块,并预留支付接口集成能力,适合具备一定PHP基础、想研究在线交易平台架构的技术人员参考。压缩包共678个文件,约10.38MB,以210个php业务代码、123个js脚本、79个html页面及89个png图片资源为主,另含css样式、tpl模板、sql数据库脚本与cer证书文件,覆盖前端展示、后端逻辑与支付对接等层面。目前已有865人学习下载。源码包含入口文件、数据库初始化脚本、支付目录及运行时缓存目录,结构完整,可帮助读者理解ThinkPHP的MVC分层、数据库表设计与支付回调处理思路,并在此基础上进行二次定制与功能扩展。
1. 微交易系统到底在交易什么:从一笔 0.01 手订单说起
很多人第一次听到「微交易系统」会以为是某种小额支付工具,其实在 PHP 技术圈里,它指的是一套跑在 Web 端的微型盘面交易系统——用户注册后能看到实时报价曲线,用余额下单买涨买跌,系统按到期时间结算盈亏。标题里的「微盘系统源码」就是这类东西的工程化叫法,核心是一套 PHP + MySQL 写的行情推送、订单撮合、资金结算和后台管理的闭环。它解决的不是「怎么炒」,而是「怎么用一套 LAMP 栈把交易逻辑跑稳」:订单不能重复扣款、余额不能出现负数、结算不能漏单。适合谁?适合想练手高并发写入、事务一致性、定时任务调度的 PHP 后端,也适合需要一套可二次开发的交易类 Demo 的团队。下面我按自己搭过的一套模拟盘思路,把选型、建表、下单、结算、排错全讲一遍。
2. 技术选型与库表设计:为什么不用框架一把梭
2.1 PHP 版本与运行方式的选择理由
这类系统对「请求响应快、常驻进程少」有硬要求。我一般选 PHP 7.4 或 8.0 的 FPM 模式,不选 Swoole 常驻,原因是微盘的下单接口 QPS 峰值通常几百,FPM 足够,而且出问题好排查——常驻进程一旦内存泄漏,重启成本高。数据库用 MySQL 5.7 或 8.0 的 InnoDB,必须开事务。缓存层用 Redis 存行情快照和用户余额的读缓存,但余额扣减必须以 MySQL 为准,Redis 只做展示,这是血泪经验:早期我用 Redis 做余额原子扣减,结果主从切换时丢了几笔,对账对到凌晨。
环境依赖清单如下,装完直接能跑:
| 组件 | 版本建议 | 用途 |
|---|---|---|
| PHP | 7.4 / 8.0 | 业务逻辑 |
| MySQL | 5.7+ InnoDB | 订单、资金、用户 |
| Redis | 5.0+ | 行情缓存、限流 |
| Nginx | 1.18+ | 反向代理、静态资源 |
| Supervisor | 任意 | 守护结算定时任务 |
2.2 四张核心表的建表语句与字段含义
微盘系统的表不用多,但每张都得想清楚。用户表、订单表、资金流水表、行情表,这四张是骨架。下面是我常用的建表 SQL,字段注释写全,方便二次开发的人看懂:
-- 用户表:余额字段用 decimal,绝不用 float CREATE TABLE `user` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `username` varchar(32) NOT NULL COMMENT '登录名', `balance` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '可用余额', `frozen` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '冻结金额', `created_at` int NOT NULL COMMENT '注册时间戳', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:status 用 tinyint,0待结算 1已结算 2已撤单 CREATE TABLE `order` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL, `direction` tinyint NOT NULL COMMENT '1买涨 2买跌', `amount` decimal(12,2) NOT NULL COMMENT '下单金额', `open_price` decimal(12,4) NOT NULL COMMENT '建仓价', `close_price` decimal(12,4) DEFAULT NULL COMMENT '结算价', `profit` decimal(12,2) DEFAULT NULL COMMENT '盈亏', `status` tinyint NOT NULL DEFAULT '0', `expire_at` int NOT NULL COMMENT '到期时间戳', `created_at` int NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`,`status`), KEY `idx_status_expire` (`status`,`expire_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 资金流水表:每一笔余额变动都要留痕,方便对账 CREATE TABLE `fund_log` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL, `change_type` tinyint NOT NULL COMMENT '1下单扣款 2结算入账 3撤单退回', `amount` decimal(12,2) NOT NULL COMMENT '变动金额,正负区分', `balance_after` decimal(12,2) NOT NULL COMMENT '变动后余额', `order_id` bigint unsigned DEFAULT NULL, `created_at` int NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 行情表:只存快照,历史K线另表或走时序库 CREATE TABLE `quote` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `symbol` varchar(16) NOT NULL COMMENT '品种代码', `price` decimal(12,4) NOT NULL, `updated_at` int NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_symbol` (`symbol`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:order表的idx_status_expire联合索引是给结算任务用的,定时任务每分钟扫status=0 AND expire_at<=now的订单,没这个索引数据量一上来就全表扫。fund_log的balance_after字段看着冗余,但对账时能直接定位是哪一笔把余额改错的,省掉后悔药。参数上,decimal(12,2)表示最大 10 位整数加 2 位小数,够用且不丢精度;expire_at用 int 存时间戳比 datetime 省空间,比较也快。
2.3 为什么余额字段坚决不用 float
float 在 PHP 和 MySQL 里做加减会出现0.1+0.2=0.30000000000000004这种玄学结果,交易系统里这是致命的。我见过一个 Demo 用 float 存余额,用户连续下 10 笔 0.1 元的单,最后余额显示 -0.0000001,触发风控误判。统一用 decimal,PHP 侧用bcadd、bcsub做运算,或者干脆在 SQL 里用balance = balance - ?让数据库算。这是选型阶段就要定死的规矩,后期改表结构成本极高。
3. 下单与结算的完整链路:从接口到定时任务
3.1 下单接口的事务写法与防重复提交
下单是整个系统最容易翻车的地方。用户点一下按钮,可能因为网络重试发两次请求,如果接口没做幂等,就会扣两次款。我的做法是:前端生成一个request_id(UUID),随下单请求带上,后端用 Redis 的SETNX做 5 秒去重,再进 MySQL 事务。下面是一个精简的下单处理函数:
<?php // 下单核心逻辑:先幂等,再事务扣款,最后落订单 function placeOrder($userId, $direction, $amount, $requestId) { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 幂等锁:同一个 request_id 5 秒内只处理一次 $lockKey = 'order_lock:' . $requestId; if (!$redis->set($lockKey, 1, ['nx', 'ex' => 5])) { return ['code' => 429, 'msg' => '请求重复,请勿频繁提交']; } $pdo = new PDO('mysql:host=127.0.0.1;dbname=weipan', 'root', 'pass'); $pdo->beginTransaction(); try { // 行锁查余额,FOR UPDATE 防止并发扣成负数 $stmt = $pdo->prepare('SELECT balance FROM user WHERE id = ? FOR UPDATE'); $stmt->execute([$userId]); $balance = $stmt->fetchColumn(); if ($balance < $amount) { $pdo->rollBack(); return ['code' => 400, 'msg' => '余额不足']; } // 扣款:用 SQL 原子减,避免 PHP 侧计算误差 $pdo->prepare('UPDATE user SET balance = balance - ? WHERE id = ?') ->execute([$amount, $userId]); // 落订单,到期时间设为当前 + 60 秒 $expireAt = time() + 60; $pdo->prepare('INSERT INTO `order` (user_id,direction,amount,open_price,status,expire_at,created_at) VALUES (?,?,?,?,0,?,?)') ->execute([$userId, $direction, $amount, getCurrentPrice(), $expireAt, time()]); $orderId = $pdo->lastInsertId(); // 记流水 $pdo->prepare('INSERT INTO fund_log (user_id,change_type,amount,balance_after,order_id,created_at) VALUES (?,1,?,?,?,?)') ->execute([$userId, -$amount, $balance - $amount, $orderId, time()]); $pdo->commit(); return ['code' => 0, 'msg' => '下单成功', 'order_id' => $orderId]; } catch (Exception $e) { $pdo->rollBack(); return ['code' => 500, 'msg' => '系统繁忙']; } }逻辑说明:FOR UPDATE是关键,它给这一行加排他锁,并发下单时第二个请求会等第一个提交后再读,保证余额判断准确。request_id幂等锁用 Redis 的nx选项,只有 key 不存在时才设置成功,天然防重。参数上,锁过期时间设 5 秒是权衡:太短防不住重试,太长会误杀正常请求。注意getCurrentPrice()要从行情表或 Redis 取最新价,别在事务里做耗时查询,否则锁持有时间变长,并发直接崩。
3.2 结算定时任务的扫描与批量处理
订单到期后要结算,靠的是常驻的定时任务。我用 Supervisor 守护一个 PHP CLI 脚本,每分钟跑一次,扫出到期未结算的订单,按当前价算盈亏,更新订单状态并给用户加钱。核心是批量处理 + 逐单事务,别一个事务包所有订单,否则一笔失败全批回滚。
<?php // 结算脚本:每分钟由 crontab 或 supervisor 拉起 $pdo = new PDO('mysql:host=127.0.0.1;dbname=weipan', 'root', 'pass'); $now = time(); // 一次最多取 200 单,避免单次执行过长 $stmt = $pdo->prepare('SELECT * FROM `order` WHERE status = 0 AND expire_at <= ? LIMIT 200'); $stmt->execute([$now]); $orders = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($orders as $order) { $pdo->beginTransaction(); try { // 再次确认状态,防止并发重复结算 $check = $pdo->prepare('SELECT status FROM `order` WHERE id = ? FOR UPDATE'); $check->execute([$order['id']]); if ($check->fetchColumn() != 0) { $pdo->rollBack(); continue; } $closePrice = getCurrentPrice($order['symbol'] ?? 'default'); // 买涨:结算价高于建仓价则盈利,买跌反之 $diff = $order['direction'] == 1 ? $closePrice - $order['open_price'] : $order['open_price'] - $closePrice; // 盈亏按金额的固定比例放大,这里设 80% $profit = round($order['amount'] * ($diff / $order['open_price']) * 0.8, 2); $pdo->prepare('UPDATE `order` SET status = 1, close_price = ?, profit = ? WHERE id = ?') ->execute([$closePrice, $profit, $order['id']]); // 返还本金 + 盈亏,若亏损则只返还本金加负盈亏 $returnAmount = $order['amount'] + $profit; $pdo->prepare('UPDATE user SET balance = balance + ? WHERE id = ?') ->execute([$returnAmount, $order['user_id']]); $pdo->prepare('INSERT INTO fund_log (user_id,change_type,amount,balance_after,order_id,created_at) VALUES (?,2,?,?,?,?)') ->execute([$order['user_id'], $returnAmount, getBalance($order['user_id']), $order['id'], time()]); $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); // 记录日志,下一轮会重新扫到这笔 error_log('settle fail order:' . $order['id']); } }逻辑说明:LIMIT 200是防止单次任务跑太久,200 单在普通机器上 1 秒内能处理完。FOR UPDATE二次确认状态,是因为定时任务可能被拉起多个实例(比如 Supervisor 配错),不加锁会重复结算。盈亏公式里0.8是赔付率参数,实际业务里这个值由后台配置,别写死。注意getBalance()在事务里查余额会有读一致性问题,更稳的做法是结算前先查一次余额,加钱后再查一次,或者直接用balance_after字段记录。
3.3 行情推送的轮询与缓存策略
微盘的行情不用 WebSocket 也能做,前端每 2 秒轮询一次接口,后端从 Redis 读快照返回。行情更新由一个独立脚本每 500 毫秒写一次 Redis,MySQL 只做持久化,不参与实时读。这样做的原因是:MySQL 扛不住高频读,Redis 单机几万 QPS 轻松。接口代码很短:
<?php // 行情接口:优先读 Redis,miss 再查库并回写 function getQuote($symbol) { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $key = 'quote:' . $symbol; $price = $redis->get($key); if ($price === false) { $pdo = new PDO('mysql:host=127.0.0.1;dbname=weipan', 'root', 'pass'); $stmt = $pdo->prepare('SELECT price FROM quote WHERE symbol = ?'); $stmt->execute([$symbol]); $price = $stmt->fetchColumn() ?: '0.0000'; $redis->setex($key, 3, $price); // 缓存 3 秒 } return $price; }逻辑说明:setex3 秒过期是兜底,防止行情脚本挂了导致价格永远不更新。参数上,轮询间隔 2 秒是体验和压力的平衡点,再快用户也感知不到,反而增加服务器负担。注意别在行情接口里做鉴权以外的重逻辑,它会被高频调用。
4. 避坑与排查:那些让系统半夜报警的细节
4.1 并发下单导致余额扣成负数
现象:压测时 100 并发下单,最后几个用户余额变成负数。原因:下单接口没用FOR UPDATE,两个请求同时读到余额 100,都判断够扣,结果各扣 100。解决:所有涉及余额变动的查询必须加行锁,或者用UPDATE user SET balance = balance - ? WHERE id = ? AND balance >= ?这种带条件的原子更新,根据 affected rows 判断是否成功。
4.2 结算任务重复执行导致重复加钱
现象:用户反馈结算后余额多了两倍。原因:Supervisor 配了 2 个进程,或者上一轮任务没跑完下一轮又启动。解决:结算脚本入口加文件锁或 Redis 分布式锁,SETNX settle_lock成功才执行,执行完释放;同时订单表用FOR UPDATE二次确认状态。
4.3 时间戳时区不一致导致订单提前结算
现象:订单设 60 秒到期,实际 30 秒就被结算了。原因:PHP 的time()是 UTC 时间戳,但数据库或服务器时区设成了东八区,比较时错位。解决:全链路统一用 UTC 时间戳存 int,展示时再转本地时区。别用datetime字段存到期时间,跨时区必翻车。
4.4 行情缓存击穿导致数据库被打挂
现象:行情接口突然大量超时,MySQL 连接数爆满。原因:Redis 里行情 key 同时过期,所有请求穿透到 MySQL。解决:行情脚本写 Redis 时用set不带过期,另起一个脚本每 3 秒刷新,或者用setex但加互斥锁只让一个请求回源。更简单的做法是行情 key 永不过期,靠脚本更新。
4.5 资金流水与余额对不上
现象:对账时发现fund_log累加和user.balance不一致。原因:某次改代码时漏记了一笔流水,或者事务回滚了但流水已写。解决:所有余额变动必须和流水在同一个事务里,且流水记录balance_after。每天跑一次对账脚本,用SELECT SUM(amount) FROM fund_log WHERE user_id=?和user.balance比对,不一致就告警。
5. 进阶技巧:用对账脚本和压测把系统钉死
系统能跑起来只是及格,能扛住对账和压测才算落地。我一般会写一个对账脚本,每天凌晨跑一次,把每个用户的流水汇总和余额比对,差一分钱都报警。这个脚本不长,但能救命:
<?php // 对账脚本:流水汇总 vs 用户余额,不一致则记录 $pdo = new PDO('mysql:host=127.0.0.1;dbname=weipan', 'root', 'pass'); $users = $pdo->query('SELECT id, balance FROM user')->fetchAll(PDO::FETCH_ASSOC); foreach ($users as $u) { $stmt = $pdo->prepare('SELECT SUM(amount) FROM fund_log WHERE user_id = ?'); $stmt->execute([$u['id']]); $sum = $stmt->fetchColumn() ?: '0.00'; // 用 bccomp 比较,避免浮点误差 if (bccomp($sum, $u['balance'], 2) !== 0) { error_log("对账不平 user:{$u['id']} 流水:{$sum} 余额:{$u['balance']}"); } }逻辑说明:bccomp第三个参数 2 表示保留两位小数比较,比==可靠。这个脚本跑全量用户,数据量大时按 id 分页跑,别一次查出来。参数上,对账频率每天一次足够,太频繁浪费资源。
压测我一般用ab或wrk打下单接口,重点看三个指标:下单成功率、余额负数出现次数、结算延迟。命令示例:
# 用 wrk 压测下单接口,10 线程 100 连接,持续 30 秒 wrk -t10 -c100 -d30s --latency -s post.lua http://127.0.0.1/api/orderpost.lua里构造带request_id的 POST 请求,每次请求换一个 UUID,否则全被幂等锁挡掉测不出真实并发。压测时盯着 MySQL 的SHOW PROCESSLIST,如果大量Waiting for table metadata lock,说明有长事务没提交,回去查代码。
最后说个我自己的习惯:任何涉及钱的系统,上线前必须手动跑一遍「下单-结算-对账」全流程,用两个账号互相下单,看余额和流水对不对。自动化测试能覆盖 90%,但剩下 10% 的边界——比如结算时行情刚好更新、用户余额刚好等于下单金额——只有手动点一遍才放心。这套微盘系统源码的骨架不复杂,难的是把每个边界都堵死。希望帮到你。
本文还有配套的精品资源,点击获取