:從數(shù)據(jù)結(jié)構(gòu)選型到撮合引擎設(shè)計(jì))
簡介這是基于Java Swing開發(fā)的股票交易模擬系統(tǒng)課程設(shè)計(jì)資源包面向需要完成數(shù)據(jù)結(jié)構(gòu)課程設(shè)計(jì)、Java實(shí)訓(xùn)或畢業(yè)設(shè)計(jì)的本科/高職學(xué)生。系統(tǒng)采用面向?qū)ο笈cMVC分層思想綜合運(yùn)用數(shù)據(jù)庫存儲技術(shù)、鏈表與隊(duì)列數(shù)據(jù)結(jié)構(gòu)、JFreechart開源圖表庫以及新浪股票行情接口實(shí)現(xiàn)行情揭示、賬戶管理、模擬買賣、走勢圖繪制、技術(shù)指標(biāo)與策略分析等完整功能同時(shí)以隨機(jī)數(shù)模擬實(shí)時(shí)數(shù)據(jù)便于脫離真實(shí)行情環(huán)境獨(dú)立運(yùn)行調(diào)試。資源包共187個(gè)文件約10.79MB包含54個(gè)Java源碼、20個(gè)依賴jar包、87個(gè)編譯后的class文件以及PPT答辯演示文稿、數(shù)據(jù)庫配置腳本、界面截圖和設(shè)計(jì)說明文檔代碼分層清晰可直接導(dǎo)入主流IDE學(xué)習(xí)與二次開發(fā)。已有758人學(xué)習(xí)下載適合用來掌握Swing圖形界面設(shè)計(jì)、數(shù)據(jù)庫連接、隨機(jī)數(shù)仿真、事件驅(qū)動(dòng)編程等核心技能也可作為課程設(shè)計(jì)報(bào)告與答辯PPT的參考素材。1. Java Swing 股票交易模擬系統(tǒng)這個(gè)課程設(shè)計(jì)包真正值得花時(shí)間的部分每年數(shù)據(jù)結(jié)構(gòu)課設(shè)都有人拿到類似「Java Swing 股票交易模擬系統(tǒng)源碼數(shù)據(jù)庫ppt」的壓縮包第一反應(yīng)是裝上跑通、截圖交差。但這類 java 課程設(shè)計(jì)案例源碼遍地都是真正拉開分?jǐn)?shù)差距的不是界面多好看而是你能不能把訂單排隊(duì)、行情緩存、持倉映射、賬戶并發(fā)扣款這些業(yè)務(wù)場景和數(shù)據(jù)結(jié)構(gòu)課上的堆、隊(duì)列、哈希表、平衡樹逐一對上號。題目里帶著「數(shù)據(jù)結(jié)構(gòu)」三個(gè)字評分點(diǎn)基本落在選型和復(fù)雜度上。這篇文章我會(huì)從整體設(shè)計(jì)拆到數(shù)據(jù)庫建表再講清楚撮合、行情刷新和線程安全怎么落地最后給你一套答辯前能直接用的驗(yàn)證方法和避坑順序。2. 先搭框架再看代碼四層架構(gòu)與每層該選什么數(shù)據(jù)結(jié)構(gòu)2.1 依賴方向決定改代碼的位置UI、服務(wù)、數(shù)據(jù)三層為什么要物理拆開很多課設(shè)包的原始代碼是「一個(gè) MainFrame 包天下」JTable 的渲染、行情生成循環(huán)、下單邏輯全塞在一個(gè)類里甚至監(jiān)聽器里直接寫 SQL。這種寫法不是不能跑而是你一旦被問到「某個(gè)功能改了會(huì)不會(huì)影響另一個(gè)功能」根本答不清楚依賴關(guān)系。我一般會(huì)先把代碼按四層拆開按下面的包結(jié)構(gòu)落com.course.stock ├── view # JFrame、JTable、監(jiān)聽器只做展示和輸入收集 ├── service # 賬戶、撮合引擎、行情生成業(yè)務(wù)規(guī)則都在這里 ├── dao # JDBC 數(shù)據(jù)訪問負(fù)責(zé)落庫和查詢 └── domain # User、TradeOrder、DealRecord、Position 等實(shí)體依賴方向是單向的view 調(diào) serviceservice 調(diào) daodomain 是公共數(shù)據(jù)載體。service 層絕對不 import javax.swingdao 層也絕對不出現(xiàn)業(yè)務(wù)判斷。這樣分完之后你在答辯時(shí)可以說「UI 層的登錄按鈕只負(fù)責(zé)把用戶名密碼交給 service 去校驗(yàn)數(shù)據(jù)庫連接方式換成連接池UI 層不需要改動(dòng)」。這句話比功能演示更能證明你理解了分層。一個(gè)額外收益是調(diào)試成本下降。比如行情價(jià)格異常先看 service 層的生成邏輯不用在監(jiān)聽器里翻幾千行找數(shù)據(jù)到底從哪來的。對于課設(shè)這種「交付時(shí)可能被抽查代碼」的場景類之間職責(zé)清晰比代碼寫得花哨重要得多。2.2 訂單簿用優(yōu)先隊(duì)列而不是普通 List撮合復(fù)雜度差了一個(gè)量級股票模擬系統(tǒng)里最核心的數(shù)據(jù)結(jié)構(gòu)是訂單簿。買盤按價(jià)格從高到低排隊(duì)賣盤按價(jià)格從低到高排隊(duì)同價(jià)按時(shí)間先后排隊(duì)。這個(gè)「按優(yōu)先級出隊(duì)」的語義天然是堆的活。用 ArrayList 存訂單每次撮合都要線性掃描找最優(yōu)價(jià)用 PriorityQueueJava 的優(yōu)先隊(duì)列底層是二叉堆插入和取最值都是 O(log n)。一次撮合要取買家最高價(jià)和賣家最低價(jià)各一次n 筆訂單下整體復(fù)雜度從 O(n2) 降到 O(n log n)。這個(gè)對比放在答辯里就是實(shí)打?qū)嵉募臃贮c(diǎn)。// 買盤價(jià)格高者優(yōu)先同價(jià)先到先得 QueueOrder buyOrders new PriorityQueue( (o1, o2) - o2.getPrice().compareTo(o1.getPrice()) ! 0 ? o2.getPrice().compareTo(o1.getPrice()) : o1.getTime().compareTo(o2.getTime()) ); // 賣盤價(jià)格低者優(yōu)先同價(jià)先到先得 QueueOrder sellOrders new PriorityQueue( (o1, o2) - o1.getPrice().compareTo(o2.getPrice()) ! 0 ? o1.getPrice().compareTo(o2.getPrice()) : o1.getTime().compareTo(o2.getTime()) );這里有兩個(gè)細(xì)節(jié)值得在代碼注釋里寫清楚。第一價(jià)格比較用 BigDecimal 的 compareTo 而不是 equals原因是 equals 還比較精度1.0 和 1.00 會(huì)被判為不同但它們在訂單簿里必須是一個(gè)價(jià)位。第二時(shí)間戳字段建議直接存 long 型的 System.currentTimeMillis()不需要在隊(duì)列里存 Date 對象做比較排序更快落庫時(shí)再轉(zhuǎn) DATETIME。隊(duì)列初始化后撮合循環(huán)就很簡潔同時(shí)看兩個(gè)隊(duì)頭能成交就處理剩余量不能成交就退出。這個(gè)邏輯放到第 4 章展開這里先記住一個(gè)原則——PriorityQueue 只在內(nèi)存里維護(hù)訂單順序訂單本身的持久化還在數(shù)據(jù)庫兩者不沖突。2.3 行情環(huán)存、持倉映射與自選列表三種典型數(shù)據(jù)結(jié)構(gòu)的使用位置訂單簿之外還有三處數(shù)據(jù)結(jié)構(gòu)選型容易在答辯時(shí)被追問。第一是行情快照。實(shí)時(shí)價(jià)格要被表格、K 線圖、持倉盈虧同時(shí)讀取用 ConcurrentHashMapString, Stock 存代碼到行情對象的映射讀多寫少比用 Hashtable 鎖整個(gè)表要合理。更新行情時(shí)只 put 單只股票其他股票的讀操作不會(huì)受阻塞。第二是 K 線滑動(dòng)窗口。只保留最近 N 根分鐘 K 線新數(shù)據(jù)進(jìn)來把最老的擠掉用數(shù)組實(shí)現(xiàn)的環(huán)形緩沖最合適。public class RingBuffer { private final double[] data; private int head; private int size; public RingBuffer(int capacity) { this.data new double[capacity]; } public void add(double value) { data[(head size) % data.length] value; if (size data.length) { head (head 1) % data.length; // 滿了就覆蓋最老的一格 } else { size; } } public double avg() { double sum 0; for (int i 0; i size; i) { sum data[(head i) % data.length]; } return size 0 ? 0 : sum / size; } }用它算最近 N 分鐘的均價(jià)復(fù)雜度是 O(1)不需要每次重新遍歷整個(gè)歷史表。如果把「均價(jià)高于實(shí)時(shí)價(jià)提示風(fēng)險(xiǎn)」做成一個(gè)小標(biāo)簽放在界面里這一處設(shè)計(jì)足夠支撐一個(gè)追問「你這里為什么不用 LinkedList」答案就是數(shù)組索引定位 O(1)、GC 壓力小且容量固定。第三是持倉映射。默認(rèn)用 HashMapString, Position 以股票代碼為鍵取持倉 O(1)。但如果自選股面板需要按漲跌幅排序展示不要在 HashMap 上強(qiáng)行排序而是排序時(shí)把 entrySet 拉出來丟進(jìn)臨時(shí)列表用 Collections.sort。原因是持倉數(shù)量通常是個(gè)位數(shù)到幾十個(gè)數(shù)量級排序成本可忽略沒必要為了排序引入一個(gè)始終維護(hù)有序性的 TreeMap 把寫操作拖慢。3. 數(shù)據(jù)庫表設(shè)計(jì)與 JDBC 落地把行情、訂單、持倉變成可對賬的賬3.1 建表腳本六張表和它們的字段語義模擬交易系統(tǒng)再怎么簡化「用戶、賬戶、股票、委托、成交、K 線」這六類數(shù)據(jù)一樣都不能少。賬戶和用戶拆開是為了以后擴(kuò)展「一個(gè)用戶多個(gè)模擬賬戶」留余地。委托表和成交表拆開是因?yàn)橐还P委托可能被拆成多筆成交這是真實(shí)撮合語義。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, available_balance DECIMAL(18,2) DEFAULT 1000000.00, frozen_balance DECIMAL(18,2) DEFAULT 0.00, CONSTRAINT fk_account_user FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_stock ( code VARCHAR(10) PRIMARY KEY, name VARCHAR(20) NOT NULL, last_price DECIMAL(10,2) NOT NULL, updated_at DATETIME ); CREATE TABLE t_trade_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, stock_code VARCHAR(10) NOT NULL, side TINYINT NOT NULL COMMENT 1買 2賣, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, status TINYINT NOT NULL COMMENT 0未成交 1部分成交 2全部成交 3已撤單, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_deal_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, stock_code VARCHAR(10) NOT NULL, deal_price DECIMAL(10,2) NOT NULL, deal_quantity INT NOT NULL, deal_amount DECIMAL(18,2) NOT NULL, deal_time DATETIME DEFAULT CURRENT_TIMESTAMP );DECIMAL(18,2) 是故意選的。模擬盤初始資金 100 萬單股價(jià)格按 10200 元算買一萬股剛好 200 萬DECIMAL(18,2) 足夠覆蓋且不會(huì)出現(xiàn) double 那種 0.10.2 不等于 0.3 的精度問題。t_trade_order 要建索引在 (user_id, status) 上因?yàn)椤覆槲业奈闯山晃小故亲铑l繁的查詢。t_deal_record 的 order_id 保留是為了演示時(shí)點(diǎn)開一筆委托能看到它被拆成了幾次成交。K 線表按分鐘落庫就夠了字段是 stock_code、kline_time、open、high、low、close、volume聯(lián)合主鍵 (stock_code, kline_time)。這筆數(shù)據(jù)不用實(shí)時(shí)寫每 N 秒由內(nèi)存里的環(huán)形緩沖聚合一次再批插入避免高頻行情把數(shù)據(jù)庫拖垮。3.2 JDBC 工具類、事務(wù)提交與連接參數(shù)先能跑再談優(yōu)化課設(shè)階段不需要上數(shù)據(jù)庫連接池一個(gè)簡單的 JDBC 工具類足夠。但連接參數(shù)里有兩個(gè)坑必須提前避開MySQL 8 的驅(qū)動(dòng)類名是 com.mysql.cj.jdbc.Driver不再是 com.mysql.jdbc.DriverserverTimezone 必須顯式指定否則會(huì)拋時(shí)區(qū)異常。public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/stock_sim ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement st, ResultSet rs) { if (rs ! null) try { rs.close(); } catch (SQLException ignored) {} if (st ! null) try { st.close(); } catch (SQLException ignored) {} if (conn ! null) try { conn.close(); } catch (SQLException ignored) {} } }寫入操作一定要手動(dòng)控制事務(wù)。最典型的場景是「買入成交」涉及三張表成交記錄插入、賬戶余額扣減、訂單狀態(tài)更新。任何一個(gè)失敗賬就對不上了。Connection conn DbUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 扣減賬戶余額 // 2. 更新訂單狀態(tài)為全部成交 // 3. 插入成交記錄 conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DbUtil.close(conn, null, null); }連接池的話題可以在答辯時(shí)提一句「生產(chǎn)環(huán)境會(huì)用連接池管理連接課設(shè)里為了讓你看清鏈路所以用 DriverManager」。但前提是你在配置文件里留了 db.properties 而不是把用戶名密碼寫死在常量類里。常見的做法是 Properties.load 讀取這里就不再貼完整代碼知道這個(gè)方向即可。3.3 拿到 zip 后從導(dǎo)入到第一條記錄落庫的完整路徑壓縮包解壓后先用一小時(shí)做環(huán)境核對而不是直接雙擊運(yùn)行腳本。我見過的翻車大半發(fā)生在「IDEA 里 Run 起來報(bào) ClassNotFoundException」或者「表結(jié)構(gòu)對不上」。按下面順序操作用 Navicat 或命令行執(zhí)行建庫腳本庫名用 stock_sim核對 SQL 腳本里的 CREATE DATABASE 語句是否包含字符集設(shè)置打開 db.properties把 jdbc.url、username、password 改為本機(jī)實(shí)際值在 IDEA 中打開項(xiàng)目確認(rèn) Project SDK 是 1.8 或更高Language level 與編譯版本一致在 Project Structure 的 Libraries 里把 mysql-connector-java 的 jar 包加進(jìn)來確認(rèn)它出現(xiàn)在最終 artifact 里找到帶 main 方法的入口類通常是 LoginFrame 或 MainFrame先跑一次跑通后做一次冒煙驗(yàn)證注冊一個(gè)用戶、查看初始資金是否為 100 萬、下買入單、去數(shù)據(jù)庫查 t_trade_order 是否多了一行。這條鏈路通了后面所有演示環(huán)節(jié)才談得上。4. 核心交易邏輯實(shí)現(xiàn)撮合、行情刷新與線程安全4.1 撮合引擎價(jià)格優(yōu)先、時(shí)間優(yōu)先的完整成交流程撮合引擎是整個(gè)系統(tǒng)里最有講頭的一段代碼。它的輸入是兩個(gè)優(yōu)先隊(duì)列——買盤隊(duì)列和賣盤隊(duì)列循環(huán)邏輯一句話概括買一價(jià)格 賣一價(jià)格就能成交成交量取兩者剩余量的較小值成交價(jià)取賣一價(jià)成交后把剩余量放回原隊(duì)列繼續(xù)比對。public ListDeal match(QueueOrder buys, QueueOrder sells) { ListDeal deals new ArrayList(); while (!buys.isEmpty() !sells.isEmpty()) { Order buy buys.peek(); Order sell sells.peek(); if (buy.getPrice().compareTo(sell.getPrice()) 0) { break; // 買方最高價(jià)夠不到賣方最低價(jià) } int quantity Math.min(buy.getRemaining(), sell.getRemaining()); Deal deal new Deal(); deal.setBuyOrderId(buy.getId()); deal.setSellOrderId(sell.getId()); deal.setStockCode(buy.getStockCode()); deal.setPrice(sell.getPrice()); // 成交價(jià)按賣一價(jià) deal.setQuantity(quantity); deals.add(deal); buy.setRemaining(buy.getRemaining() - quantity); sell.setRemaining(sell.getRemaining() - quantity); if (buy.getRemaining() 0) buys.poll(); if (sell.getRemaining() 0) sells.poll(); // 沒成交完的訂單留在隊(duì)里下一輪繼續(xù)比較 } return deals; }成交價(jià)按賣一價(jià)而不是買一價(jià)這是模擬盤常用的簡化規(guī)則真實(shí)交易所的撮合中間價(jià)規(guī)則更復(fù)雜。你在答辯時(shí)主動(dòng)說出「我這個(gè)模型做了簡化真實(shí)場景要考慮中間價(jià)和最小變動(dòng)價(jià)位」比等評委來問要主動(dòng)得多。要注意的是這里的 peek 不會(huì)移除訂單只有剩余量歸零才 poll。如果買單一筆很大可能連續(xù)吃掉好幾檔賣單每吃一檔就產(chǎn)生一條 Deal 記錄正好對應(yīng)數(shù)據(jù)庫里 t_deal_record 的一行。將這次循環(huán)產(chǎn)生的 List 批量入庫不要一條條 commit。4.2 行情生成與界面回顯SwingWorker 把價(jià)格刷新的結(jié)果送回 EDT行情數(shù)據(jù)在模擬系統(tǒng)里通常是一個(gè)后臺線程循環(huán)更新。這里最容易犯的錯(cuò)誤是直接在守護(hù)線程里調(diào)用 table.setValueAt——Swing 組件不是線程安全的任何 UI 操作必須在事件分發(fā)線程EDT上執(zhí)行。直接別處改 UI 的后果是界面偶發(fā)閃爍、半刷新甚至直接拋異常。class PriceFeedWorker extends SwingWorkerVoid, PriceTick { private final Random random new Random(); Override protected Void doInBackground() throws Exception { while (!isCancelled()) { Thread.sleep(1000); ListStock stocks stockDao.findAll(); ListPriceTick ticks new ArrayList(); for (Stock s : stocks) { double change (random.nextDouble() - 0.5) * 2 * 0.02; BigDecimal newPrice s.getLastPrice() .multiply(BigDecimal.valueOf(1 change)) .setScale(2, RoundingMode.HALF_UP); PriceTick tick new PriceTick(s.getCode(), newPrice); ticks.add(tick); latestPrices.put(s.getCode(), newPrice); } publish(ticks.toArray(new PriceTick[0])); } return null; } Override protected void process(ListPriceTick chunks) { // 此方法一定運(yùn)行在 EDT 上可以安全更新表格 for (PriceTick tick : chunks) { stockTableModel.updatePrice(tick.getCode(), tick.getPrice()); } } }SwingWorker 的 publish/process 機(jī)制就是設(shè)計(jì)出來解決這個(gè)問題的。doInBackground 在后臺線程跑循環(huán)process 匯總后切回 EDT 批量更新界面。注意 setScale(2, RoundingMode.HALF_UP) 保證價(jià)格只保留兩位小數(shù)。還有一點(diǎn)我踩過坑后臺線程里每秒查一次全表股票雖然不是大問題但明顯浪費(fèi)。更合理的做法是啟動(dòng)時(shí)把股票列表加載進(jìn)內(nèi)存的 ConcurrentHashMap后臺線程只更新內(nèi)存對象定時(shí)任務(wù)再批量把快照寫回?cái)?shù)據(jù)庫。數(shù)據(jù)庫是模擬系統(tǒng)的「存檔」不是行情數(shù)據(jù)的實(shí)時(shí)通道。4.3 賬戶與持倉的并發(fā)控制synchronized 鎖在哪個(gè)對象上模擬盤允許同時(shí)開啟多個(gè)窗口下單或者一個(gè)窗口里連續(xù)快速點(diǎn)擊買入。如果賬戶扣款和持倉變更不清理并發(fā)「余額扣了但持倉沒到賬」「賣出數(shù)量超過持倉」這些問題就會(huì)冒出來。這其實(shí)是 java 面試八股文里經(jīng)典的并發(fā)問題換了個(gè)皮膚。public synchronized TradeResult buy(Order order, Account account) { BigDecimal cost order.getPrice().multiply(BigDecimal.valueOf(order.getQuantity())); if (account.getAvailableBalance().compareTo(cost) 0) { return TradeResult.fail(可用余額不足需要 cost 實(shí)際 account.getAvailableBalance()); } account.setAvailableBalance(account.getAvailableBalance().subtract(cost)); Position position positions.get(order.getStockCode()); if (position null) { position new Position(order.getStockCode(), BigDecimal.ZERO, BigDecimal.ZERO); positions.put(order.getStockCode(), position); } position.setQuantity(position.getQuantity() order.getQuantity()); position.setCostAmount(position.getCostAmount().add(cost)); return TradeResult.ok(); }把整個(gè)買入動(dòng)作鎖在方法上聽著粗暴但在單機(jī)模擬盤這個(gè)場景下是正確的取舍。課設(shè)系統(tǒng)沒有微服務(wù)沒有分布式業(yè)務(wù)都在一個(gè) JVM 里方法級鎖足夠保證賬戶余額和持倉的一致性。如果你用鎖對象注意別鎖字符串常量兩個(gè)不同的 key 如果值相同會(huì)互相同步造成無意義的等待。余額不足的校驗(yàn)一定要放在扣款之前并且校驗(yàn)和扣款必須處于同一把鎖下。先校驗(yàn)再放鎖再扣款中間另一個(gè)線程插進(jìn)來余額就被透支了。這個(gè)「檢查和動(dòng)作之間要有原子性」的結(jié)論值得在答辯論證時(shí)反復(fù)強(qiáng)調(diào)。5. 翻車點(diǎn)避坑與排查五個(gè)常見問題的現(xiàn)象、原因和解決順序5.1 界面卡死登錄按鈕點(diǎn)下去整個(gè)窗口白屏現(xiàn)象點(diǎn)擊登錄后 JFrame 無響應(yīng)轉(zhuǎn)圈或者白屏過幾秒恢復(fù)嚴(yán)重時(shí)直接 NotResponding。原因登錄按鈕的監(jiān)聽器里直接執(zhí)行了數(shù)據(jù)庫查詢而鼠標(biāo)事件回調(diào)運(yùn)行在 EDT 上一條慢 SQL 或網(wǎng)絡(luò)抖動(dòng)就把界面線程堵住了。數(shù)據(jù)量一大這個(gè)卡頓就越明顯。解決所有可能耗時(shí)超過幾十毫秒的操作丟進(jìn) SwingWorker 或普通線程結(jié)果回來后再用 SwingUtilities.invokeLater 或 worker 的 process 刷新界面。登錄按鈕里只做「啟動(dòng)登錄任務(wù)」這一個(gè)動(dòng)作不要在里面查庫。把這條寫好評委的「你的界面為什么流暢」就有答案了。5.2 連不上數(shù)據(jù)庫ClassNotFoundException 或 CommunicationsException現(xiàn)象點(diǎn)登錄立刻報(bào)錯(cuò)控制臺出現(xiàn) java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver或者 Communications link failure。原因前者是驅(qū)動(dòng) jar 沒被正確打進(jìn)運(yùn)行環(huán)境后者八成是 MySQL 版本和 URL 參數(shù)不匹配。MySQL 5.7 用 com.mysql.jdbc.Driver 還能過MySQL 8 必須用 com.mysql.cj.jdbc.Driver 且 URL 里加 serverTimezone。解決先確認(rèn)用的驅(qū)動(dòng) jar 版本。IDE 里添加完 Library 后去 Artifacts 里勾選 include in project build很多 IDEA 項(xiàng)目里 Library 存在但打包時(shí)沒帶上。連接參數(shù)統(tǒng)一走 db.properties排查時(shí)只需要改一個(gè)文件不要在代碼里到處找硬編碼的 URL。5.3 賣出金額和買入成本對不上賬持倉成本越算越亂現(xiàn)象演示買入 100 股、賣出 100 股后賬戶總資產(chǎn)不等于初始資金差額正好是買入賣出的某個(gè)百分比。原因用 double 存價(jià)格和金額累加多次后精度丟失。比如 19.99 元的價(jià)格在 double 里實(shí)際是 19.989999999999998乘上股數(shù)放大后誤差變成幾毛錢再經(jīng)過幾筆交易就完全對不上。解決價(jià)格、金額、余額全部用 BigDecimal構(gòu)造時(shí)用字符串構(gòu)造器 new BigDecimal(19.99)不要用 new BigDecimal(19.99)。JDBC 讀取 DECIMAL 字段時(shí)用 getBigDecimal 拿到就是 BigDecimal不要轉(zhuǎn)成 double 再轉(zhuǎn)回來。這是所有對賬問題的根源。5.4 注冊成功后列表不刷新偶爾空指針現(xiàn)象新用戶注冊完成頁面上的用戶列表沒有新數(shù)據(jù)點(diǎn)擊某些行還會(huì)報(bào) NullPointerException。原因注冊成功后的數(shù)據(jù)加載仍然執(zhí)行在舊邏輯里拿的是注冊前的列表引用或者界面刷新和后臺數(shù)據(jù)加載的時(shí)序不對窗口還沒完成初始化數(shù)據(jù)觸達(dá) UI 時(shí)表格模型還是 null。解決數(shù)據(jù)變更后統(tǒng)一走「重新查詢→構(gòu)建新的 TableModel→setModel 刷新」這一條路徑??罩羔樑挪闀r(shí)先打印堆??词悄膫€(gè)對象為 null八成是領(lǐng)域?qū)ο鬀]有初始化例如 new ArrayList() 沒賦值就 add。不要在 getter 里返回 null集合屬性在聲明處直接初始化。5.5 快速雙擊下單持倉出現(xiàn)負(fù)數(shù)現(xiàn)象快速點(diǎn)擊買入按鈕明明余額足夠買 100 股最終持倉變成 -20 股或者余額變負(fù)數(shù)。原因兩次點(diǎn)擊事件都在余額校驗(yàn)完成前進(jìn)入了扣款邏輯或者賣出時(shí)沒有檢查當(dāng)前持倉量。數(shù)據(jù)庫層面沒有約束程序?qū)用嬉矝]有加鎖負(fù)庫存就出現(xiàn)了。解決下單入口加 synchronized 見 4.3 的寫法賣出在下單前先查 position.getQuantity()不足直接拒絕。另外可以在 t_account 表加 CHECK (available_balance 0)MySQL 8.0.16 以后會(huì)強(qiáng)制生效作為最后一層防線。程序鎖加數(shù)據(jù)庫約束雙層保護(hù)演示時(shí)再怎么點(diǎn)都不怕。6. 答辯前只練這三件事數(shù)據(jù)驗(yàn)證、演示節(jié)奏與加分說辭答辯演示前我會(huì)先跑一遍對賬 SQL確認(rèn)賬是平的。賬不平的模擬盤演示出去功能再多也圓不回來。對賬思路不復(fù)雜用戶的「當(dāng)前可用余額 各持倉市值 所有成交扣費(fèi)」應(yīng)該等于初始資金。SQL 寫成這樣SELECT u.username, a.available_balance, SUM(p.quantity * s.last_price) AS market_value, a.available_balance SUM(p.quantity * s.last_price) AS total_assets FROM t_user u JOIN t_account a ON u.id a.user_id LEFT JOIN t_position p ON p.user_id u.id LEFT JOIN t_stock s ON s.code p.stock_code GROUP BY u.id;如果每個(gè)用戶的 total_assets 都等于初始資金說明撮合、扣款、持倉更新是一致的。這條 SQL 跑通你演示時(shí)心里有底。演示節(jié)奏我習(xí)慣控制在十分鐘第一分鐘界面行情刷新動(dòng)起來讓評委看到價(jià)格在每秒跳動(dòng)中間五分鐘做一筆完整買入和一筆賣出強(qiáng)調(diào)撮合隊(duì)列的價(jià)格優(yōu)先和時(shí)間優(yōu)先最后兩分鐘打開數(shù)據(jù)庫對著 t_trade_order 和 t_deal_record 展示買賣數(shù)據(jù)是怎么落庫的。這比反復(fù)切窗口講功能更能體現(xiàn)數(shù)據(jù)結(jié)構(gòu)課程的訓(xùn)練痕跡。加分說辭記得準(zhǔn)備兩個(gè)。一是撮合隊(duì)列為什么用堆而不用數(shù)組線性掃描答「ArrayDeque 和 List 得 O(n) 掃描PriorityQueue 插入和取最值 O(log n)」二是 ConcurrentHashMap 和 Hashtable 的差別答「鎖粒度不同前者鎖桶后者鎖整表」。這類問題就是常見的 java 基礎(chǔ)面試題變體提前背熟被追問不慌。我自己的習(xí)慣是正式演示前至少完整過三遍流程中間故意制造一次「余額不足」的失敗彈窗證明異常路徑也處理了。這一手比單純展示順利路徑更讓評委相信系統(tǒng)是你自己寫的。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取