實戰(zhàn):從數(shù)據(jù)庫設(shè)計到事務(wù)一致性)
簡介這是一套面向Java Web初學(xué)者與課程設(shè)計者的采購管理系統(tǒng)完整源碼采用JSP技術(shù)搭建配合MySQL數(shù)據(jù)庫用于解決企業(yè)采購信息的管理問題適合作為畢業(yè)設(shè)計、課程大作業(yè)或進銷存類項目的參考模板。系統(tǒng)實現(xiàn)了用戶登錄、供應(yīng)商管理、材料管理三大核心模塊登錄環(huán)節(jié)對用戶名與密碼進行校驗并區(qū)分普通用戶與超級管理員的操作頁面供應(yīng)商模塊支持靈活添加與修改供貨商信息材料模塊則負責材料種類與庫存的維護。壓縮包共248個文件約22.71MB包含32個java源文件、30個jsp頁面、35個class編譯文件、63個jar依賴包以及xml配置、gif與png圖片、css樣式、properties配置等資源結(jié)構(gòu)完整便于直接導(dǎo)入運行與二次開發(fā)。目前已有2659人學(xué)習下載讀者可從中獲取分層清晰的Action、Service與實體類設(shè)計思路理解JSP與MySQL的整合方式并參考頁面跳轉(zhuǎn)與權(quán)限控制的實現(xiàn)細節(jié)快速完成自己的采購管理項目。1. Java 采購管理系統(tǒng)從課程設(shè)計到能跑起來的第一版很多 Java 課程設(shè)計案例源碼下載下來第一件事就是跑不起來數(shù)據(jù)庫連不上、依賴缺一半、SQL 腳本對不上實體類字段。采購管理系統(tǒng)這個題目在高校課程設(shè)計和初級面試里出現(xiàn)頻率極高原因很直接它同時覆蓋了面向?qū)ο缶幊?Java 的核心語法、數(shù)據(jù)庫增刪改查的完整鏈路、以及一個真實業(yè)務(wù)場景下的事務(wù)處理需求。你拿到一個「Java實現(xiàn)采購管理系統(tǒng)含數(shù)據(jù)庫.rar」里面大概率是 Eclipse 或 IDEA 工程加一份 SQL 文件能不能直接用于答辯或二次開發(fā)取決于你有沒有把數(shù)據(jù)層和業(yè)務(wù)層拆清楚。這篇文章面向三類人正在做數(shù)據(jù)庫課程設(shè)計需要交作品的學(xué)生、想拿采購系統(tǒng)當 Spring Boot 練手項目的初級開發(fā)者、以及面試前需要梳理 Java 怎么保證數(shù)據(jù)一致性這類八股文的求職者。我會按「先跑通最小閉環(huán)再補業(yè)務(wù)邏輯最后處理并發(fā)和事務(wù)」的順序把建庫建表、JDBC 連接、采購單增刪改查、庫存聯(lián)動、事務(wù)控制這幾個環(huán)節(jié)拆開講。你不需要有現(xiàn)成源碼跟著步驟能從零搭出一個可演示的版本。2. 數(shù)據(jù)庫設(shè)計與建表采購單、供應(yīng)商、庫存三張核心表怎么定2.1 先想清楚采購流程里有哪些實體采購管理系統(tǒng)的業(yè)務(wù)鏈條不復(fù)雜采購員選供應(yīng)商、下采購單、采購單包含多個商品明細、商品入庫后庫存增加。對應(yīng)到數(shù)據(jù)庫設(shè)計至少需要五張表供應(yīng)商表 supplier、商品表 product、采購單主表 purchase_order、采購單明細表 purchase_order_item、庫存表 inventory。很多課程設(shè)計案例源碼里把明細直接塞進主表用逗號分隔這種設(shè)計在查詢和統(tǒng)計時非常痛苦不建議模仿。主表和明細表拆開的好處是一張采購單可以包含任意數(shù)量的商品每個商品有獨立的單價和數(shù)量總金額通過聚合計算得出而不是存冗余字段。下面是我一般會用的建表 SQL字段類型按 MySQL 8.0 寫其他數(shù)據(jù)庫改一下自增語法即可。-- 供應(yīng)商表 CREATE TABLE supplier ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 供應(yīng)商名稱, contact VARCHAR(50) COMMENT 聯(lián)系人, phone VARCHAR(20) COMMENT 聯(lián)系電話, status TINYINT DEFAULT 1 COMMENT 1啟用 0停用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category VARCHAR(50), unit VARCHAR(10) COMMENT 計量單位, price DECIMAL(12,2) NOT NULL DEFAULT 0.00, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采購單主表 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 采購單號, supplier_id BIGINT NOT NULL, total_amount DECIMAL(14,2) DEFAULT 0.00, status TINYINT DEFAULT 0 COMMENT 0待審核 1已審核 2已入庫 3已取消, created_by VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_supplier (supplier_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 采購單明細表 CREATE TABLE purchase_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(12,2) NOT NULL, subtotal DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED, INDEX idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 庫存表 CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL UNIQUE, quantity INT NOT NULL DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;這段 SQL 里幾個參數(shù)值得說明。DECIMAL(12,2)用于金額絕不能用 FLOAT 或 DOUBLE否則累加會出現(xiàn)精度丟失這是血淚經(jīng)驗。subtotal用了生成列MySQL 5.7 以上支持好處是插入明細時不用手動算小計查詢時直接讀。order_no加了唯一索引防止重復(fù)提交產(chǎn)生相同單號。inventory表的product_id唯一約束保證一個商品只有一條庫存記錄更新時用INSERT ... ON DUPLICATE KEY UPDATE就能實現(xiàn) upsert。2.2 索引和狀態(tài)字段的設(shè)計取舍采購單的status字段用 TINYINT 而不是 ENUM原因是 ENUM 在后續(xù)加狀態(tài)時需要 ALTER TABLE而 TINYINT 配合代碼里的常量類更靈活。索引方面supplier_id和status各建一個單列索引如果查詢條件經(jīng)常是「某供應(yīng)商的待審核單」可以考慮建聯(lián)合索引idx_supplier_status (supplier_id, status)。但不要一上來就堆索引采購系統(tǒng)數(shù)據(jù)量通常不大索引過多反而拖慢插入。明細表的order_id索引是必須的因為查采購單詳情時一定會WHERE order_id ?。外鍵約束我一般不加原因是課程設(shè)計環(huán)境里經(jīng)常需要手動刪數(shù)據(jù)調(diào)試外鍵會讓刪除順序變得很麻煩。如果你要做嚴謹?shù)臄?shù)據(jù)庫課程設(shè)計可以加上FOREIGN KEY ... ON DELETE CASCADE但要在文檔里說明。注意建表時統(tǒng)一用 utf8mb4 字符集避免商品名稱里有生僻字或 emoji 時插入失敗。這個坑在微信數(shù)據(jù)庫解密相關(guān)的討論里也常被提到本質(zhì)都是字符集問題。3. JDBC 連接與 DAO 層不用框架怎么把增刪改查寫干凈3.1 數(shù)據(jù)庫連接池選型與配置課程設(shè)計里最常見的翻車場景是每個方法都DriverManager.getConnection()跑幾十次查詢就報連接超時。正確做法是用連接池。Java 生態(tài)里最輕量的選擇是 HikariCP一個 jar 包搞定配置也簡單。如果你用的是 Maven加依賴dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.1.0/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency連接池的初始化代碼我一般寫成一個工具類全局只初始化一次import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { private static final HikariDataSource DATA_SOURCE; static { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/purchase_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4); config.setUsername(root); config.setPassword(your_password); config.setMaximumPoolSize(10); // 最大連接數(shù) config.setMinimumIdle(2); // 最小空閑連接 config.setConnectionTimeout(30000); // 獲取連接超時 30s config.setIdleTimeout(600000); // 空閑回收 10min config.setMaxLifetime(1800000); // 連接最大存活 30min DATA_SOURCE new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return DATA_SOURCE.getConnection(); } public static DataSource getDataSource() { return DATA_SOURCE; } }參數(shù)說明maximumPoolSize設(shè) 10 對課程設(shè)計足夠生產(chǎn)環(huán)境按CPU核數(shù) * 2 磁盤數(shù)估算。serverTimezone必須設(shè)否則 MySQL 8 驅(qū)動會報時區(qū)錯誤。characterEncodingutf8mb4和建表字符集對應(yīng)。maxLifetime要比數(shù)據(jù)庫的wait_timeout小否則連接被服務(wù)端斷開后池里還留著死連接。3.2 采購單新增主表加明細的事務(wù)寫法采購單新增是整個系統(tǒng)里最需要保證數(shù)據(jù)一致性的操作。主表插一條、明細表插 N 條任何一步失敗都必須回滾。下面是一個不依賴 Spring 的純 JDBC 實現(xiàn)public class PurchaseOrderDao { public long createOrder(PurchaseOrder order, ListPurchaseOrderItem items) throws SQLException { String orderSql INSERT INTO purchase_order (order_no, supplier_id, total_amount, status, created_by) VALUES (?, ?, ?, ?, ?); String itemSql INSERT INTO purchase_order_item (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?); Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 開啟事務(wù) // 1. 插入主表 long orderId; try (PreparedStatement ps conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setLong(2, order.getSupplierId()); ps.setBigDecimal(3, order.getTotalAmount()); ps.setInt(4, order.getStatus()); ps.setString(5, order.getCreatedBy()); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { rs.next(); orderId rs.getLong(1); } } // 2. 批量插入明細 try (PreparedStatement ps conn.prepareStatement(itemSql)) { for (PurchaseOrderItem item : items) { ps.setLong(1, orderId); ps.setLong(2, item.getProductId()); ps.setInt(3, item.getQuantity()); ps.setBigDecimal(4, item.getUnitPrice()); ps.addBatch(); } ps.executeBatch(); } conn.commit(); return orderId; } catch (SQLException e) { if (conn ! null) { conn.rollback(); // 任何異常都回滾 } throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); // 歸還連接到池 } } } }邏輯說明setAutoCommit(false)關(guān)閉自動提交后主表和明細表的插入在同一個事務(wù)里。RETURN_GENERATED_KEYS拿到自增主鍵作為明細表的order_id。addBatch()加executeBatch()比循環(huán)單條插入快一個數(shù)量級明細多的時候尤其明顯。catch 塊里 rollback 是后悔藥沒有它一旦明細插入失敗主表會留下一條臟數(shù)據(jù)。finally 里恢復(fù) autoCommit 再 close是因為連接池歸還的連接必須回到初始狀態(tài)。參數(shù)方面total_amount建議在 Java 層算好再傳不要依賴數(shù)據(jù)庫生成列跨表計算。status初始值設(shè) 0 表示待審核后續(xù)審核和入庫分別改成 1 和 2。3.3 采購單查詢分頁和條件拼接查詢列表頁需要支持按供應(yīng)商、狀態(tài)、時間范圍過濾還要分頁。拼接 SQL 時用 StringBuilder 加參數(shù)列表不要用字符串拼接值否則有 SQL 注入風險public ListPurchaseOrder queryOrders(Long supplierId, Integer status, int page, int size) throws SQLException { StringBuilder sql new StringBuilder(SELECT * FROM purchase_order WHERE 11); ListObject params new ArrayList(); if (supplierId ! null) { sql.append( AND supplier_id ?); params.add(supplierId); } if (status ! null) { sql.append( AND status ?); params.add(status); } sql.append( ORDER BY created_at DESC LIMIT ? OFFSET ?); params.add(size); params.add((page - 1) * size); ListPurchaseOrder list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } return list; }WHERE 11是個慣用技巧讓后續(xù)每個條件都能以AND開頭不用判斷是不是第一個條件。LIMIT ? OFFSET ?是 MySQL 的分頁寫法Oracle 要用ROWNUM達夢數(shù)據(jù)庫兼容 MySQL 語法但分頁建議用LIMIT。setObject能自動處理 Long 和 Integer 的裝箱類型比逐個判斷類型再 setLong/setInt 簡潔。4. 庫存聯(lián)動與事務(wù)一致性入庫時怎么保證不超賣不丟單4.1 入庫操作的原子性設(shè)計采購單審核通過后要入庫入庫動作包含兩步更新采購單狀態(tài)為「已入庫」、增加對應(yīng)商品的庫存。這兩步必須在同一個事務(wù)里否則會出現(xiàn)單子狀態(tài)改了但庫存沒加的丟單情況。庫存更新用 upsert 語句INSERT INTO inventory (product_id, quantity) VALUES (?, ?) ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity);這條語句在 MySQL 里是原子的不需要先 SELECT 再 UPDATE。VALUES(quantity)引用的是 INSERT 部分傳入的值。如果你的數(shù)據(jù)庫不支持這種語法比如某些版本的 SQLite就要用INSERT OR REPLACE或者先查后改加行鎖。入庫的 Java 方法結(jié)構(gòu)public void stockIn(long orderId) throws SQLException { String updateOrder UPDATE purchase_order SET status 2 WHERE id ? AND status 1; String updateStock INSERT INTO inventory (product_id, quantity) VALUES (?, ?) ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity); String queryItems SELECT product_id, quantity FROM purchase_order_item WHERE order_id ?; Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 更新單狀態(tài)帶 status1 條件防止重復(fù)入庫 try (PreparedStatement ps conn.prepareStatement(updateOrder)) { ps.setLong(1, orderId); int affected ps.executeUpdate(); if (affected 0) { throw new SQLException(訂單狀態(tài)不允許入庫或已入庫); } } // 2. 查明細 Listlong[] items new ArrayList(); try (PreparedStatement ps conn.prepareStatement(queryItems)) { ps.setLong(1, orderId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { items.add(new long[]{rs.getLong(product_id), rs.getLong(quantity)}); } } } // 3. 逐條更新庫存 try (PreparedStatement ps conn.prepareStatement(updateStock)) { for (long[] item : items) { ps.setLong(1, item[0]); ps.setLong(2, item[1]); ps.addBatch(); } ps.executeBatch(); } conn.commit(); } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }關(guān)鍵點在UPDATE purchase_order SET status 2 WHERE id ? AND status 1。加上status 1條件后如果訂單已經(jīng)是狀態(tài) 2executeUpdate返回 0直接拋異常回滾。這比先查狀態(tài)再更新更可靠因為查和更新之間可能有并發(fā)。這就是 Java 怎么保證數(shù)據(jù)一致性的一個具體落地用數(shù)據(jù)庫的條件更新做樂觀鎖而不是在應(yīng)用層加 synchronized。4.2 并發(fā)場景下的庫存扣減與超賣防范采購系統(tǒng)一般不會像秒殺那樣高并發(fā)但多個采購員同時入庫同一商品時庫存更新仍可能出問題。ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity)本身是行級鎖不會丟更新。但如果你用的是「先 SELECT 當前庫存Java 里加完再 UPDATE」的寫法兩個線程同時讀到 10各加 5最終庫存可能是 15 而不是 20。防范方法有三種。第一種是用上面的原子 upsert最簡單。第二種是在 SELECT 時加FOR UPDATE行鎖但會降低并發(fā)。第三種是用版本號字段做樂觀鎖適合更新頻率低但沖突檢測要求高的場景。課程設(shè)計里第一種足夠面試時三種都要能說清楚。提示如果你的數(shù)據(jù)庫是 Oracle 或達夢upsert 語法不同。Oracle 用MERGE INTO達夢兼容 Oracle 模式時也用 MERGE。寫 DAO 層時可以把這段 SQL 抽成配置換數(shù)據(jù)庫時只改 SQL 不改 Java 代碼。5. 避坑與排查采購系統(tǒng)開發(fā)中最容易翻車的五個地方5.1 中文亂碼從建庫到 JDBC 要查四層現(xiàn)象商品名稱插入后顯示問號或者查詢條件帶中文查不到數(shù)據(jù)。原因通常出在四層中的某一層數(shù)據(jù)庫字符集、表字符集、JDBC URL 的 characterEncoding、以及 Java 源文件編碼。解決順序是從下往上查先SHOW VARIABLES LIKE character%確認數(shù)據(jù)庫是 utf8mb4再SHOW CREATE TABLE product確認表也是 utf8mb4然后檢查 JDBC URL 有沒有characterEncodingutf8mb4最后確認 IDE 的源文件編碼是 UTF-8。四層都對了就不會亂碼。5.2 連接池耗盡導(dǎo)致頁面卡死現(xiàn)象系統(tǒng)跑一段時間后所有查詢都超時日志里出現(xiàn)Connection is not available, request timed out。原因是有代碼拿了連接沒關(guān)閉常見于 try 塊里開了 Connection 但 catch 塊里忘了 close或者 ResultSet 和 PreparedStatement 沒關(guān)。解決方法是全部用 try-with-resourcesConnection 的獲取和關(guān)閉也放進 try-with-resources 里。如果已經(jīng)上線先把maximumPoolSize臨時調(diào)大爭取時間再定位泄漏點。5.3 金額計算用 double 導(dǎo)致對不上賬現(xiàn)象采購單明細小計加起來和主表總金額差幾分錢。原因是用了 double 或 float 做金額運算二進制浮點數(shù)無法精確表示 0.1 這類小數(shù)。解決方法是所有金額字段用BigDecimal數(shù)據(jù)庫用DECIMALJava 里new BigDecimal(0.1)而不是new BigDecimal(0.1)。加法用add()比較用compareTo()而不是equals()因為equals會比較精度。5.4 采購單號重復(fù)生成現(xiàn)象并發(fā)提交時出現(xiàn)兩條相同 order_no 的記錄或者插入時報唯一鍵沖突。原因是單號生成用了System.currentTimeMillis()或者簡單的日期加隨機數(shù)并發(fā)下會撞。解決方法是單號用「日期 數(shù)據(jù)庫序列 隨機后綴」組合或者直接用數(shù)據(jù)庫自增 ID 加前綴。更穩(wěn)妥的做法是單獨建一張序列表用UPDATE ... SET seq seq 1加行鎖取號。5.5 事務(wù)里混用了連接池的不同連接現(xiàn)象明明開了事務(wù)回滾卻不生效主表數(shù)據(jù)還在。原因是事務(wù)方法里某一步從連接池又拿了一個新連接兩個連接不在同一個事務(wù)里。解決方法是事務(wù)方法內(nèi)所有數(shù)據(jù)庫操作共用同一個 Connection 對象通過參數(shù)傳遞而不是每個 DAO 方法內(nèi)部各自DBUtil.getConnection()。如果用了 SpringTransactional能幫你管但純 JDBC 必須自己傳。6. 從課程設(shè)計到可演示系統(tǒng)三個提升完成度的技巧第一個技巧是給采購單加一個狀態(tài)流轉(zhuǎn)圖用枚舉類而不是散落的魔法數(shù)字。定義OrderStatus枚舉包含 PENDING、APPROVED、STOCKED、CANCELLED 四個值每個值帶 code 和描述。這樣代碼里寫OrderStatus.PENDING.getCode()比寫 0 可讀得多答辯時老師一看就知道你懂面向?qū)ο?。枚舉還可以加一個canTransitionTo(OrderStatus target)方法把狀態(tài)流轉(zhuǎn)規(guī)則收在枚舉里避免業(yè)務(wù)層到處寫 if-else。第二個技巧是寫一個簡單的控制臺測試類不依賴前端就能演示完整流程。很多課程設(shè)計的前端頁面是擺設(shè)答辯時點兩下就報錯。與其這樣不如寫一個Main類按順序調(diào)用創(chuàng)建供應(yīng)商、創(chuàng)建商品、創(chuàng)建采購單、審核、入庫、查詢庫存這幾個方法把關(guān)鍵輸出打印到控制臺。這樣即使前端沒做完核心功能也能演示。測試類里用assert或者簡單的 if 判斷驗證結(jié)果比如入庫后查庫存數(shù)量是否等于采購數(shù)量。第三個技巧是給關(guān)鍵操作加日志。不用引入 log4j 那么重用java.util.logging或者直接System.out.println加時間戳就行。日志要記錄操作類型、關(guān)鍵參數(shù)、執(zhí)行結(jié)果。比如創(chuàng)建采購單時打印單號和明細條數(shù)入庫時打印訂單 ID 和影響的庫存記錄數(shù)。出問題時這些日志就是黑匣子能快速定位是哪一步失敗。我一般會在 DAO 層的每個方法入口和出口各打一條雖然啰嗦但排查時省時間。最后一個習慣每次改完 SQL 或 DAO 代碼先跑一遍測試類確認主流程沒斷再去看前端。課程設(shè)計最怕的是改了一個字段名前端頁面全掛但你沒發(fā)現(xiàn)。先保核心鏈路再保外圍展示這個順序能讓你在截止日期前始終有一個能跑的東西。希望幫到你。本文還有配套的精品資源點擊獲取