# 下載解壓
tar -xzf apache-tomcat-9.0.x.tar.gz -C /opt/
ln -s /opt/apache-tomcat-9.0.x /opt/tomcat
# Web 應用目錄結構 (Servlet 3.0+ 無需 web.xml)
$CATALINA_HOME/webapps/banner/
├── WEB-INF/
│ ├── classes/ # 編譯後 .class 文件
│ ├── lib/ # mysql-connector-j-8.x.jar
│ └── web.xml (可選) # 只有需配置 Filter/Listener 時才需要
├── images/ # 靜態圖片資源
└── index.jsp # 測試頁面
CREATE DATABASE banner_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE banner_db;
CREATE TABLE ads (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
image_file VARCHAR(255) NOT NULL COMMENT '圖片相對路徑 /images/xxx.jpg',
target_url VARCHAR(500) NOT NULL COMMENT '點擊跳轉 URL',
customer_name VARCHAR(100) COMMENT '廣告主',
clicks_purchased INT UNSIGNED DEFAULT 0 COMMENT '購買點擊數',
clicks_remaining INT UNSIGNED DEFAULT 0 COMMENT '剩餘點擊數',
impressions INT UNSIGNED DEFAULT 0 COMMENT '展示次數',
weight TINYINT UNSIGNED DEFAULT 10 COMMENT '權重 (1-100)',
status ENUM('ACTIVE','PAUSED','EXPIRED') DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_weight (status, weight)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 測試數據
INSERT INTO ads (image_file, target_url, customer_name, clicks_purchased, clicks_remaining, weight) VALUES
('/images/ad1.jpg', 'https://example.com', 'Client A', 1000, 1000, 30),
('/images/ad2.jpg', 'https://shop.example.com', 'Client B', 500, 500, 20),
('/images/ad3.jpg', 'https://blog.example.com', 'Client C', 200, 200, 50);
# 下載 mysql-connector-j-8.2.0.jar (JDK 11+)
# 放入 $CATALINA_HOME/webapps/banner/WEB-INF/lib/
# 重啟 Tomcat
| 類別 | 職責 | 關鍵方法 | 複用性 |
|---|---|---|---|
Logger | 統一日誌輸出至文件 | logEntry(), logStart(), logEnd(), deleteLog() | ✅ 全專案通用 |
DBHandler | JDBC 封裝:連接、PreparedStatement、ResultSet 管理 | setQuery(), lookup(), executeUpdate(), insert(), close() | ✅ 任何 JDBC 專案 |
Banner | POJO:廣告元數據封裝 (id, imageFile, url, weight...) | Getter/Setter, toString() | 業務專用 |
BannerServlet | 核心控制器:初始化緩存、處理 image/link 請求、權重隨機、統計更新 | init(), service(), getRandomBanner(), increaseImpressions(), decreaseClicks() | 業務專用 |
public void init(ServletConfig config) throws ServletException {
super.init(config);
_banners = new HashMap<>();
_totalWeight = 0;
DBHandler db = new DBHandler("com.mysql.cj.jdbc.Driver",
"jdbc:mysql://localhost:3306/banner_db?useSSL=false",
"banner_user", "pass", logger);
db.setQueryString("SELECT * FROM ads WHERE status='ACTIVE'");
ResultSet rs = db.executeQuery();
int index = 0;
while (rs.next()) {
Banner b = new Banner();
b.setId(rs.getInt("id"));
b.setImageFile(rs.getString("image_file"));
b.setUrl(rs.getString("target_url"));
b.setWeight(rs.getInt("weight"));
b.setClicksRemaining(rs.getInt("clicks_remaining"));
// ... 其他欄位
_banners.put(index++, b);
_totalWeight += b.getWeight();
}
db.close();
}
public void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String type = req.getParameter("type");
HttpSession session = req.getSession(true);
if ("image".equals(type)) {
// 1. 加權隨機選取
Banner banner = getRandomBanner();
// 2. 存入 Session 供點擊時使用
session.setAttribute("currentBanner", banner);
// 3. 異步增加展示次數 (不阻塞響應)
new Thread(() -> increaseImpressions(banner.getId())).start();
// 4. 重定向至圖片資源 (或直接輸出圖片流)
resp.sendRedirect(req.getContextPath() + banner.getImageFile());
} else if ("link".equals(type)) {
// 1. 從 Session 取出對應廣告
Banner banner = (Banner) session.getAttribute("currentBanner");
if (banner != null) {
// 2. 異步減少剩餘點擊數
new Thread(() -> decreaseClicksRemaining(banner.getId())).start();
// 3. 重定向至目標 URL
resp.sendRedirect(banner.getUrl());
} else {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
}
private Banner getRandomBanner() {
int randomWeight = (int) (Math.random() * _totalWeight) + 1;
int cumulative = 0;
for (Banner b : _banners.values()) {
cumulative += b.getWeight();
if (randomWeight <= cumulative) return b;
}
return _banners.values().iterator().next(); // fallback
}
優化方向:預計算前綴和陣列 + 二分查找 O(log n),或使用 DiscreteDistribution (Apache Math)。
private void increaseImpressions(int bannerId) {
DBHandler db = new DBHandler(...);
db.setQueryString("UPDATE ads SET impressions = impressions + 1 WHERE id = ?");
db.executeUpdate(bannerId);
db.close();
reloadBanners(); // 重新載入 HashMap 保證權重/剩餘點擊實時
}
private void decreaseClicksRemaining(int bannerId) {
DBHandler db = new DBHandler(...);
db.setQueryString("UPDATE ads SET clicks_remaining = clicks_remaining - 1 WHERE id = ? AND clicks_remaining > 0");
db.executeUpdate(bannerId);
db.close();
reloadBanners();
}
<%@ page contentType="text/html;charset=UTF-8" %>
<html>
<body>
<h2>動態橫幅廣告演示</h2>
<!-- 圖片請求 -->
<a href="<%=request.getContextPath()%>/banner/servlet/BannerServlet?type=link">
<img src="<%=request.getContextPath()%>/banner/servlet/BannerServlet?type=image"
alt="Ad Banner" border="0" width="468" height="60"/>
</a>
<hr/>
<p>每次刷新頁面,橫幅圖片與鏈接均會根據權重隨機變化。</p>
</body>
</html>
| 坑點 | 後果 | 解決 |
|---|---|---|
| Servlet 單例多線程共享 HashMap 非線程安全 | 併發下數據錯亂、NPE | 用 ConcurrentHashMap 或 ReadWriteLock 保護 _banners |
| 每請求 new DBHandler() 導致連接風暴 | 資料庫連接耗盡、響應極慢 | 引入連接池、ThreadLocal 復用、或改用 Spring JdbcTemplate |
| Session 依賴導致集群 Session 不共享 | 多節點部署點擊丟失關聯 | Sticky Session / Redis Session / JWT 攜帶 bannerId |
| 權重總和溢出 (int 上限 21億) | 隨機算法失效 | 用 long 存儲 _totalWeight |
| 圖片直接輸出流未設 Content-Type/Content-Length | 瀏覽器無法正確渲染/緩存 | resp.setContentType("image/jpeg"); resp.setContentLength(len); |
這個經典的 Tomcat + MySQL + Servlet 專案,雖然技術棧歷經多代演進,但其**核心架構思想——職責分離、內存緩存熱數據、異步處理非關鍵寫入、加權隨機分發**——至今仍是廣告系統、推薦系統、A/B 測試平台的基因底色。掌握其演進邏輯,能讓你在現代雲原生架構中做出更有歷史厚度的技術決策。