求全鏈路拆解:從Cookie會(huì)話到Servlet與數(shù)據(jù)庫(kù)分頁(yè))
簡(jiǎn)介面向Java Web學(xué)習(xí)者的個(gè)人微博應(yīng)用項(xiàng)目圍繞會(huì)員管理、個(gè)人中心、消息發(fā)布等核心模塊系統(tǒng)展示W(wǎng)eb原理與前后端開發(fā)全流程適合課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)及Servlet/JSP初學(xué)者參考是一次完整的Web應(yīng)用實(shí)踐。壓縮包內(nèi)共71個(gè)文件以22個(gè)Java源碼、24個(gè)class編譯文件、5個(gè)JSP頁(yè)面為主輔以XML配置、CSS樣式、Jar依賴及數(shù)據(jù)庫(kù)文件等整體僅3.33MB包含Eclipse工程配置文件導(dǎo)入后即可運(yùn)行調(diào)試。目前已有223人學(xué)習(xí)。借助完整的工程目錄讀者可對(duì)照學(xué)習(xí)用戶注冊(cè)登錄、數(shù)據(jù)持久化、接口設(shè)計(jì)、實(shí)時(shí)推送等核心知識(shí)還可參考代碼中的權(quán)限管理、單元測(cè)試與搜索思路是一份幫助串聯(lián)Web應(yīng)用前后端技術(shù)的實(shí)用案例。1. 前端選 Java Web 方向后第一次把瀏覽器請(qǐng)求打到后端時(shí)最先撞上的不是代碼 bug而是一個(gè)反直覺(jué)的事實(shí)HTTP 本身完全“健忘”你剛注冊(cè)完賬號(hào)刷新一次頁(yè)面卻發(fā)現(xiàn)時(shí)間線里出現(xiàn)了別人發(fā)的微博而且頁(yè)面右上角還掛著你自己的頭像。瀏覽器明明只是往服務(wù)器發(fā)了一個(gè)普通的 GET 請(qǐng)求服務(wù)器是怎么在“沒(méi)有記憶”的 HTTP 協(xié)議上認(rèn)出你、知道你關(guān)注了誰(shuí)、該給你推什么內(nèi)容的答案不是某個(gè)魔法字段而是 Cookie、Session、數(shù)據(jù)庫(kù)查詢、Servlet 調(diào)度這四件事串成的一條完整鏈路。搞不懂這條鏈路前端寫的是“頁(yè)面死不死”后端寫的則是“登錄態(tài)丟不丟、數(shù)據(jù)亂不亂、連接池崩不崩”。個(gè)人微博應(yīng)用恰好是把這些機(jī)制全部走一遍的最小完整案例注冊(cè)登錄涉及會(huì)話管理發(fā)微博涉及數(shù)據(jù)寫入時(shí)間線涉及查詢與排序異步刷新涉及 JSON 序列化。把這套機(jī)制拆開你會(huì)看到一個(gè)請(qǐng)求從瀏覽器到數(shù)據(jù)庫(kù)再回到瀏覽器的完整旅程——這也是為什么值得專門寫一篇文章把它講透。2. 一次微博刷新背后從 TCP 連接、線程池到 Servlet 的完整調(diào)度鏈2.1 先看清請(qǐng)求的“底層路徑”不是 url 直連 Java 方法很多初學(xué)者以為瀏覽器地址欄里輸入 index.jsp服務(wù)器就會(huì)“直接執(zhí)行那個(gè)文件”。實(shí)際上請(qǐng)求要先經(jīng)過(guò)一層網(wǎng)絡(luò)協(xié)議棧、一個(gè)監(jiān)聽端口、一個(gè)線程池最后才由 Servlet 容器交到你的代碼手里。我一般會(huì)這么理解Servlet 容器例如 Tomcat啟動(dòng)后會(huì)在某個(gè)端口上持續(xù) accept TCP 連接。瀏覽器發(fā)來(lái)的 GET /index.jsp HTTP/1.1 請(qǐng)求是一段純文本容器把這段文本解析成請(qǐng)求行、請(qǐng)求頭、請(qǐng)求體封裝成一個(gè) HttpServletRequest 對(duì)象然后從線程池里撈出一個(gè)空閑線程在這個(gè)線程上執(zhí)行匹配到的組件代碼。這里有個(gè)隱蔽但關(guān)鍵的點(diǎn)容器處理請(qǐng)求的工作線程是復(fù)用的。你寫的代碼里如果讓線程 sleep或者在一個(gè)請(qǐng)求里反復(fù)創(chuàng)建數(shù)據(jù)庫(kù)連接然后不關(guān)消耗的不是“這一個(gè)請(qǐng)求”而是整個(gè)應(yīng)用的吞吐能力。后面避坑環(huán)節(jié)里連接耗盡的問(wèn)題根源就在這里。2.2 URL 映射與 Filter 鏈誰(shuí)先碰這個(gè)請(qǐng)求請(qǐng)求進(jìn)入容器后會(huì)先經(jīng)過(guò)一層 Filter 鏈再到達(dá) Servlet。理解先后順序很重要Filter 的執(zhí)行發(fā)生在 Servlet 的 service 方法之前因此編碼設(shè)置、登錄校驗(yàn)這類橫切邏輯通常會(huì)放在 Filter 里而不是在每個(gè) Servlet 里重復(fù)寫。一個(gè)典型個(gè)人微博項(xiàng)目的 web.xml 或注解配置里會(huì)聲明類似這樣的映射WebFilter(urlPatterns {/api/*, /index.jsp}) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }chain.doFilter 是關(guān)鍵它表示把請(qǐng)求繼續(xù)向后傳遞。如果忘了調(diào)用這一行請(qǐng)求就會(huì)在這里被“吞掉”后面的 Servlet 永遠(yuǎn)執(zhí)行不到前端表現(xiàn)就是接口一直 pending。再往下容器根據(jù)請(qǐng)求路徑找到對(duì)應(yīng)的 Servlet。對(duì)于 /api/login 這個(gè)路徑容器會(huì)在映射關(guān)系里找到 LoginServlet然后調(diào)用它的 service 方法service 再根據(jù) HTTP 方法分發(fā)到 doGet 或 doPost。所以 doPost 不是“你寫的入口”而是整個(gè)調(diào)度鏈上最靠近業(yè)務(wù)邏輯的那一層。2.3 Servlet 三兄弟的生命周期init、service、destroyServlet 不是“每個(gè)請(qǐng)求新建一個(gè)實(shí)例”恰恰相反同一個(gè) Servlet 在容器里通常只有一個(gè)實(shí)例被所有請(qǐng)求線程共享。這是新人最容易踩的并發(fā)思維誤區(qū)。init 方法容器啟動(dòng)或首次請(qǐng)求時(shí)執(zhí)行一次適合做資源初始化。service 方法每個(gè)請(qǐng)求都會(huì)執(zhí)行但它內(nèi)部按方法類型分發(fā)給 doGet/doPost。destroy 方法容器關(guān)閉時(shí)執(zhí)行一次適合釋放資源。這里就出現(xiàn)一個(gè)并發(fā)隱患如果在一個(gè) Servlet 里定義了成員變量并用它存某個(gè)登錄用戶的身份信息那么用戶 A 的請(qǐng)求和用戶 B 的請(qǐng)求會(huì)同時(shí)讀寫同一個(gè)變量。正確做法是把每個(gè)請(qǐng)求、每個(gè)用戶特有的數(shù)據(jù)放進(jìn) request 作用域、session 作用域或方法局部變量而不是 Servlet 成員變量。一個(gè)真實(shí)場(chǎng)景的登錄 Servlet 大致長(zhǎng)這樣WebServlet(/api/login) public class LoginServlet extends HttpServlet { private UserService userService new UserServiceImpl(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); User user userService.login(username, password); if (user ! null) { HttpSession session req.getSession(true); session.setAttribute(currentUserId, user.getId()); resp.sendRedirect(req.getContextPath() /index.jsp); } else { req.setAttribute(loginError, 用戶名或密碼錯(cuò)誤); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }注意這里的兩個(gè)跳轉(zhuǎn)方式差別非常微妙sendRedirect 是讓瀏覽器再發(fā)一次新請(qǐng)求地址欄會(huì)變成 /index.jspforward 是服務(wù)器內(nèi)部直接改渲染目標(biāo)地址欄保持 /api/login 不變。登錄失敗用 forward能把錯(cuò)誤信息通過(guò) request 屬性帶給登錄頁(yè)登錄成功用 redirect能避免用戶刷新頁(yè)面時(shí)把“登錄提交”再次重放。這段代碼里的 UserService 是業(yè)務(wù)層它的內(nèi)部會(huì)調(diào) DAO 去查數(shù)據(jù)庫(kù)這就是后面章節(jié)里時(shí)間線查詢的入口。3. 會(huì)話機(jī)制Cookie 里的 JSESSIONID、服務(wù)端 Map 與“假裝記住你”3.1 HttpSession 到底存在哪上一章登錄成功時(shí)調(diào)用 req.getSession(true)這里發(fā)生了兩件事服務(wù)器創(chuàng)建或找到一條會(huì)話記錄同時(shí)在響應(yīng)頭里寫一個(gè) Set-Cookie。會(huì)話記錄本身是一個(gè)服務(wù)端的內(nèi)存對(duì)象本質(zhì)是一個(gè)鍵值結(jié)構(gòu)存著這個(gè)會(huì)話的創(chuàng)建時(shí)間、最后訪問(wèn)時(shí)間、失效時(shí)間以及你往里塞的各種 attribute。Cookie 里存的是會(huì)話的唯一標(biāo)識(shí)大多數(shù)容器默認(rèn)叫 JSESSIONID。瀏覽器收到后后續(xù)每次請(qǐng)求都會(huì)把這個(gè) Cookie 原樣帶回。服務(wù)器一看 JSESSIONID就能從自己的會(huì)話集合里找到對(duì)應(yīng)的 HttpSession 對(duì)象然后把“當(dāng)前登錄的是誰(shuí)”這個(gè)信息取出來(lái)。丟登錄態(tài)最常見的原因就是這條鏈路某一段斷了環(huán)節(jié)常見坑表現(xiàn)Cookie 未設(shè)置 HttpOnly前端腳本能讀到 JSESSIONIDXSS 后可被竊取會(huì)話劫持Cookie 未設(shè)置 Path應(yīng)用上下文路徑變化后 Cookie 帶不過(guò)來(lái)登錄失效Session 超時(shí)超過(guò) maxInactiveInterval 無(wú)訪問(wèn)容器自動(dòng)清理突然要重新登錄容器重啟內(nèi)存會(huì)話全部消失重啟后全部掉線3.2 登錄校驗(yàn) Filter為什么每個(gè)受保護(hù)頁(yè)面都要過(guò)一道關(guān)卡微博應(yīng)用中發(fā)微博、看時(shí)間線、改頭像這些操作都不允許匿名訪問(wèn)。如果每個(gè) Servlet 都手寫一遍“檢查 session 里有沒(méi)有 currentUserId”代碼會(huì)瞬間失控。標(biāo)準(zhǔn)做法是做一個(gè)統(tǒng)一的登錄校驗(yàn) Filter只讓登錄過(guò)的請(qǐng)求通過(guò)。WebFilter(urlPatterns {/api/postWeibo, /api/timeline, /index.jsp}) public class LoginCheckFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) req; HttpSession session httpReq.getSession(false); if (session ! null session.getAttribute(currentUserId) ! null) { chain.doFilter(req, resp); } else { HttpServletResponse httpResp (HttpServletResponse) resp; httpResp.sendRedirect(httpReq.getContextPath() /login.jsp); } } }這里專門用了 getSession(false)如果當(dāng)前沒(méi)有會(huì)話直接返回 null而不是新造一個(gè)。很多人寫成 getSession(true)導(dǎo)致未登錄用戶每次訪問(wèn)受保護(hù)頁(yè)面都會(huì)被服務(wù)器創(chuàng)建一個(gè)新會(huì)話服務(wù)器內(nèi)存里堆滿“幽靈會(huì)話”。登錄校驗(yàn)的嚴(yán)謹(jǐn)程度可以從這一個(gè)參數(shù)上看出來(lái)。3.3 密碼不能明文存加鹽哈希與登錄流程中容易被跳過(guò)的那一步個(gè)人微博項(xiàng)目里最常見的重災(zāi)區(qū)是密碼存儲(chǔ)。有人直接明文存有人用 MD5 存一次就當(dāng)作加密。MD5 確實(shí)“不是明文”但它經(jīng)不起彩虹表和暴力碰撞。真正常見且可靠的思路是加鹽哈希每個(gè)用戶生成一個(gè)隨機(jī)鹽把鹽拼到密碼后面再進(jìn)行多次哈希然后鹽和哈希結(jié)果一起入庫(kù)。public class PasswordUtil { private static final int ITERATIONS 10000; public static String hashWithSalt(String rawPassword, String salt) throws Exception { byte[] result rawPassword.getBytes(UTF-8); for (int i 0; i ITERATIONS; i) { byte[] combined new byte[result.length salt.getBytes(UTF-8).length]; System.arraycopy(result, 0, combined, 0, result.length); System.arraycopy(salt.getBytes(UTF-8), 0, combined, result.length, salt.length()); result MessageDigest.getInstance(SHA-256).digest(combined); } return bytesToHex(result); } }這里的迭代次數(shù)不是隨便寫的。每次循環(huán)都把上一步的摘要結(jié)果再和鹽一起重新哈希目的是把計(jì)算成本抬高到“暴力破解一個(gè)密碼需要足夠長(zhǎng)時(shí)間”的量級(jí)。與此同時(shí)登錄驗(yàn)證時(shí)也要重復(fù)同樣次數(shù)的哈希再比對(duì)所以登錄接口天然會(huì)比其他接口慢一些這是安全換來(lái)的可接受代價(jià)。從這一章能看到整個(gè)登錄態(tài)的本質(zhì)服務(wù)器并沒(méi)有“記住你”它只是保存了一條狀態(tài)記錄并通過(guò)一個(gè)不可偽造的標(biāo)識(shí)和瀏覽器保持約定。你刷新一次頁(yè)面看到自己的頭像和別人的微博其實(shí)是 session 里的 userId 被拿去查了關(guān)注關(guān)系、查了時(shí)間線最終拼出了“好像記得你是誰(shuí)”的完整體驗(yàn)。4. 時(shí)間線是怎么“算”出來(lái)的feed 表結(jié)構(gòu)、拉取式查詢與分頁(yè)索引4.1 核心表結(jié)構(gòu)用戶表、微博表、關(guān)注關(guān)系表個(gè)人微博應(yīng)用的數(shù)據(jù)模型圍繞三個(gè)核心實(shí)體用戶、微博內(nèi)容、關(guān)注關(guān)系。微博表是業(yè)務(wù)的主體關(guān)注關(guān)系決定時(shí)間線范圍。建表 SQL 里索引設(shè)計(jì)決定了查詢上限。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, salt VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL ); CREATE TABLE weibo ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content VARCHAR(280) NOT NULL, created_at DATETIME NOT NULL, INDEX idx_user_time (user_id, created_at DESC), INDEX idx_time (created_at DESC) ); CREATE TABLE follow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, follow_user_id BIGINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_user_follow (user_id, follow_user_id) );這里有兩個(gè)索引細(xì)節(jié)值得展開。idx_user_time 支撐“查某個(gè)人的所有微博并按時(shí)間倒序”這種個(gè)人主頁(yè)查詢idx_time 支撐“全站最新微博”這種公共時(shí)間線查詢。當(dāng) create_at 被加上后MySQL 可以直接按索引順序倒著讀避免做文件排序。4.2 拉取式時(shí)間線為什么這種小項(xiàng)目不選擇“推送”時(shí)間線設(shè)計(jì)有兩條路拉取模式和推送模式。拉取模式是當(dāng)你請(qǐng)求時(shí)間線時(shí)現(xiàn)場(chǎng)去查你關(guān)注的人最近發(fā)了什么推送模式是發(fā)微博時(shí)系統(tǒng)立刻把這條微博復(fù)制到所有粉絲的收件箱里。個(gè)人微博項(xiàng)目用拉取有兩個(gè)現(xiàn)實(shí)原因。一是用戶量小關(guān)注關(guān)系簡(jiǎn)單查詢開銷完全扛得住二是推送模式要維護(hù)“每個(gè)用戶的收件箱表”每次發(fā)微博都是一次扇出寫入粉絲多時(shí)要寫入幾千條數(shù)據(jù)還要處理事務(wù)一致性問(wèn)題。拉取模式把開銷從“寫時(shí)”轉(zhuǎn)移到了“讀時(shí)”代碼簡(jiǎn)單失誤率低。你手上這份資源里如果用 JDBC 手寫 SQL 查詢走的一定是拉取模式因?yàn)樗恍枰胂㈥?duì)列或異步任務(wù)。4.3 時(shí)間線 SQLJOIN 與分頁(yè)的正確姿勢(shì)查詢時(shí)間線時(shí)最直觀的寫法是查出所有關(guān)注對(duì)象的 id再去微博表里查這些人的微博。但逐條查會(huì)產(chǎn)生 N1 次 SQL。正確做法是用子查詢加 JOIN 一次性查出完整結(jié)果SELECT w.id, w.content, w.created_at, u.username FROM weibo w JOIN user u ON u.id w.user_id WHERE w.user_id IN ( SELECT follow_user_id FROM follow WHERE user_id ? ) ORDER BY w.created_at DESC LIMIT 20 OFFSET 0;這條 SQL 的執(zhí)行順序和人們通常以為的不一樣不是先執(zhí)行 IN 子查詢?cè)侔呀Y(jié)果拿去 JOIN而是優(yōu)化器會(huì)嘗試先把 follow 表按 user_id 定位再通過(guò)索引找對(duì)應(yīng)的 weibo 行。這里占位符 ? 必須用 PreparedStatement 傳參不能直接字符串拼接否則一個(gè)包含單引號(hào)的微博內(nèi)容就能把整個(gè)查詢語(yǔ)法打碎這就是 SQL 注入的典型入口。分頁(yè)參數(shù) LIMIT 20 OFFSET 0 表達(dá)的是“每頁(yè) 20 條跳過(guò) 0 條”。頁(yè)碼越大OFFSET 值越大MySQL 需要先把前面所有行全部掃出來(lái)再丟棄這就是深翻頁(yè)變慢的根源。資源 DAO 層如果提供了“上一頁(yè)最后一條微博 id”這種參數(shù)走的其實(shí)是鍵集分頁(yè)比如 WHERE w.created_at ? ORDER BY w.created_at DESC LIMIT 20它能直接走索引而不需要掃已讀過(guò)的數(shù)據(jù)兩種分頁(yè)方式的性能差距在數(shù)據(jù)量過(guò)萬(wàn)后會(huì)非常明顯。DAO 層把 SQL 查詢結(jié)果封裝成對(duì)象的常見思路是先取 ResultSet再逐行構(gòu)建 Weibo 對(duì)象組裝成 List 返回給 Service 層Service 層再把時(shí)間列表交給 Servlet 響應(yīng)輸出。這一層封裝雖然代碼量大卻是 JDBC 應(yīng)用的骨架。如果資源里使用了連接池配置那連接的生命周期管理就是下一章要說(shuō)的重災(zāi)區(qū)。5. 前端 Java Web 避坑清單亂碼、連接耗盡、異步數(shù)據(jù)拿不到、會(huì)話丟失5.1 中文亂碼編碼設(shè)置的位置比想象中更早現(xiàn)象注冊(cè)時(shí)輸入“我喜歡寫代碼”存入數(shù)據(jù)庫(kù)變成“?????”頁(yè)面顯示一串問(wèn)號(hào)。原因Servlet 容器解析請(qǐng)求體時(shí)默認(rèn)按 ISO-8859-1 讀取字節(jié)流。你調(diào)用 req.getParameter 的時(shí)候參數(shù)已經(jīng)被按錯(cuò)誤編碼解碼了后面無(wú)論怎么 new String 轉(zhuǎn)碼都只能靠“先還原字節(jié)再重解”這步補(bǔ)救而這個(gè)還原過(guò)程一旦遇到不可逆字符就徹底失敗。解決在最先接觸請(qǐng)求的 Filter 里于讀取任何參數(shù)之前執(zhí)行 req.setCharacterEncoding(UTF-8)同時(shí)給 JDBC 連接 URL 加上 characterEncodingUTF-8建表時(shí)把字符集設(shè)為 utf8mb4。三處缺一不可Filter 少了請(qǐng)求參數(shù)報(bào)錯(cuò)URL 少了數(shù)據(jù)庫(kù)寫入報(bào)錯(cuò)表字符集少了存儲(chǔ)和展示不一致。5.2 數(shù)據(jù)庫(kù)連接不釋放一次忘記 close 引發(fā)的雪崩現(xiàn)象應(yīng)用剛啟動(dòng)一切正常運(yùn)行二十分鐘后隨便一個(gè)接口都卡住不動(dòng)重啟后恢復(fù)過(guò)一段時(shí)間又卡死。原因Java 代碼里每次用 DriverManager.getConnection 創(chuàng)建連接用完后沒(méi)有 close。數(shù)據(jù)庫(kù)的最大連接數(shù)被占滿后新的請(qǐng)求只能排隊(duì)等超時(shí)。更隱蔽的是即使寫了 close如果出現(xiàn)在 try 塊中途拋異常的分支上close 根本執(zhí)行不到。解決統(tǒng)一使用連接池例如手動(dòng)實(shí)現(xiàn)一個(gè)池或直接依賴容器數(shù)據(jù)源并在 finally 里釋放連接。連接的 close 方法在池化場(chǎng)景下不是真正斷開數(shù)據(jù)庫(kù)連接而是“歸還”到池中所以每個(gè) Request 的處理都必須保證歸還否則池里的可用連接會(huì)越借越少。從 Java 7 開始的 try-with-resources 寫法能把釋放邏輯收斂到一行代碼中是這類資源里最值得優(yōu)先改造的點(diǎn)。5.3 Ajax 拿不到 JSON返回類型和響應(yīng)頭各錯(cuò)一半現(xiàn)象前端用 fetch 或 XHR 異步請(qǐng)求 /api/timeline控制臺(tái)報(bào)解析錯(cuò)誤后端日志沒(méi)異常MySQL 查詢也正常執(zhí)行。原因Servlet 往 response 里寫 JSON 字符串時(shí)既沒(méi)有設(shè)置 Content-Type又順手返回了一個(gè) JSP 頁(yè)面。瀏覽器收到的是 text/html 類型把它當(dāng)網(wǎng)頁(yè)處理前端卻按 JSON 解析自然解析失敗。另一個(gè)常見原因是在 Servlet 里寫了 resp.sendRedirect把異步請(qǐng)求整個(gè)重定向到登錄頁(yè)前端拿到的是登錄頁(yè)的 HTML 片段。解決返回 JSON 的接口統(tǒng)一設(shè)置 resp.setContentType(application/json;charsetUTF-8)用第三方工具完成對(duì)象到 JSON 字符串的序列化同時(shí)避免在異步接口里使用重定向。前端 fetch 里也要加上請(qǐng)求頭聲明并在 then 里判斷 res.ok 再 res.json()。兩個(gè)方向各退半步問(wèn)題就能定位到具體哪一側(cè)。5.4 Session 突然丟失登錄態(tài)容器重啟和瀏覽器策略各有一套邏輯現(xiàn)象后端代碼一改重啟應(yīng)用服務(wù)器所有用戶被踢下線用戶用無(wú)痕窗口打開應(yīng)用每次都要重新登錄。原因HttpSession 默認(rèn)存在容器內(nèi)存里不是存在數(shù)據(jù)庫(kù)或磁盤文件中。容器一重啟內(nèi)存清空所有 JSESSIONID 對(duì)應(yīng)的會(huì)話記錄全部消失。無(wú)痕窗口則是瀏覽器不保留會(huì)話級(jí) Cookie 的表現(xiàn)因?yàn)槿萜髂J(rèn)沒(méi)有給 JSESSIONID 設(shè)置 Max-AgeCookie 只存活在瀏覽器進(jìn)程內(nèi)存里窗口一關(guān)就沒(méi)了。解決先分清楚這是“預(yù)期行為”還是“產(chǎn)品缺陷”。個(gè)人微博項(xiàng)目里容器重啟導(dǎo)致掉線可以接受因?yàn)闀?huì)話數(shù)據(jù)量小、重建成本低。如果產(chǎn)品要求“記住我”七天免登錄就要用持久化的登錄令牌方案登錄成功后生成一個(gè)隨機(jī) token 存數(shù)據(jù)庫(kù)把 token 寫進(jìn)帶 Max-Age 的 Cookie 里后續(xù)請(qǐng)求用 token 換取用戶身份。這個(gè)方案繞開了 Session 內(nèi)存存儲(chǔ)的邊界代價(jià)是每次請(qǐng)求多一次數(shù)據(jù)庫(kù)查詢。5.5 時(shí)間線深翻頁(yè)越來(lái)越慢LIMIT/OFFSET 的代價(jià)被低估現(xiàn)象前幾頁(yè)的微博秒開翻到三十頁(yè)后接口耗時(shí)從 80ms 漲到 800ms。原因數(shù)據(jù)庫(kù)要 LIMIT 20 OFFSET 580就得從最新的那條開始一路數(shù)過(guò)前六百條再丟掉前五百八十條。翻得越深數(shù)據(jù)庫(kù)做的工作越多而且這些被丟掉的行沒(méi)法走索引加速。解決改用鍵集分頁(yè)。前端把當(dāng)前頁(yè)最后一條微博的時(shí)間或 id 傳回后端SQL 改成 WHERE w.created_at ? ORDER BY w.created_at DESC LIMIT 20。這個(gè)條件下數(shù)據(jù)庫(kù)能直接利用索引定位到指定位置隨后順序讀取不管翻到多深掃描范圍都固定在一頁(yè)大小內(nèi)。注意時(shí)間字段在有數(shù)據(jù)重復(fù)時(shí)可能出現(xiàn)同一秒內(nèi)多條記錄被跳過(guò)的問(wèn)題穩(wěn)妥做法是使用 id 作為排序鍵因?yàn)?id 在主鍵索引里天然單調(diào)遞增、單調(diào)且唯一。6. 從這門課的設(shè)計(jì)取舍再看現(xiàn)代的 Java Web手寫鏈路的價(jià)值在框架封裝之后反而更清晰普通 Web 開發(fā)者現(xiàn)在寫接口習(xí)慣是框架一鍵生成前端發(fā)請(qǐng)求后端寫一個(gè) controller 方法加幾個(gè)注解返回 JSON完事??雌饋?lái)和 Servlet 時(shí)代天差地別但對(duì)照前幾章的鏈路你會(huì)發(fā)現(xiàn) Spring 這類框架做的每一件事都是在用更抽象的方式重新表達(dá) Servlet 規(guī)范里已有的機(jī)制RequestMapping 替代了 web.xml 里的 URL 映射攔截器替代了 Filter 鏈JdbcTemplate 替你把 PreparedStatement 的創(chuàng)建、參數(shù)綁定、ResultSet 遍歷收進(jìn)了一行代碼而控制器方法拿到 HttpSession 參數(shù)的過(guò)程本質(zhì)上仍是容器在調(diào)用鏈里解析并注入會(huì)話對(duì)象。所以我一直覺(jué)得先手寫一遍 Servlet、Service、DAO 三層再去看框架效率是最高的。你踩過(guò)的亂碼、連接池耗盡、會(huì)話丟失、分頁(yè)變慢每一個(gè)坑對(duì)應(yīng)的都是框架底層幫你封裝的某個(gè)組件??蚣懿皇悄Хㄋ前涯愕难獪I經(jīng)驗(yàn)固化成了一批默認(rèn)配置。遇到復(fù)雜的異常時(shí)如果腦子里沒(méi)有這條手寫鏈路就只能對(duì)著堆棧瞎猜。驗(yàn)證這套理解最直接的方法打開瀏覽器開發(fā)者工具觀察從輸入 URL 到頁(yè)面渲染完成的網(wǎng)絡(luò)請(qǐng)求時(shí)間線找到那個(gè)異步加載時(shí)間線的請(qǐng)求點(diǎn)開它的請(qǐng)求頭找到 Cookie 里的 JSESSIONID再去服務(wù)器日志里找到那次請(qǐng)求對(duì)應(yīng)的 SQL 日志你可以親眼看到 session 里的 userId 被拼進(jìn)了哪個(gè)查詢條件。從那以后我每接手一個(gè) Java Web 項(xiàng)目第一件事不是跑起來(lái)而是先把它的請(qǐng)求鏈路圖畫一遍哪個(gè) Filter 先執(zhí)行、哪個(gè) Servlet 處理、Session 從哪里寫入又在何處讀、數(shù)據(jù)庫(kù)連接的生命周期覆蓋在哪個(gè)調(diào)用范圍內(nèi)。這張圖畫清楚了項(xiàng)目里百分之八十的詭異問(wèn)題都有了定位方向。希望這份從請(qǐng)求到會(huì)話再到數(shù)據(jù)的完整鏈路拆解能幫你在跑通這份資源時(shí)少走幾步當(dāng)年我走過(guò)的彎路。本文還有配套的精品資源點(diǎn)擊獲取