
做Web开发的人几乎都拿Servlet写过一两个Demo其中曝光率最高的除了登录注册就是点击计数器。这个项目标题看起来只是统计某个按钮或页面的访问次数真正动手时你会发现它就像一枚硬币的两面一面是Servlet的基本用法另一面涉及并发、存储、生命周期这些老生常谈却极易踩坑的问题。不管你是刚学Java Web的学生还是在公司里被分到维护老系统任务的开发理解这个经典案例都能帮你把Servlet的工作方式彻底摸透。这篇博文我会从一个实际可跑的Servlet点击计数器入手讲清楚它背后的方案选型、线程安全、持久化以及我踩过的那些坑。1. 需求拆解与方案选型一个小小的计数器到底在统计什么1.1 计数器背后隐藏的需求任何点击计数器在动手前第一步要搞清楚的其实是“点一下”到底指什么。是用户每刷新一次页面就加一还是同一个会话只算一次是统计页面的PV还是统计某个按钮被点击的次数这两种诉求对应的实现逻辑完全不同。很多教程只写“计数器累加”却没说清楚统计口径导致学完之后放到真实项目里怎么改都不对。如果我们做一个页面访问计数器通常期望它是每次访问都加一包括刷新。这种情况下判断依据就是每一次HTTP请求。如果是按钮点击次数通常要在点击事件里发一个请求再由后端Servlet计数这个请求可能是异步的Ajax也可能是表单提交。第二种情况往往需要前端配合后端只关注新增的请求是否来自有效用户。所以你不能一上来就写代码。先把业务说清楚按请求计数还是按用户去重计数。这个决定直接影响后面要不要使用Session、Cookie、IP等方案。我见过不少项目因为没分清这个边界计数器统计出来的数字和业务口径完全对不上最后只能重写。另外还有一个最容易忽略的点计数器的初始值从哪里来。很多Demo直接把count变量设为0但如果这个页面已经上线很久你中途才接入统计那数据会从0开始业务方必然不接受。所以真实的点击计数器一定支持从某个已有数值继续累加这就把存储和初始化方案也牵扯进来了。我习惯在写任何代码之前先把需求整理成几个明确的问题统计对象是什么、统计口径是什么、是否需要实时展示、数据能不能接受丢失、并发量大概多少。这几个问题决定了方案走向。1.2 存到哪内存、文件、数据库的三方权衡计数器需要一个地方来存放不断变化的数值。对Servlet应用来说最直接的存储位置就是ServletContext。这个对象跟Tomcat一样伴随整个Web应用生命周期所有用户共享一个实例天然适合保存全局计数。它的优点是无需序列化、读写快、代码简单所有Servlet都可以通过getServletContext()拿到同一个对象。缺点是数据存在内存里一旦Tomcat重启或者应用重新部署计数器就会清零。如果你只做Demo完全没问题如果你想把它变成可上线的访问统计那必须考虑持久化。第二种选择是文件存储。以普通文本文件或Properties文件保存当前计数每次请求时读出、递增、写回。这个方案实现简单不依赖额外组件适合单机小规模使用。但并发写入需要小心处理因为多个请求同时打开同一个文件很容易出现后写覆盖先写的情况。而且频繁写文件会有IO开销文件损坏恢复起来也比较麻烦。文件方案我一般只用来做“临时持久化”比如每隔几分钟把内存里的值刷一次而不是每次请求都写。第三种是数据库存储。用一个表保存计数项和计数值每次递增都走SQL的UPDATE。优点是不会因为应用重启丢失数据也能支持多个Servlet节点同时统计配合连接池可以做得比较稳。缺点是需要多写一些JDBC代码对初学者来说多了一道门槛。在真实项目里数据库方案基本是首选。至于Redis这类缓存性能高是优势但那是另一个量级的话题建议先把前三种搞明白再扩展。为了让你更清楚地对比我把三个方案的核心指标列一下存储位置读写速度重启丢失复杂度适合场景ServletContext内存极快会丢失极低学习演示、临时统计文件较快恢复后保留中单机、低频写数据库中不丢失高正式项目、多实例注意上表的“文件”一行如果在应用停止时写回重启后可以保留但崩溃时可能丢失最近一段数据。数据库则基本上是最安全的。1.3 并发与线程安全为什么计数器不是“ 1”那么简单Servlet的service方法、doGet、doPost会被容器多个线程并发调用。也就是说当100个人同时点击页面代码里那句count可能同时被执行。count在Java里不是原子操作它实际分成读、加、写三步两个线程并发操作时可能同时读到旧值写回去时造成覆盖最终计数比实际值小。这就是典型的并发丢失更新的问题。处理办法并不复杂要么用synchronized把递增代码包起来要么使用AtomicLong这种线程安全的原子类。我推荐AtomicLong因为它底层用CAS实现在高并发下比锁的性能更好而且代码写起来很干净。CAS的全称是Compare-And-Swap通俗理解就是“先比一比当前值是否和自己预想的旧值一样一样就更新不一样就重试”所以不会像加锁那样阻塞太多线程。要注意的是如果你在递增之外还做了其他操作比如同时更新数据库就不能只靠AtomicLong了需要引入事务控制。这个我们后面扩展数据库时会详细讲。另外还有一个容易被忽略的线程安全问题如果你把Long类型的值存在ServletContext里每次都要getAttribute再setAttribute回去即使两个步骤之间加锁也不能保证get到的旧值一定是你上一次写回的值。因为Java内存模型里普通变量在多个线程之间不保证可见性Long对象又是不可变的必须配合volatile或者使用可变原子类。这个细节是很多人反复测试都测不出问题、一到高并发就崩的根源。所以不要嫌AtomicLong啰嗦它解决了可见性和原子性两个问题。2. 手把手实现一个最小可用的Servlet点击计数器2.1 环境准备与项目骨架在开始之前先把环境准备好。我用的是最经典的一套JDK 8、Tomcat 9、Maven构建理论上Tomcat 7也兼容新手用IDE启动内置Tomcat也没问题。虽然现在Spring Boot很流行但这个练习的核心是理解Servlet容器的行为所以我推荐直接在原始的Servlet工程里跑一遍不借助任何框架。创建一个标准的Web工程结构大概是src/main/java放Servlet类src/main/webapp放HTML、JSP或静态资源src/main/resources放配置比如数据库连接信息pom.xml引入javax.servlet-api注意scope设为provided因为Tomcat自带Servlet容器避免冲突如果没有Maven也可以手动创建Dynamic Web Project效果一样。重点不是工具而是让你知道请求从URL到Servlet方法之间的路径关系。Maven项目的pom.xml关键依赖如下dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency这个依赖只用来编译运行时Tomcat已经提供所以scope一定是provided不然部署到Tomcat时容易遇到类重复冲突。启动Tomcat的方式有很多如果你用IDE直接配置一个Tomcat Server然后右键运行即可。如果想纯命令行练手也可以把打好包的war放到Tomcat的webapps目录下然后启动bin/startup.sh或startup.bat。我个人习惯用IDE因为热部署和日志查看方便。不过一定要记住热部署有时候会保留旧类的实例计数器不更新时先杀进程再重启。2.2 映射Servlet请求是怎么找到计数器的一个Servlet要能被浏览器访问必须配置映射路径。有两种方式在web.xml里用servlet和servlet-mapping标签或者在类上直接加WebServlet注解。早期项目多使用web.xml好处是集中管理坏处是文件越来越大。现在注解方式更主流代码里直接看到路径效率更高。比如WebServlet(/counter)表示所有访问“/工程名/counter”的请求都会进入这个Servlet。如果你配置的是WebServlet(/counter/*)那么/counter/any路径都会命中要注意路径匹配规则。下面给出web.xml方式作为补充servlet servlet-namecounterServlet/servlet-name servlet-classcom.demo.counter.CounterServlet/servlet-class /servlet servlet-mapping servlet-namecounterServlet/servlet-name url-pattern/counter/url-pattern /servlet-mapping两种方式选一种即可。我比较喜欢注解因为它把“这个Servlet负责哪个URL”直接写在类上面读代码时一目了然。真实项目里Spring MVC还会把DispatcherServlet映射到根路径但底层依然是Servlet的url-pattern机制所以这里弄清了后面看框架也不会发怵。2.3 写一个最简单的计数逻辑下面是我实际跑通的最小实现。先创建一个CounterServlet.javapackage com.demo.counter; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.concurrent.atomic.AtomicLong; WebServlet(/counter) public class CounterServlet extends HttpServlet { private static final long serialVersionUID 1L; private AtomicLong totalCount new AtomicLong(0); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { long current totalCount.incrementAndGet(); resp.setContentType(text/html;charsetUTF-8); resp.getWriter().println(htmlbody); resp.getWriter().println(h2页面点击次数 current /h2); resp.getWriter().println(/body/html); } }启动Tomcat后在浏览器访问/counter每次刷新页面数字都会加一。这个实现已经能用但有几个问题计数器存在哪里了答案是一个Servlet实例的私有字段上。因为Tomcat对同一个Servlet只创建一个实例相当于一个全局变量所有线程共享所以数字是全局的。但是这种存储方式在应用重启后就会丢失而且如果项目部署到多台服务器每台服务器的Servlet实例各自持有自己的计数全站数字就不是全局的了。所以这个版本只能用来快速验证Servlet环境是否正常不适合直接上生产。我建议你在浏览器里打开开发者工具切到Network面板再刷新几次观察每一个请求状态都是200响应时间通常个位数毫秒。这样可以确认请求确实到达了Servlet而不是被静态资源缓存了。2.4 用ServletContext做全局共享为了让计数真正跟Web应用绑定更好的做法是存在ServletContext里也就是application作用域。它是在Web应用启动时创建的全局对象同一个Tomcat应用下的所有Servlet共享。你可以把它理解成整个应用的公共储物柜谁都能访问。ServletContext还有一个父级概念就是同一个JVM下多个应用之间相互隔离所以不同应用的同名属性不会串。我通常的做法是在init方法里初始化一个AtomicLong把它存进去每次请求再从ServletContext取出来递增WebServlet(/counter-v2) public class CounterContextServlet extends HttpServlet { Override public void init() throws ServletException { getServletContext().setAttribute(totalCount, new AtomicLong(0)); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { ServletContext ctx getServletContext(); AtomicLong count (AtomicLong) ctx.getAttribute(totalCount); long current count.incrementAndGet(); resp.setContentType(text/html;charsetUTF-8); resp.getWriter().println(当前点击次数 current); } }这里要注意不要把普通的Long存进去再试图加一因为从context里取出来的Long是不可变的你必须把增量之后的值再setAttribute回去这个过程同样有并发问题。AtomicLong本身就是可变对象直接调用incrementAndGet才安全。这个细节是很多一遍遍踩坑的关键。如果你想要更加整洁可以把初始化逻辑放到ServletContextListener里因为应用启动时Listener会在Servlet实例化之前执行这样无论哪个Servlet先被请求都能拿到已经初始化好的计数对象。Listener还有机会在应用停止时执行持久化这个我们后面讲。2.5 区分用户同一用户刷新要不要重复统计回到最开始的需求。如果业务希望同一个用户多次刷新只算一次那就要用到HttpSession。在doGet里调用req.getSession()然后用一个session级别的标记记录这个用户是否已经访问过。例如首次访问时session里没有“hasCounted”这个属性就执行递增然后把它设成一个布尔值。第二次进来时发现已经有该属性就不递增只返回当前计数。HttpSession session req.getSession(); Boolean counted (Boolean) session.getAttribute(hasCounted); if (counted null) { AtomicLong count (AtomicLong) getServletContext().getAttribute(totalCount); count.incrementAndGet(); session.setAttribute(hasCounted, true); }这种方式的优点是简单适合“一人一次”的投票类场景。缺点是Session是依赖Cookie的用户清掉Cookie或者换浏览器就又变成新用户了。如果要更严格地按设备去重可以再结合IP、User-Agent等信息做指纹但指纹算法本身又是一个大坑。大多数场景下Session去重已经够用。还有一点需要分析清楚“点击计数器”到底是“访问计数器”还是“点击计数器”如果是按钮点击次数通常每次点击都要计数不应按Session去重否则用户点两次只记一次反而不对。3. 进阶让计数器扛住重启与并发3.1 用文件持久化简单但不推荐如果你只是模拟演示不想引入数据库可以把计数定期写到一个文件里。思路是在ServletContextListener的contextInitialized方法里读取属性文件拿到上次的计数初始化AtomicLong然后在每次递增之后或者每隔一定时间把当前值写回文件。这样重启时就能恢复。代码大致逻辑如下public class CounterListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { String filePath sce.getServletContext().getRealPath(/WEB-INF/counter.properties); long start 0; try (InputStream input new FileInputStream(filePath)) { Properties props new Properties(); props.load(input); start Long.parseLong(props.getProperty(count, 0)); } catch (Exception e) { // 文件不存在就当0处理 } sce.getServletContext().setAttribute(totalCount, new AtomicLong(start)); } Override public void contextDestroyed(ServletContextEvent sce) { AtomicLong count (AtomicLong) sce.getServletContext().getAttribute(totalCount); if (count ! null) { String filePath sce.getServletContext().getRealPath(/WEB-INF/counter.properties); try (OutputStream out new FileOutputStream(filePath)) { Properties props new Properties(); props.setProperty(count, String.valueOf(count.get())); props.store(out, counter); } catch (IOException ignored) { } } } }但实际使用中文件写回要加同步锁而且多实例环境下文件路径会豁分歧非常容易出问题。所以这个方案我建议只用在学习阶段线上还是老老实实走数据库。3.2 数据库持久化JDBC实现点击计数数据库方案才是真正的正规路线。建表很简单CREATE TABLE page_counter ( id INT PRIMARY KEY AUTO_INCREMENT, counter_name VARCHAR(50) UNIQUE NOT NULL, counter_value BIGINT NOT NULL );counter_name可以区分不同页面、不同按钮比如“home_page_click”“download_button_click”。业务逻辑变成请求进来后对对应的counter_value执行UPDATE加一然后再SELECT当前值。如果你用MySQL可以写成一条SQLUPDATE page_counter SET counter_value counter_value 1 WHERE counter_name ?;但是你会遇到一个问题如果这条SQL先执行然后另一个用户紧接着执行了同样的UPDATE你再去SELECT可能读到的是别人已经加过的值对你来说等于是把这个请求也算进去了。这倒也不算错误因为计数本来就是并发的最终聚合值。关键是要保证UPDATE和SELECT之间不要插进其他会话的修改。最稳妥的办法是使用事务把更新和查询放在同一个事务里并合理设置隔离级别或者直接用数据库的原子递增语句然后返回结果例如MySQL的LAST_INSERT_ID技巧但不同数据库写法不同。为了简化如果不是超高并发直接在Java里用事务Connection conn dataSource.getConnection(); conn.setAutoCommit(false); try { PreparedStatement ps conn.prepareStatement( UPDATE page_counter SET counter_value counter_value 1 WHERE counter_name ?); ps.setString(1, counterName); ps.executeUpdate(); PreparedStatement ps2 conn.prepareStatement( SELECT counter_value FROM page_counter WHERE counter_name ?); ps2.setString(1, counterName); ResultSet rs ps2.executeQuery(); long value rs.next() ? rs.getLong(1) : 0; conn.commit(); // 返回 value } catch (SQLException e) { conn.rollback(); } finally { if (conn ! null) conn.close(); }注意这里UPDATE已经提交后紧接着SELECT在同一事务中会读到该事务修改后的值但其他事务可能又修改了它不过你读到的仍然是当前已提交的值。实际项目中统计精度要求没那么严格用数据库的原子更新已经够了。如果需要严格的数据一致性和完全防并发丢失可以使用SELECT FOR UPDATE锁行但这会增加锁竞争不适合特别高的吞吐。3.3 内存缓存 异步写库数据库方案虽然稳但如果每个点击都立刻UPDATE和SELECT高并发下数据库压力会很大而且响应时间也变长。这在真实项目里不是最优解。常见的优化思路是先用内存里的AtomicLong快速递增同时记录一个“上次写库值”每隔一定时间或每累计N次把当前值批量写入数据库。这个方案既保证了前端响应速度又不会把数据库写爆。但也带来一个风险在应用突然崩溃时内存中未写库的计数会丢失。解决办法是在增加计数器时异步记录日志或者接受这种极短时间的误差。大多数非核心场景比如统计页面访问量的绝对值稍微损失几个点完全可接受。如果一定要100%准确那就不需要内存缓冲直接同步写库就好速度和准确性之间你得做一个明确的取舍这也是项目经理最容易问的问题。这里给出一个简单的批量写库思路用ScheduledExecutorService定时每5秒刷一次。如果觉得引入线程池太复杂也可以简单地在每次请求达到N次后触发一次写库其余请求只更新内存。我踩过几次坑后总结最好的折中是“每10秒或每50次取先到条件”因为固定时间间隔会让数据库的写入压力均匀。注意批量更新时不要忘了清空“待写值”否则会重复计数。3.4 API化改造把Servlet返回JSON前端自己渲染现在前后端分离很常见Servlet点击计数器完全可以做成一个数据接口。将响应类型改为application/json返回结构如{ code: 0, data: 123, message: success }前端轮询或者点击时调用这个接口然后更新页面上对应元素。改造很简单只需把doGet的输出从HTML改成JSON再用Jackson或Gson做序列化。如果没有引入依赖也可以手动拼字符串但要小心转义。我在做接口化改造时习惯同时加一个“是否允许重复计数”的参数比如GET请求的query包含recountfalse后端通过session判断是否计数。这样同一个Servlet既能统计页面访问量又能被前端按钮点击调用灵活很多。用Jackson的示例resp.setContentType(application/json;charsetUTF-8); MapString, Object result new HashMap(); result.put(code, 0); result.put(data, current); result.put(message, success); resp.getWriter().write(new ObjectMapper().writeValueAsString(result));前端就可以在页面加载后fetch(/counter, {method: GET})然后把data值渲染到span里。如果你担心接口被机器人刷可以加一个简单的校验参数比如把值放到session里前端用同一个session的Cookie就可以。这些都是小优化但能让你把计数器从玩具变成真正可用的服务。4. 实战问题排查与效率优化4.1 计数器不更新刷新一直不变最常见的原因不是逻辑错而是浏览器缓存。你在Servlet设置的是text/html某些浏览器会对GET请求做缓存导致走不到Servlet。解决方法是加上禁止缓存的响应头Cache-Control: no-store或者改成POST请求。另外一个原因是访问路径不对如果你写的是WebServlet(/counter)但浏览器地址栏输的是工程名/xxx肯定找不到。确认一下Tomcat控制台日志看看是不是有404或者资源未找到。还有一种情况是Servlet容器里旧代码没热部署干净。尤其是加了数据库之后改了DAO但忘了重启Tomcat继续访问旧实例计数器自然不更新。所以排查时要先看日志再确认进程时间最后再看代码逻辑。如果你用IDE建议改完代码后强制重启一次不要迷信热部署。4.2 并发环境下数字偏小如果你没有使用AtomicLong或加锁直接用count高并发测试会发现统计结果低于实际请求数。这个现象我之前说过是典型的竞态条件。解决方式很简单换成AtomicLong或synchronized。但如果你用的是ServletContext但存的是一个Long对象后续要重新setAttribute那么仅仅是原子类还不够因为setAttribute和读旧的Long也不是原子的。所以一定要保证整个读写操作在同一个锁块内或使用可变对象。我实测过用synchronized包住整个“读-加-写”流程在1000并发时能准确到个位数AtomicLong也差不多只是代码更清爽。想自己验证的话可以写一个简单的并发脚本比如用Java的CountDownLatch同时发起200个请求然后看最终计数是否等于200。如果你看到193、196这类数字基本就是没处理好并发。4.3 服务器重启后计数回到零这是所有内存方案的通病。如果你看到重启后从零开始说明没有持久化逻辑或者持久化在内存里的变量被重新初始化了。检查启动时是否从文件/数据库读取了初始值。还要注意一个细节Tomcat在redeploy应用时会销毁旧的ServletContext并创建新的如果你在contextDestroyed里没有写回文件数据就没了。建议在停止时保存一次或者在每次递增时顺便存库。千万不要以为JVM内存就是永久的进程一结束一切归零。如果你接入了数据库重启后计数仍然从零多半是因为你的初始化方法里又new了一个AtomicLong(0)覆盖了数据库里的值。正确做法是启动时读库失败才用0兜底而不是无条件用0。4.4 中文乱码与URL路径问题点击计数器页面免不了显示中文比如“点击次数”。如果你在Servlet里直接println中文字符串浏览器显示乱码绝大多数是因为没有设置响应编码。记得使用resp.setContentType(text/html;charsetUTF-8)并且不要在已经拿到Writer之后再设置要在获取输出流之前设好。如果你用JSP则在页面顶部加% page contentTypetext/html;charsetUTF-8 %。至于URL路径工程名如果没有设置成根路径访问时容易漏掉context path最好用request.getContextPath()拼接前端链接。我这里提供一个快速判断是不是编码问题的方法把响应头改一下如果显示乱码就看浏览器开发者工具里的Content-Type。如果看到text/html;charsetISO-8859-1那一定是你没设置或者设置位置不对。给Writer之前setContentType不要等到写完再调用因为编码一旦确定后面的修改不生效。4.5 连接池与资源释放一旦接入数据库最最容易被新手忽视的就是连接资源。像我之前示例代码里写的conn.close()如果在finally里没做数据库连接很快会耗尽接着出现Connection reset或者连接超时Tomcat直接崩溃。我建议用HikariCP或Druid这样的连接池不要手动new DriverManager每次新建。连接池的好处是重用连接减少创建销毁开销也避免并发下把数据库拖死。在Servlet里获取连接时优先使用DataSource而不是DriverManager。一个简单的HikariCP配置示例HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(20); DataSource dataSource new HikariDataSource(config);把这个dataSource放在ServletContext里每个Servlet需要时取出来用完finally关闭。注意“关闭连接”不是真的断开数据库连接而是还给连接池所以一定要在finally里调用。4.6 一个真实的小场景为“下载按钮”做一个独立计数我实际接手过一个功能客户要在下载按钮旁显示“已下载xxx次”。开始他们把计数存在Session里结果用户每刷新一次下载列表显示的下载次数都不变因为服务器判断同一个session不再计数。后来改成每次请求按钮时都发一个POST到/download-statServlet里用counter_name“download”然后执行UPDATE SELECT才算真正达到了业务要求。所以说计数器怎么计数一定要从业务视角出发而不只是看“点击”两个字。这个场景还带来一个经验前端按钮点击后页面通常会紧接着发起下载请求如果下载请求也是同一个Servlet处理的你需要在下载逻辑之前先计数避免直接返回文件流时漏掉统计。我曾见过有人把计数放在finally块里结果文件下载异常时也计数了数据虚高。正确姿势是在下载动作真正开始前计数然后做下载确保“用户点了下载”这个事件和“服务器返回了文件”是严格先后关系。4.7 性能优化的几个方向说点经验。如果你的每个点击都要同步读库写库性能天花板很低。可以试试这样首先在Servlet内存中用一个Map维护多个计数器的值只在后台定时线程里批量刷数据库或者每次线程达到50次时刷一次其次使用数据库层做原子递增不要先SELECT再UPDATE避免两个步骤之间不一致再次考虑用异步Servlet或者Spring的DeferredResult把计数请求丢给队列处理但那样对初学者来说复杂度会上一个台阶。个人建议先把同步版本跑通、想明白再去碰异步和分布式否则容易混淆到底是容器问题还是你的线程池问题。我再列一个真实压测数据供参考一台普通4核8G服务器Tomcat默认线程池200同步内存版计数器可以压出每秒1万次以上的递增但同步写库版本每秒也就1500次左右瓶颈主要在数据库单行更新和连接获取。如果改成内存计数每50次批量写库吞吐能回到8000以上但会丢掉崩溃前最后一批数据。所以性能优化往往会牺牲少量一致性这个取舍要提前跟业务对齐。如果还想继续深入建议你把上面的版本依次改到“ServletContext AtomicLong 定时写库”再改成“数据库事务版本”最后用JMeter做一轮并发测试。我个人实际使用下来觉得Servlet点击计数器这个题目很适合拿来检验自己对Servlet生命周期的理解。你每换一种存储方式就会被迫重新思考一次线程安全、作用域和数据一致性的边界。这里面的大部分问题在Spring Boot里被框架悄悄帮你处理掉了但如果脱离了框架你就会发现这些基本功会直接影响你定位线上问题的速度。如果你也想练手建议从内存版开始然后逐步加一个ServletContextListener去读文件再接数据库最后再做异步每一步都会让你对“点击”这两个字有不同的理解。