
带实习生和面试这几年我有个体会很多人简历里写着微服务、分布式真被问到细节反而讲不出所以然倒是一些看起来不起眼的项目能把Java基本功暴露得清清楚楚。报名系统就是这么个典型——活动报名、比赛报名、课程选课本质上是同一套业务它让用户注册登录、活动展示、名额扣减、报名记录管理这些功能挤在一个系统里还要在多人同时抢一个名额时保证数据一致性。基于Java的报名系统表面上是增删改查内核却牵扯到面向对象建模、并发控制、权限隔离、数据一致性一堆问题。把这些问题想明白比空泛地背十遍面试题都管用。下面我就按自己从头做这套系统的完整思路走一遍先建模重点讲并发扣名额时数据一致性怎么落地再把行级权限、深拷贝、名单导出这些容易被忽略的细节补上最后聊聊从开发环境到上线部署以及这段经验写进简历该怎么讲。1. 报名系统这个选题为什么值得用Java反复做一遍1.1 它把传统业务系统和高并发场景焊在了一起我最早做报名系统是帮朋友的高校社团做比赛报名当时以为真就是CRUD活动表、用户表、报名表三个接口完事。结果第一次试运行100个名额的活动一瞬间涌进300多个请求后台一看报名记录超了十几条。那一刻我才意识到报名系统最迷人的地方就在这里它的业务模型足够小小到可以在一周之内全部实现但它遇到的问题和电商秒杀是同一类问题——并发、超卖、幂等、数据一致性。你不需要先搭一套完整的秒杀架构才能学到这些东西一个报名接口就全有了。这种小系统遇到大并发的张力特别适合练手。比如我用策略模式把普通活动、限额活动、阶梯优惠活动的名额扣减策略抽象出来再用工厂根据activity.type创建对应的处理器。这一套写下来对面向对象设计模式的理解比看十遍理论文章都深刻。面向对象在Java里不是语法是当你看到多个if-else在膨胀、多个状态字段在互相打架时自然而然想重构的意识。1.2 Java面试的高频考点基本都能在这里找到落点我粗略盘点了一下这套系统覆盖的知识点发现它几乎能当一本Java面试题的实体索引面向对象与设计模式用户、活动、报名记录是天然的领域对象策略模式处理不同活动类型的扣减模板方法定义报名前的校验流程状态模式管理报名状态流转。集合与Lambda/Stream排行榜用TreeSet或Stream排序DTO转换用Map映射内部类与Lambda的配合也能在这里找到真实场景。数据一致性这是核心中的核心乐观锁、条件更新、唯一索引全部能用上。权限与数据隔离行级权限、越权防护中高级岗位必问。对象深拷贝VO/DTO转换时的引用共享问题能过滤掉一大批只会用BeanUtils的候选人。正则表达式手机号、邮箱校验属于Java基础应用。POI与文件导出虽然不是Java核心但团队里总需要有人会适合做项目加分项。说白了报名系统是一个小切口、深纵深的项目。横向看它覆盖了Java开发几乎全栈纵向看每一个点都能往下追问三层。这种项目比那些一上来就基于微服务的电商系统要实在得多也更适合写进简历。2. 动手前先建模角色、数据表与报名状态机写代码之前先花一个晚上把业务边界画清楚。这一步看似浪费时间实际上能避免后面改表改到崩溃。报名系统的核心不是报名这个动作而是用户、活动、报名记录三张表之间的状态流转。2.1 三类角色、一条主流程先把边界划清楚我习惯把角色拆成三类管理员负责创建活动、设置名额和时间、审核报名名单、导出Excel、在活动异常时关闭活动。用户报名者浏览活动列表和详情提交报名、取消报名、查看我的报名、活动当天签到。系统定时任务自动把过期活动置为已结束清理超时未占用的名额发送通知。主流程并不复杂管理员发布活动用户看到活动后报名系统做名额校验和防重校验管理员审核通过用户到场签到。要注意的是报名不是一次性动作后面还有取消、审核、签到所以从一开始就要把数据模型设计成能承载整个生命周期的结构而不是只存一条记录。在这个阶段还要想清楚两个边界问题一个活动允不允许重复报名取消后的名额能不能被重新释放这两条直接决定数据模型怎么建。我默认的规则是一个用户对同一活动只能有一条有效报名记录取消后释放名额并可以重新报名。2.2 核心表设计报名关系表才是真正的灵魂表结构我会保持极简。用户表、活动表、报名记录表三张核心表字段没必要一开始就堆得满满当当字段越多后续维护越乱。下面是我最终稳定的核心设计活动表activity包含id、title、total_quota总名额、signed_count已报名数冗余字段、status草稿/发布/已满/已结束/已取消、start_time、end_time、version乐观锁用先留着。用户表user包含id、name、phone、email、student_no学号/工号、password_hash。报名记录表enrollment包含id、activity_id、user_id、status已报名/已通过/已取消/已签到、created_at。这里最反直觉的设计是活动表里那个signed_count冗余字段。很多人会写SELECT COUNT(*) FROM enrollment WHERE activity_id?来统计已报名人数这个写法在数据量小的时候没问题但报名记录积累到几十万条之后InnoDB的COUNT是要扫描索引页的不是O(1)操作在高频列表接口里会明显拖慢响应。所以我在每次报名成功的事务里让signed_count精确1查询时直接读这个字段。代价是要保证它和报名记录实时一致因此写入必须在同一事务里完成——这就是后面并发章节的伏笔。2.3 状态机从报名到签到的全生命周期状态管理最怕散落在业务代码里变成一堆if判断。我把活动状态和报名状态都做成显式的状态机。活动状态草稿(0) → 发布(1) → 已满(2) → 已结束(3)某些特殊场景可以取消(4)。报名状态已报名(1) → 已通过(2) → 已取消(3)已通过后活动当天可变为已签到(4)。为什么要把状态机显式化因为状态之间不是随便能跳的。比如活动已经结束就不能再报名用户已经签到就不能再取消。这些规则如果不固化在代码里光是产品随口一句加个新状态就能把开发逼疯。我通常在每个状态字段的setter或者专门的领域方法里做状态校验不允许非法跳转。状态值统一用整数存库、用Java枚举在代码里表达两边加注释对照排查问题时一眼能看出当前数据处于什么阶段。3. 并发抢名额数据一致性从入门到能落地的四种实现3.1 先查再写为什么必然超卖最初版本的报名逻辑大概是这样的Activity activity activityMapper.selectById(activityId); if (activity.getSignedCount() activity.getTotalQuota()) { return 名额已满; } activityMapper.increaseSignedCount(activityId); enrollmentMapper.insert(enrollment);看起来没毛病先查有没有名额有就加一再插记录。可一旦两个请求同时通过select读到同一个signedCount比如都是119活动名额120它们都会通过if判断然后各自加一最后signedCount变成121。这就是经典的检查-执行竞态窗口问题。我那次社团活动超卖就是这么出来的。这种问题不是运气不好而是必现只是取决于并发窗口撞上的概率。并发越高超卖数量可能越夸张。所以数据一致性不能靠业务层判断得把判断和写入做成一个原子操作。3.2 方案AUPDATE带上名额条件让数据库做原子判断MySQL里最简单可靠的写法是把名额判断下沉到UPDATE语句这也是我最终主力方案Transactional public boolean enroll(Long userId, Long activityId) { int updated activityMapper.decreaseQuotaIfAvailable(activityId); if (updated 0) { return false; // 名额已满或活动状态不对 } try { enrollmentMapper.insert(new Enrollment(userId, activityId, STATUS_ENROLLED)); return true; } catch (DuplicateKeyException e) { throw new BizException(您已报名请勿重复提交); } }对应SQLUPDATE activity SET signed_count signed_count 1 WHERE id #{activityId} AND status 1 AND signed_count total_quota;这里UPDATE的where条件是关键。MySQL对单行UPDATE会加行锁同一时刻只有一个事务能对这条活动记录做扣减。两个并发请求会排队执行第二个执行时signed_count已经变成120where条件不满足影响行数为0直接返回报名失败。数据库层面就把超卖堵死了。注意事务边界扣减名额和插入报名记录必须在同一个事务里任何一步失败整体回滚否则会出现名额扣了但记录没插进来或者反过来。另外DuplicateKeyException在Spring事务里需要小心处理直接catch后返回提示会让事务处于不一致状态正确做法是抛出业务异常触发回滚让那条重复插入回滚掉。记住业务异常如果继承的是RuntimeExceptionSpring默认才会回滚自定义checked异常需要显式指定rollbackFor。3.3 方案B乐观锁version字段与重试机制如果不想在where条件里塞业务判断也可以用经典乐观锁。活动表加version字段每次更新带上旧版本号int updated activityMapper.updateQuotaByVersion(activityId, version, 1); if (updated 0) { // 版本冲突说明期间已有人改过重新查一遍再试 }UPDATE activity SET signed_count signed_count 1, version version 1 WHERE id #{activityId} AND version #{version};乐观锁的核心是用版本号做比较和更新原子化。旧版本号一旦被别的请求改掉当前更新就会影响0行业务层据此判断冲突并重试。它的优点是并发冲突不频繁时简单够用不用引入中间件缺点是冲突率高时重试次数多对数据库压力反而更大不适合抢购这类超高竞争场景。我实际做的时候建议并发量在几百QPS以内直接方案A就够乐观锁可以作为代码层的一种兜底或展示能力的选项但不要为了用而用。3.4 方案CRedis Lua脚本做前置扣减DB异步兜底如果做到全校级别的选课瞬时并发几千上面两种方案都会在数据库行锁上排队吞吐量被锁竞争卡住。这时候的常见做法是把库存前置到Redis用Lua脚本保证扣减原子性-- KEYS[1] 是活动库存key返回1表示扣减成功0表示不足 local stock tonumber(redis.call(get, KEYS[1])) if not stock or stock 1 then return 0 end redis.call(decrby, KEYS[1], 1) return 1Java侧先执行Lua扣减成功了再写报名记录写库仍然用唯一索引防重。写库失败要回补Redis库存同时用定时任务对账保证最终以数据库为准。这里要明确一点Redis扣的是前置名额数据库才是最终事实。Redis的优势是原子操作快扛得住高并发但它不是可靠存储进程重启可能丢数据。生产环境必须设计降级方案——Redis挂了就走方案A的数据库扣减。很多系统在这里翻车就是把Redis当成了唯一库存源Redis一重启库存和报名记录就乱了。3.5 唯一索引与最终选型不是所有场景都要上Redis不管用上面哪种方案报名记录表上的唯一索引(activity_id, user_id)都是必须的它保证了一个用户对同一活动只能有一条有效插入记录是防重复报名的最终防线。前面所有校验都失效时数据库还能用约束兜底这是多一层保险。至于最终选型我的结论很朴素人数少、几百并发以内直接用方案A加唯一索引一年省下的服务器和心力都是实实在在的真的遇到秒杀级流量再上Redis加Lua加异步落库并配套降级。报名系统往往是看起来几十万人实际峰值几百QPS把方案A做到极致已经赢了大多数人。这是我踩过坑之后最想说的经验——不要为了炫技给系统引入不必要的复杂度。4. 行级权限与数据隔离权限控制不能只靠SQL加个where4.1 登录态识别从ServletRequest到LoginUser注入报名系统的权限核心是用户只能看自己的报名记录、只能取消自己的报名、管理员只能管理自己权限范围内的活动。最基础也最关键的一点是当前用户身份必须从登录态里取绝不能信前端传过来的userId。我在项目里用Spring MVC的HandlerMethodArgumentResolver做了个LoginUser注解写起来很清爽Component public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(LoginUser.class) parameter.getParameterType().equals(UserVO.class); } Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { Long userId UserContext.getUserId(); // 由拦截器从token解析后放入ThreadLocal return userService.loadUserVO(userId); } }这样每个接口只需写一个UserVO currentUser参数框架自动注入当前登录用户。不仅省代码更是从架构上杜绝了伪造userId查别人数据的路径。这个被问烂的越权漏洞在项目里就这样被结构性地规避了。4.2 行级权限的三层落地查询、操作与数据权限第一层是查询层。查我的报名时SQL必须带user_id条件而不是查出全表再在内存里过滤这个不用多解释。第二层是操作层。取消报名、修改报名备注这类操作UPDATE和DELETE语句里都要带user_id条件。很多人只注意了查询条件忘了操作语句结果一个越权取消接口把别人整条记录干掉了。我喜欢在每个Mapper语句里强制要求带上归属条件实在不行就在Service层先查一次归属再操作。第三层是数据权限。管理员不是所有人都能看全部数据比如社团管理员只能看本社团的活动报名。实现方案有MyBatis拦截器动态拼SQL也有更直白的每个查询显式传机构ID。拦截器方案看起来酷但很容易出现漏加、拼错、SQL注入的坑我建议普通项目不要过度封装。显式传条件虽然啰嗦但每个查询的语义一眼可见排查问题也没那么玄学。行级权限做得好不好最直接的观察点就是能不能通过枚举ID把别人的记录翻出来。能翻出来就是没做隔离。4.3 实体与VO分离深拷贝不是BeanUtils一把梭权限隔离还牵扯到一个Java基础问题——对象深拷贝。实体类里有密码、手机号这类敏感字段不能直接返回给前端所以需要实体转VO。很多人直接BeanUtils.copyProperties浅拷贝的两个典型坑是嵌套对象共享引用改VO里某个子对象会改动实体集合字段也是引用传递一个地方改了另一处看到的是同一份List。我的方案是引入MapStruct编译期生成转换代码效率和安全性都远高于反射。示例Mapper public interface UserConverter { UserConverter INSTANCE Mappers.getMapper(UserConverter.class); Mapping(target password, ignore true) Mapping(target phone, ignore true) UserVO toVO(User user); }字段映射关系显式、编译期检查、无反射损耗。项目里用上深拷贝之后很多接口返回的数据被前端改坏缓存对象被意外污染的疑难杂症会自然消失。这个话题看似基础却是面试里最能分辨候选人是否真写过代码的点。5. 从查询列表到名单导出性能、校验与POI的日常5.1 报名列表排序和分页索引设计比手写排序更重要活动列表要按开始时间排序报名列表要按报名时间倒序、按状态过滤。很多人看到排序就想到冒泡排序其实业务数据的排序请交给数据库Java里最多用Collections.sort或Stream.sorted处理内存中的小集合。面试考冒泡排序考察的是算法思维业务代码里手写排序纯属给自己挖坑。真正的性能点在索引。比如查询某活动的报名名单按状态过滤按报名时间倒序我会建一个复合索引(activity_id, status, created_at)利用最左前缀原则让过滤和排序都走索引。这里有个容易被忽略的点不要只看SQL执行快不快要用EXPLAIN看是否走了预期索引。报名系统初期数据量小全表扫描也是毫秒级等数据量上来再发现索引问题就晚了。分页也有讲究。LIMIT 100000, 20这种深翻页在数据库里要扫描前10万行再丢弃慢是必然的。报名系统很多场景只需要下一页而不是任意页码那就可以用游标分页WHERE id ? ORDER BY id DESC LIMIT 20。这个优化在导出大数据量时尤其有用下面导出名单时还会用到。5.2 后端正则校验手机号、邮箱与Java字符串转义报名表单的手机号、邮箱、学号都要校验这属于Java正则表达式的经典场景。拿手机号来说private static final Pattern PHONE_PATTERN Pattern.compile(^1[3-9]\\d{9}$); public boolean isValidPhone(String phone) { return phone ! null PHONE_PATTERN.matcher(phone).matches(); }两个细节。第一Java字符串里反斜杠要转义所以正则里的\d在Java代码里要写成\d。第二Pattern要定义成static final预编译不要每次校验都Pattern.compile那是浪费。校验位置必须在后端也来一遍前端校验只是体验后端校验才是安全。报名接口被脚本机器人刷的时候正则校验是挡第一层垃圾数据最简单的武器。5.3 POI导出Excel5000人名单不能把内存撑爆管理员要导出报名名单Apache POI是Java最常用的库。但直接用XSSFWorkbook一次性加载5000行数据内存会扛不住。我用SXSSFWorkbook流式模式解决它在内存里只保留最近N行其他行刷到磁盘try (SXSSFWorkbook workbook new SXSSFWorkbook(500)) { Sheet sheet workbook.createSheet(报名名单); int rowNum 0; try (ResultSet rs enrollmentMapper.pageQueryForExport(activityId, lastRowId, pageSize)) { // 游标翻页 流式写行避免一次性加载全部数据到内存 // 每行写入sheet然后 rowNum } // 设置ContentType并写出 }配合前面说的游标分页查询即使导出上万人的名单内存也稳定。如果有同事问Java能不能在Word里生成图表POI对Excel图表支持相对成熟Word图表原生支持很有限通常的做法是Excel生成图表后作为OLE对象嵌入Word或者走模板渲染。技术选型上建议先搞清需求到底是报表还是文档别上来就选错库。6. 从能跑到能上线环境坑、部署与压测的完整链路6.1 JDK版本与Lombok最先翻车的往往是环境问题开发环境和生产环境的JDK版本不一致是我见过最多的翻车现场。团队里有人用JDK8、有人用JDK17代码里用了新语法CI服务器上是老编译器一打包就炸。我的建议是统一用LTS版本JDK8、11、17都行但团队必须统一pom里设置maven.compiler.source和target跟你选用的版本一致。还有一个非常经典的坑Lombok和JDK版本不兼容。你可能会看到类似you arent using a compiler supported by lombok, so lombok will not work的报错翻译一下就是当前Lombok版本不支持你用的JDK。解决方案很朴素升级Lombok到支持当前JDK的版本或者统一降JDK。不要为了一个注解处理器把整个项目环境搞复杂。Windows开发机上配置JAVA_HOME和PATH是老生常谈每次换机器都会有人卡住。我的经验是JAVA_HOME配置到JDK根目录PATH里加%JAVA_HOME%\bin命令行执行java -version确认无误再开工。6.2 打包、后台运行与日志从Windows到LinuxSpring Boot项目打成fat jar后部署很简单生产服务器上可以直接用nohup或者systemd。Spring Boot内置了Tomcat容器所以不用单独装Tomcat这也是开发到上线效率高的原因之一mvn clean package -DskipTests scp target/enrollment-system.jar rootserver:/opt/app/ ssh rootserver nohup java -jar /opt/app/enrollment-system.jar --spring.profiles.activeprod /opt/app/logs/startup.log 21 生产环境最好用systemd管理进程崩溃自动重启这也是很多初学者没养成的习惯。日志方面logback-spring.xml里配置按天滚动和大小限制保留30天size和totalSizeCap都设置成合理值不然跑上一年日志能吃掉几个G磁盘。异常日志和业务日志分开文件出问题时先看error日志定位速度快很多。6.3 压测与连接池一次压测就暴露瓶颈上线前我做了一次简单的压测用ab直接打报名接口ab -n 10000 -c 200 -p payload.json -T application/json http://localhost:8080/api/enroll结果非常典型数据库连接池先被打爆应用日志里全是连接超时。连接池参数不是越大越好HikariCP作者给过一个经验值——最大连接数约等于CPU核数乘以2再加上磁盘数连接数过多反而因为上下文切换拖垮性能。这个反直觉的点我建议每个项目都用压测验证一遍。压测的目的不是把服务器压垮而是找到系统的瓶颈水位。报名系统一般压到能稳定支撑预期的峰值并发再留30%冗余就够了。为了几万一年的流量堆机器是对资源的浪费这个度要自己拿捏而拿捏的依据就是压测数据。7. 简历上那一行项目经验面试官想听什么7.1 项目描述的第一行决定了面试官接下来问什么很多人的简历写本项目基于Spring Boot实现报名系统的基本增删改查面试官看一眼就不想继续了。真正好的写法是这样的设计并实现校园活动报名系统支撑2000人同时在线报名。通过数据库条件更新与唯一索引解决并发超卖和重复报名问题基于参数解析器实现登录态识别与行级数据权限隔离采用SXSSFWorkbook流式导出实现5000人名单的稳定导出。关键不是项目名而是规模、问题、方案、效果四个要素。面试官看到这个描述第一反应大概率是超卖怎么做的行级权限怎么实现的导出为什么不卡这些问题全都能对应到你前面写的代码。7.2 最容易被追问的三个技术点提前自己预演我在面试里最常追问的点是为什么不用分布式锁很多人到这里会紧张。我自己的答案是如果系统只有单库部署数据库条件更新本身已经原子化引入分布式锁和Redis反而增加故障点和运维成本只有多实例、流量大时才需要考虑分布式锁或Redis扣减。这个回答传递的是我知道有更高级的方案但我能判断什么时候该用它比背出一堆中间件名字有用得多。还有一个高频问题是深拷贝。被问到时别急着背方案先分清楚场景简单DTO用BeanUtils可以嵌套对象用MapStruct或手动映射。面试官想听的是你理解浅拷贝的引用共享问题而不是只会用一个工具。最后是数据一致性的理解。记住一个原则同一事务内靠数据库约束跨系统靠对账补偿。报名系统是个体量合适的项目正好用来把这三个问题想透。我每次面试发现能把报名系统讲得有条理的人沉淀能力通常都不差。把一个小项目做深比铺一堆半懂不懂的大项目更值钱。