開發(fā)實戰(zhàn):分層架構、連接池與事務并發(fā)控制)
簡介Java銀行管理系統(tǒng)是一份基于Java的綜合性課程實驗項目資源面向Java初學者及正在學習面向?qū)ο缶幊?、軟件工程的學生用于理解賬戶管理、存取款、轉(zhuǎn)賬、利息計算等銀行核心業(yè)務的實現(xiàn)方式。壓縮包共7個文件包含源碼java文件、編譯后的class文件以及Eclipse工程配置.project、.classpath、.prefs整體僅8KB適合作為小型教學案例快速導入IDE運行學習。目前已有763人學習下載。項目雖小但覆蓋了Account類的設計、存款取款、轉(zhuǎn)賬、余額查詢等典型模塊可幫助讀者掌握類結構、方法封裝、異常處理及基礎數(shù)據(jù)持久化思路若進一步擴展還可涉及并發(fā)控制、SQL注入防范等安全性議題。整體上是一份麻雀雖小五臟俱全的Java入門實踐參考既能鞏固課堂理論也能為后續(xù)開發(fā)更完整的業(yè)務系統(tǒng)打下基礎。1. Java銀行管理系統(tǒng)從學生項目到可落地的分層架構做Java銀行管理系統(tǒng)的人多半繞不開一個尷尬代碼跑通了演示完了交差了但心里清楚這系統(tǒng)拿不出手。我在某次接手一個模擬項目X的銀行系統(tǒng)時就遇到過這種狀態(tài)——登錄、開戶、存取款都能跑一進并發(fā)測試就翻車余額對不上連接池直接打滿。后來花了兩周重構整個工程結構才把常見學生項目的“單類打天下”改成真正能維護的分層架構。這篇筆記就是用那次重構的視角來拆解從工程骨架、數(shù)據(jù)庫設計到鑒權、事務和排錯按落地順序走一遍。你可能是剛?cè)腴T的初學者也可能是準備做課程設計的在校生這套思路能幫你把“能演示的項目”升級成“經(jīng)得起問的項目”。2. 工程骨架與數(shù)據(jù)庫設計先說清楚表和連接池怎么定2.1 三層架構職責劃分dao、service、web各自管什么常見的翻車寫法是把JDBC代碼、業(yè)務判斷、Servlet請求處理全塞進一個類看起來熱鬧后續(xù)每個功能都要改這一個類。我在某高校項目里見過一個核心類超過三千行的版本加個字段要滾動半天。做這個銀行管理系統(tǒng)時我用的標準三層web層只做參數(shù)接收、封裝請求對象、調(diào)用service、響應結果service層負責業(yè)務規(guī)則比如余額是否充足、賬戶是否凍結、轉(zhuǎn)賬是否在同一個事務dao層只做SQL的增刪改查不寫if else邏輯這樣分層帶來的直接好處是session校驗、數(shù)據(jù)庫操作、業(yè)務判斷可以分別測試后面換數(shù)據(jù)庫或者改頁面時不用動業(yè)務代碼。Controller里出現(xiàn)的SQL語句本身就是個設計味道很重的信號。2.2 建表SQL與字段陷阱賬戶表、客戶表、流水表怎么設計先把三張核心表和字段寫下來這是整個系統(tǒng)里最需要先想清楚的部分后續(xù)的查詢性能、對賬邏輯都在這一步?jīng)Q定。-- 客戶表 CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, id_card VARCHAR(18) NOT NULL UNIQUE COMMENT 身份證號邏輯唯一, name VARCHAR(32) NOT NULL, phone VARCHAR(11) DEFAULT NULL, address VARCHAR(128) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 賬戶表 CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(19) NOT NULL UNIQUE COMMENT 卡號長度要預留, customer_id BIGINT NOT NULL, balance DECIMAL(14,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-凍結, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_account_customer FOREIGN KEY (customer_id) REFERENCES customer(id), KEY idx_customer_id (customer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 交易流水表 CREATE TABLE transaction_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trans_no VARCHAR(32) NOT NULL UNIQUE COMMENT 流水號格式見下, account_id BIGINT NOT NULL, trans_type TINYINT NOT NULL COMMENT 1-存款 2-取款 3-轉(zhuǎn)賬, amount DECIMAL(14,2) NOT NULL, balance_after DECIMAL(14,2) NOT NULL COMMENT 交易后余額對賬用, target_account_id BIGINT DEFAULT NULL COMMENT 轉(zhuǎn)賬目標賬戶, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_account_created (account_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段設計上有幾個地方要特別注意都是踩過坑才改出來的余額字段必須用DECIMAL不能用double或float。二進制浮點數(shù)存金額會出現(xiàn)0.10.2不等于0.3的經(jīng)典問題銀行系統(tǒng)一旦出現(xiàn)分位對不上賬等于直接報廢。account_no直接用19位字符串而不是自增id。因為卡號可能以特定數(shù)字開頭比如銀行卡常見的62開頭自增id無法滿足這種業(yè)務前綴。同時它作為業(yè)務維度唯一標識和數(shù)據(jù)庫主鍵解耦。transaction_log里必須保存balance_after這個字段是交易后余額的快照。后續(xù)排查問題時如果賬戶余額和流水對不上直接按時間順序重放流水就能定位是哪一筆出了問題。2.3 JDBC連接池與配置參數(shù)Druid連接池參數(shù)怎么設很多初學者用DriverManager.getConnection()每次請求都新建連接幾百個并發(fā)就能把數(shù)據(jù)庫連接打滿。我用的是阿里的Druid連接池配置如下# druid.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/bank_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai usernamebank_user passwordbank_pass_123 initialSize5 maxActive30 minIdle5 maxWait60000 validationQuerySELECT 1 testWhileIdletrue timeBetweenEvictionRunsMillis60000關鍵參數(shù)說明initialSize5啟動時預建5個連接避免第一個請求來臨時建連讓首次訪問慢半拍maxActive30最大活躍連接數(shù)超過這個數(shù)會排隊等待。設太小容易打滿設太大數(shù)據(jù)庫本身扛不住30對中小項目夠用maxWait60000拿不到連接時的最大等待時間超過就拋異常避免線程無限掛死validationQuerySELECT 1每次從池里拿連接前先驗證可用性防止拿到已經(jīng)被數(shù)據(jù)庫斷開的死連接連接池的獲取和歸還必須配套。我用一個工具類封裝DAO層拿完連接后必須關閉連接但關閉的是歸還連接池而不是真正斷開這是連接池的實現(xiàn)機制。有的同學在finally塊里忘記調(diào)用close方法跑一個晚上連接池就爆了。數(shù)據(jù)源的初始化位置我一般放在ServletContextListener里做應用啟動時初始化一次比每次請求時new DataSource()要合理得多。2.4 配置文件加載失敗調(diào)試classpath與路徑問題第一次跑這個配置時最容易遇到的就是Properties.load()讀不到文件。常見原因是配置文件放在了src/main/java目錄下和Java源碼混在一起編譯后文件在target/classes里能找到但在IDE里看到的是源碼目錄。正確做法是放在src/main/resources目錄下然后用如下方式加載InputStream in DruidUtil.class.getClassLoader().getResourceAsStream(druid.properties);這里有個細節(jié)getResourceAsStream返回null時Properties.load會直接拋NullPointerException而且報錯信息里根本不說是文件找不到。排查方法是先判斷in是否為null打印當前classpath路徑看一眼。我遇到過在某IDE里運行正常、打成war包后就報錯的情況多半是打包時漏了配置文件。3. 登錄鑒權與角色權限Session怎么管、密碼怎么存3.1 MD5加鹽與迭代哈希密碼不能明文存很多初學者在數(shù)據(jù)庫里直接存明文密碼登錄時拿輸入值和數(shù)據(jù)庫比對。這在學習項目里能用一旦被拖庫所有賬號全暴露。我在重構時用加鹽MD5SHA-256在密碼存儲和哈希速度上的表現(xiàn)差異較小MD5加鹽配合多次迭代足夠應付課程設計和中低安全需求的場景。public class PasswordUtil { private static final String SALT bank_sys_static_salt; public static String hashPassword(String rawPassword) { String saltPassword rawPassword SALT; // 迭代3次降低暴力破解速度 String hashed saltPassword; for (int i 0; i 3; i) { hashed md5(hashed); } return hashed; } private static String md5(String input) { // 標準MessageDigest實現(xiàn)返回32位十六進制字符串 } }這里說明一下設計取舍。靜態(tài)鹽SALT寫死在代碼里好處是用戶表里不需要單獨存鹽字段查詢簡單壞處是如果源碼泄漏所有用戶的哈希值都可以被預先計算。真實生產(chǎn)環(huán)境會給每個用戶生成隨機鹽存在庫里。對于課程設計和中小系統(tǒng)靜態(tài)鹽加多次迭代能在易用性和安全性之間取平衡。3.2 Session管理與登錄攔截器會話過期和權限失效登錄成功后把用戶信息放Session再寫一個攔截器對所有受保護頁面做過濾。我用的是Servlet Filter不需要額外引入框架也能達到目標public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(false); if (session null || session.getAttribute(currentUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }注意點有三個一是req.getSession(false)和req.getSession()的區(qū)別。帶false表示拿不到就不創(chuàng)建避免惡意請求大量觸發(fā)session創(chuàng)建。不帶參數(shù)的寫法是如果沒有session就新建一個配合攔截器邏輯判斷時會產(chǎn)生問題。二是過濾器的映射范圍。在web.xml里把需要登錄才能訪問的路徑全部包進去比如/account/、/transaction/。千萬別用/*全包否則login.jsp本身也會被攔截形成死循環(huán)。三是Session超時web.xml里配置session-config session-timeout30/session-timeout /session-config30分鐘沒操作自動失效。調(diào)試時這個超時會經(jīng)常打斷流程可以先把時間調(diào)長一點功能跑通后再改回來。3.3 角色權限柜員與管理員的操作邊界銀行系統(tǒng)里角色通常分兩種柜員處理日常業(yè)務管理員查流水、凍結賬戶。實現(xiàn)方式是登錄后在Session里存role字段在業(yè)務方法里用權限校驗工具類判斷public class PermissionUtil { public static void checkAdmin(HttpSession session) throws PermissionDeniedException { Integer role (Integer) session.getAttribute(currentRole); if (role null || role ! Roles.ADMIN) { throw new PermissionDeniedException(需要管理員權限); } } }以前權限判斷最容易出問題的點在頁面按鈕顯隱上。前端隱藏一個按鈕不等于后端安全直接構造請求URL就能繞過頁面調(diào)接口。后來我把權限校驗下沉到Service層每個關鍵操作先校驗再執(zhí)行頁面上的隱藏只是體驗優(yōu)化不是安全控制。這是從學生思維到工程思維的一個關鍵轉(zhuǎn)變。4. 存款取款與轉(zhuǎn)賬的業(yè)務閉環(huán)事務邊界和并發(fā)扣款4.1 存款業(yè)務事務邊界和余額校驗存取款的業(yè)務邏輯看起來簡單實際上有四個環(huán)節(jié)都要做對事務開啟、余額校驗、更新賬戶、寫流水。缺一個系統(tǒng)都會在對賬時暴露問題。public void deposit(Long accountId, BigDecimal amount) { String sql UPDATE account SET balance balance ? WHERE id ? AND status 1; // 執(zhí)行更新返回受影響行數(shù) // 如果行數(shù)為0說明賬戶不存在或已凍結 }這里有個細節(jié)把balance從庫里查出來然后在Java里加好再寫回存在并發(fā)覆蓋問題。兩條線程同時讀到100都加上50后提交的會把先提交的覆蓋。所以我用UPDATE的原子操作直接在SQL語句層面完成加減數(shù)據(jù)庫的行鎖會保證同一時間只有一筆操作能成功修改這個賬戶。存款流程結束后必須寫流水。流水表里存balance_after方便后續(xù)對賬。寫流水和更新余額這兩個動作必須在同一個事務里否則會出現(xiàn)余額變了但流水沒記錄的臟數(shù)據(jù)。4.2 轉(zhuǎn)賬跨賬戶事務和并發(fā)控制轉(zhuǎn)賬比存款復雜在涉及兩個賬戶事務跨度更大。經(jīng)典轉(zhuǎn)賬代碼長這樣Transactional(rollbackFor Exception.class) public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) { // 1. 扣減轉(zhuǎn)出賬戶 int fromResult accountDao.deductBalance(fromAccountId, amount); if (fromResult 0) { throw new BusinessException(余額不足或賬戶異常); } // 2. 增加轉(zhuǎn)入賬戶 accountDao.increaseBalance(toAccountId, amount); // 3. 記錄轉(zhuǎn)出流水 accountDao.insertTransLog(fromAccountId, 3, amount.negate(), fromAccountId); // 4. 記錄轉(zhuǎn)入流水 accountDao.insertTransLog(toAccountId, 3, amount, fromAccountId); }最隱蔽的問題是事務什么時候真正提交。在Spring管理下方法執(zhí)行完才提交如果步驟2或3拋異常步驟1的扣減會回滾不會出現(xiàn)扣了錢對方?jīng)]收到的情況。但如果你用的是原生JDBC沒有事務控制步驟1執(zhí)行完提交了步驟2失敗資金憑空消失。另外accountDao.deductBalance的SQL里必須帶balance ?條件UPDATE account SET balance balance - ? WHERE id ? AND balance ? AND status 1這樣做的目的是讓余額判斷和扣減操作合在一個原子SQL里避免“查出來判斷夠了再更新”兩步之間的并發(fā)窗口。數(shù)據(jù)庫層面行鎖同時只能一個事務更新這條記錄后面的更新發(fā)現(xiàn)余額不夠會直接返回0行代碼里據(jù)此拋出異常。4.3 流水號生成時間戳隨機數(shù)的實現(xiàn)方案流水號需要業(yè)務可讀、盡量唯一方便排查問題。我用的方案是String transNo T System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000));這個方案在單機低并發(fā)下夠用但注意Random生成的4位數(shù)在高并發(fā)下可能碰撞。后來我改成用數(shù)據(jù)庫自增id做前綴拼接// 插入流水記錄拿到自增id Long id transLogDao.insertAndReturnId(...); String transNo T id System.currentTimeMillis();這樣流水號一定唯一因為數(shù)據(jù)庫自增id不會重復。這個方案的好處還在于如果業(yè)務上要做冪等控制可以直接用這個流水號作為去重鍵。4.4 查詢優(yōu)化交易流水分頁與索引使用交易流水表數(shù)據(jù)量增長很快如果不在account_id和created_at上建索引按用戶查歷史流水會全表掃描。前面建表SQL里已經(jīng)加了idx_account_created組合索引查詢時用SELECT * FROM transaction_log WHERE account_id ? ORDER BY created_at DESC LIMIT ?, ?這里有一件容易忽視的事分頁頁數(shù)過大會導致深度分頁性能下降。LIMIT 100000, 20這樣的寫法數(shù)據(jù)庫要先掃描前面十萬條記錄再丟棄。當數(shù)據(jù)量真的大起來時我一般會改成基于上次查詢的最后一筆流水id做游標分頁也就是WHERE id ? ORDER BY id DESC LIMIT 20但課程設計階段用LIMIT offset的方式夠用。5. 常見問題與避坑連接泄漏、浮點精度和并發(fā)覆蓋5.1 連接池連接被耗盡getConnection后忘了關閉現(xiàn)象系統(tǒng)運行一段時間后所有請求都卡在拿連接上日志報Connection is not available, request timed out。原因DA0層代碼里使用了Connection con JDBCUtil.getConnection()但finally塊里沒有調(diào)用con.close()或者寫成了con.close()后System.exit(0)。連接沒有歸還連接池池里的連接被耗盡。最典型的場景是異常路徑上沒有close比如SQLException拋出前忘記釋放。解決用try-with-resources語法連接相關的語句全包在里面不需要手動finally。Java 7之后Connection實現(xiàn)了AutoCloseable接口這樣寫既美觀又不會漏try (Connection con JDBCUtil.getConnection(); PreparedStatement ps con.prepareStatement(sql)) { ps.setLong(1, id); ResultSet rs ps.executeQuery(); } catch (SQLException e) { log.error(query failed, e); }注意ResultSet不能放try里因為它依賴StatementStatement關了它就不能用了。只在try內(nèi)部使用不手動關閉。5.2 余額對不上賬用了浮點類型或事務缺commit現(xiàn)象某個賬戶存取款幾次后余額和流水表重算出來的金額不一樣差的金額是幾分錢。原因在Java代碼里用了double類型傳金額數(shù)據(jù)庫字段也建成了double。浮點數(shù)在二進制里無法精確表示0.1、0.2這樣的十進制小數(shù)導致計算結果出現(xiàn)0.30000000000000004。另一個原因是手動事務里執(zhí)行了connection.commit()只在成功路徑上異常路徑?jīng)]有回滾。解決Java側(cè)所有金額相關變量用BigDecimal數(shù)據(jù)庫字段在首次建表時就固定為DECIMAL(14,2)。BigDecimal構造時用字符串構造器new BigDecimal(19.99)不要用new BigDecimal(19.99)后者依然有精度問題。事務上把commit和rollback統(tǒng)一放finally塊里做判斷try { // 業(yè)務操作 con.commit(); } catch (SQLException e) { con.rollback(); } finally { con.close(); }5.3 時間精度丟失created_at默認值忽略毫秒現(xiàn)象同一秒內(nèi)多筆交易流水表里created_at完全一樣導致查不出先后順序?qū)~時無法分辨哪筆在前。原因數(shù)據(jù)庫字段默認值是CURRENT_TIMESTAMP但它默認精度到秒毫秒被舍掉。在高頻交易場景下同一秒內(nèi)會有多筆記錄。解決建表時把時間字段定義為DATETIME(3)配合DEFAULT CURRENT_TIMESTAMP(3)。Java側(cè)用LocalDateTime.now()傳入時注意類型匹配。如果已經(jīng)建好了表用ALTER TABLE transaction_log MODIFY created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3)來改。5.4 并發(fā)扣款覆蓋余額判斷和扣減分離導致超扣現(xiàn)象兩個線程同時讀取余額100各自判斷可以扣80同時提交后余額變成20而不是預期的正確值甚至變成負數(shù)。原因查詢余額和更新余額是兩個獨立步驟沒有放在同一個數(shù)據(jù)庫事務里也沒有使用行鎖保護。兩個請求并發(fā)執(zhí)行時同時讀到舊值然后分別寫回先寫回的被后寫回覆蓋。解決用UPDATE ... WHERE balance ?原子操作替代兩步邏輯。數(shù)據(jù)庫行級鎖會讓第二個UPDATE等待第一個事務提交后執(zhí)行此時余額已不滿足條件返回0行業(yè)務代碼拋異常。這是并發(fā)扣款場景的標準做法。5.5 Session失效但頁面還顯示登錄態(tài)現(xiàn)象用戶明明已經(jīng)退出登錄按瀏覽器后退按鈕還能看到之前頁面數(shù)據(jù)或者session超時后直接操作某個功能按鈕報NullPointerException。原因前端頁面緩存了動態(tài)數(shù)據(jù)沒有在請求時重新校驗session。瀏覽器后退時可能從本地cache加載頁面不發(fā)起請求。后端從session取用戶信息時沒有判斷null。解決在web.xml里給受保護頁面配置禁止瀏覽器緩存的響應頭filter filter-nameNoCacheFilter/filter-name /filter !-- 對應過濾器里設置 -- response.setHeader(Cache-Control, no-cache, no-store, must-revalidate);后端取Session屬性時統(tǒng)一用工具類如果取出來是null直接跳轉(zhuǎn)到登錄頁而不是讓NullPointerException冒泡到用戶界面。調(diào)試session問題時先在瀏覽器開發(fā)者工具的Network面板里看響應頭有沒有清晰的緩存控制標識。6. 收尾驗證用單元測試和壓測腳本給項目兜底6.1 Service層單元測試用Mock數(shù)據(jù)驗證事務回滾功能代碼寫完只代表邏輯存在不代表邏輯正確。我給轉(zhuǎn)賬寫了一個JUnit單元測試。沒有測試框架的可以用一個main方法調(diào)Service邏輯測試建議用JUnit加H2內(nèi)存庫做集成驗證。public class TransferServiceTest { private TransferService transferService new TransferService(); Test public void testTransfer_InsufficientBalance() { Long fromId 1L; Long toId 2L; BigDecimal amount new BigDecimal(9999999.00); // 斷言拋出余額不足異常 assertThrows(BusinessException.class, () - { transferService.transfer(fromId, toId, amount); }); // 斷言轉(zhuǎn)出賬戶余額沒變 BigDecimal fromBalance accountDao.getBalance(fromId); assertEquals(new BigDecimal(1000.00), fromBalance); } }這個測試驗證的是最核心的邊界轉(zhuǎn)賬失敗時轉(zhuǎn)出賬戶余額不能有動靜。如果斷言通過事務回滾是生效的。我每次改business邏輯都先跑一遍這些測試再手動點頁面效率高很多。6.2 手工壓測驗證并發(fā)安全關鍵的并發(fā)場景模擬可以用一個簡單的腳本程序來驗證前面提到的并發(fā)扣款問題。用Java的線程池模擬多用戶同時取款public class ConcurrentWithdrawTest { public static void main(String[] args) throws Exception { int threadCount 10; ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch start new CountDownLatch(1); AccountDao accountDao new AccountDao(); for (int i 0; i threadCount; i) { pool.submit(() - { try { start.await(); accountDao.withdraw(1L, new BigDecimal(50.00)); } catch (Exception e) { System.out.println(失敗: e.getMessage()); } }); } start.countDown(); pool.shutdown(); } }如果賬戶初始余額10010個線程各取50正確結果應該是最多2個成功其余全部失敗。跑完后查詢數(shù)據(jù)庫余額確認是0而不是負數(shù)說明原子更新SQL生效了。我通常會把并發(fā)數(shù)調(diào)到線程池峰值再調(diào)大一倍看數(shù)據(jù)庫連接池是否會撐爆。跑一次這個測試比自己改半天代碼防并發(fā)更直觀。6.3 收尾的驗證清單最后我有一套固定流程先把數(shù)據(jù)庫重置成初始數(shù)據(jù)啟動服務走一遍開戶、存款、取款、轉(zhuǎn)賬、查詢流水全流程然后把并發(fā)線程調(diào)大再跑一遍確認余額不出負數(shù)、流水不丟不重最后把連接池的maxActive調(diào)到2模擬資源緊張環(huán)境看系統(tǒng)是否會拋出可讀的異常而不是長時間掛死。這套流程走完我才有底氣把項目交付出去。之前的項目在一次演示時當場翻車——轉(zhuǎn)賬后余額憑空少了50塊錢現(xiàn)場改代碼的狼狽樣子現(xiàn)在還記得。從那以后每次做銀行類項目我都強制走一遍并發(fā)測試和事務回滾驗證別指望手動點多幾個按鈕就能覆蓋邊界條件。希望這次拆解對你有幫助把該繞的坑提前繞過去。本文還有配套的精品資源點擊獲取