不少朋友学完Java基础之后第一个纠结的问题往往是该拿什么项目练手。我的答案经常是同一个——图书管理系统。这个名字听起来确实“老掉牙”连很多培训班的课设案例都是它但你真把它做一遍、做扎实会发现它几乎覆盖了Java后端日常开发里最常用的那套东西CRUD、模糊查询、多表关联、事务、参数校验、前端交互。这篇分享里我会照着自己完整做过一遍的顺序从需求分析、数据库设计、技术选型到借书还书的核心代码实现再到跑通过程中一定绕不开的坑最后聊聊从“能交作业”到“能写进简历”这条进阶路线该怎么走。1. 一个“老掉牙”的项目为什么每个阶段的开发者都绕不开它一开始我也不理解为什么大家翻来覆去就爱做图书管理系统做个“在线点餐”或者“论坛社区”不是更时髦吗。后来我想明白了它能流行这么多年靠的不是新鲜感而是它作为练手项目实在太合适。1.1 业务模型天然贴近生活入门学习零门槛做任何项目第一个门槛其实是理解业务。做电商系统你得先搞清楚订单、优惠券、库存、退款那一大堆规则做金融系统更要命光“结算周期”这个概念就够琢磨半天。但是图书管理系统不一样图书馆怎么借书、还书、续借每个人从小到大都经历过需求一句话就能说清楚书要能录进去读者要能查得到借出去要记得住还回来要销得掉。业务不模糊你就能把全部精力放在代码本身的学习上而不是先花两周去补行业背景。1.2 麻雀虽小五脏俱全CRUD、多表、状态流转全都有图书管理系统看起来简单但它不是一个只能写“增删改查”的玩具。图书表、读者表、借阅记录表之间是典型的多对多关系你得解决关联查询借书操作至少涉及扣减库存、插入记录两步你得考虑事务还书操作要判断是否逾期这是业务状态流转搜索、分页、按时间筛选、统计报表这些又是后端系统的日常。也就是说一个完整项目的零件它基本都有而且每个零件都用得上。这是它比单纯“写一百道算法题”更有意思的地方你学完的知识能立刻在项目里找到落点。1.3 它还是课程设计、简历和面试的“标准素材”市面上相当多的初级工程师简历里第一个项目就是图书管理系统很多面试官也早就听过无数次这个名字。正因为听过太多次面试官反而不满足于“项目能跑起来”一定会追问并发借同一本书怎么办逾期记录怎么设计事务边界放在哪层为什么要独立建一张借阅记录表这些问题恰恰是这个项目里最能锻炼人的地方。把这些问题想透比项目本身“用了几个框架”重要得多。2. 功能盘点图书管理系统到底在“管理”哪些事写代码最忌讳一上来就建表、写接口脑子里的需求是乱的写出来的项目就会越改越拧巴。我习惯先把自己代进一个真实图书馆把“谁、在什么场景下、要干什么事”列清楚再翻译成功能清单。2.1 两类核心角色管理员和读者要做的事简化版系统一般不需要太复杂的权限体系但至少要区分两类人。管理员负责“管”图书的新增、编辑、下架读者的注册审核、冻结解冻处理借书还书查看借阅记录和逾期情况。读者负责“用”搜索图书、查看详情、查看自己的借阅记录、办理借书还书。如果系统做的是普通模式也就是管理员在前台替读者操作那读者角色只需要保留“查询”和“看记录”就够了。2.2 核心业务流程从入库到归还把核心流程走一遍才能知道表该设计成什么样。图书入库管理员登记书名、作者、ISBN、分类同时录入馆藏总数量。借书读者提交借阅请求后系统要检查这本书是否存在、当前可借数量是否大于0、读者状态是否正常然后扣减可借数量、生成一条借阅记录。还书定位到那条未归还的借阅记录更新实际归还时间恢复可借数量如果超过应还时间标记逾期。续借在未逾期的前提下延长应还日期一般限制次数。这套流程里最需要注意的是“检查库存”和“扣减库存”不能是两段孤立操作否则并发环境下会出现超借后面我会专门讲。2.3 功能清单拆解把系统边界定清楚我一般会先列一张非常具体的能力表格每一项对应后面一个或几个接口写代码时就能很清楚自己卡在哪模块功能点说明图书管理新增、编辑、删除、列表查询删除建议用逻辑删除或限制有关联数据时不可删图书检索按书名、作者、ISBN、分类模糊/精确查询分页展示读者管理新增读者、查看列表、冻结/解冻读者状态影响借书资格借阅管理借书、还书、续借核心业务必须走事务记录查询按读者、按图书、按时间范围筛选借阅记录用于催还和审计统计报表馆藏总数、借出总数、逾期数量可以简单数字也可以用图表展示如果你只是练手统计模块做简单数字就行但如果你想把它写进简历这个地方适合做点缀比如加几张可视化图表。3. 数据库设计三张表的关联与那些容易被忽视的字段细节很多新手项目失败不是因为代码写得差而是表设计一开始就埋了雷。图书管理系统虽然只有三张核心表但字段怎么定、主键怎么选、库存怎么表示每个决定后面都跟着一连串代码逻辑。3.1 三张核心表图书表、读者表、借阅记录表业务上图书和读者是多对多关系一个读者可以借多本书一本书能被很多人先后借走这种关系不能靠两张表直接表达而是拆出一个借阅记录表来转成两个一对多。下面是一个比较通用的MySQL建表参考CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 图书ID, isbn VARCHAR(20) UNIQUE COMMENT ISBN编号, title VARCHAR(200) NOT NULL COMMENT 书名, author VARCHAR(100) COMMENT 作者, category VARCHAR(50) COMMENT 分类, total_count INT NOT NULL DEFAULT 0 COMMENT 馆藏总数, available_count INT NOT NULL DEFAULT 0 COMMENT 当前可借数量, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书表; CREATE TABLE reader ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 读者ID, reader_no VARCHAR(30) UNIQUE NOT NULL COMMENT 读者编号, name VARCHAR(50) NOT NULL COMMENT 姓名, phone VARCHAR(20) COMMENT 手机号, status TINYINT DEFAULT 1 COMMENT 1正常 0冻结, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT读者表; CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL COMMENT 借出时间, due_time DATETIME NOT NULL COMMENT 应还时间, return_time DATETIME NULL COMMENT 实际归还时间, status TINYINT DEFAULT 0 COMMENT 0借出中 1已归还 2逾期归还, KEY idx_book_id (book_id), KEY idx_reader_id (reader_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT借阅记录表;3.2 主键选择、库存设计与日期字段先说主键。很多教材喜欢直接用ISBN做主键实际项目里却很少这么干。原因有三点ISBN会存在缺失、录入不规范的情况同一本书如果有不同版次ISBN也不一样更重要的是主键一旦选定后续做关联、做索引都会被这个不稳定字段绑架。所以我建议用自增ID当主键ISBN加唯一索引既能保持唯一性又不影响关联稳定性。再讲库存。这个细节特别容易被忽略图书表里我没有写“库存”这一个字段而是拆成总数量和当前可借数量。为什么要拆总数量是这本书的馆藏规模可借数量是此刻还能借出的册数二者是不同的问题。如果只用一个字段还书时做加回操作会很别扭逻辑删除了怎么办损坏了怎么处理拆开之后借书扣available_count还书加available_count总数一直稳定后面做统计也方便。最后是日期。不要为了“看得方便”就在数据库里存字符串后面排序和计算逾期天数都会变得很痛苦。统一用DATETIME或TIMESTAMPJava实体里用LocalDateTime数据库和大日期对象的映射关系一定要从项目一开始就定清楚。3.3 一个需要避免的设计把“是否借出”直接写在图书表我见过不少初版设计会在book表上加一个字段叫“是否借出”用1和0表示这本书是不是已经被借走了。这个设计在只有一个副本的情况下勉强能跑一旦馆藏多册问题立刻暴露一本书有十册其中三册外借、七册在馆请问is_borrowed填什么填1表示“有书被借走”填0表示“全都在馆”语义是错乱的。正确做法是看borrow_record表里有没有status0的记录来表示“当前有哪些书借出”。这也是为什么借阅记录表必须独立存在而不只是为了“多存一条历史”。3.4 索引与状态字段的合理维护借阅记录表里我建议给book_id、reader_id、status分别建普通索引因为业务查询基本都走这几个条件。但索引不是越多越好每张表每个字段都加索引写入性能会明显下降而且占用空间。状态字段用一个TINYINT就够了但代码里不要到处写魔法数字最好用常量类或者枚举统一管理比如0代表借出中1代表已归还2代表逾期已归还这个映射全项目只有一处定义。4. 技术选型手记从Servlet到Spring Boot我的分层思路表设计好之后下一步不是写Service而是先定技术栈。很多新手会纠结“我到底该学Servlet还是直接学Spring Boot”我的答案很直接分阶段不要一步跨太远。4.1 不同阶段的技术选择JSP/Servlet 还是 Spring Boot MyBatis如果你是Java入门后的第一个完整项目我非常推荐用Servlet JSP JDBC跑一遍。为什么因为这条路没有任何“魔法”。你能清楚看到浏览器请求怎么被容器接到怎么调用Java方法怎么通过JDBC连接数据库结果怎么渲染回页面。这个链路一旦通了后面学Spring MVC你会发现它只是帮你把这条链路规范化和简化了。如果你已经学过Spring Boot或者这个项目要用于课程设计和求职那就直接上Spring Boot Spring MVC MyBatis。开发效率高代码结构也更接近真实团队使用习惯。下面做一个直观对比技术方案适合阶段主要优点主要缺点Servlet JSP JDBC刚学完Java基础链路透明能看清底层配置繁琐代码重复多Spring Boot MVC MyBatis课程设计、求职项目开发快规范成熟自动配置有黑盒感Spring Boot Spring Data JPA想少写SQL、专注CRUD简单实体映射方便复杂查询调优难度更大4.2 Controller、Service、DAO各层职责与边界一个常见的坏习惯是把所有代码都堆在Controller里几十个方法全在前面处理业务逻辑看起来“能跑”后面加需求时就会痛苦到怀疑人生。分层的原则很简单Controller层只做三件事接收请求、参数校验、返回结果。具体图书能不能借它不关心。Service层放业务规则比如“未冻结读者才能借书”“库存不足不能借”事务也在这里控制。DAO/Mapper层只做数据访问负责把查询结果映射成对象。为什么要这么拆因为同一个业务规则可能被多个接口复用。比如“判断读者能否借书”这件事借书入口要用续借入口也要用如果你把它写在Controller里两个接口就得复制两遍改规则时容易漏。拆到Service层之后业务变化只改一处。4.3 事务为什么必须写在Service层借书这个操作最少涉及两步更新图书表的available_count减1再往borrow_record插入一条记录。假如第二步突然出错库存扣了但借阅记录没有数据就坏了。解决办法是让这两步放在同一个事务里要么全部成功要么全部回滚。在Spring框架里一般是在Service方法上标Transactional注解。为什么不标在Controller上因为事务是业务级的概念Controller管的是请求转发两者职责不同。标在DAO上粒度又太细一个借书流程要调用多个DAO方法事务覆盖不全。5. 借书还书的链路实现把需求翻译成Java代码数据库和技术栈都定下来之后就到了最核心的部分把“借一本书”这件事写成代码。下面用Spring Boot MyBatis风格做演示但思路对Servlet版同样适用。5.1 借书查书、校验、扣减库存、生成记录借书Service我建议按下面这个顺序写每一步都有明确目的Transactional(rollbackFor Exception.class) public Result borrow(BorrowRequest request) { // 1. 校验读者是否存在且状态正常 Reader reader readerMapper.selectById(request.getReaderId()); if (reader null || reader.getStatus() ! 1) { return Result.error(读者不存在或已被冻结); } // 2. 校验图书是否存在 Book book bookMapper.selectById(request.getBookId()); if (book null) { return Result.error(图书不存在); } // 3. 条件更新扣减可借数量 int updated bookMapper.decreaseAvailableCount(book.getId()); if (updated 0) { return Result.error(库存不足借书失败); } // 4. 插入借阅记录默认借期30天 BorrowRecord record new BorrowRecord(); record.setBookId(book.getId()); record.setReaderId(reader.getId()); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); record.setStatus(0); borrowRecordMapper.insert(record); return Result.success(借书成功); }注意第三步我强调的是条件更新不是先查出来再在内存里减。很多人习惯先select拿到availableCount判断大于0之后再update设置为减一后的值。这在单机单请求时没问题但两个请求同时读到时都会认为有库存然后各自插入借阅记录最终图书的可借数量变成-1。正确的写法是让数据库帮忙做判断UPDATE book SET available_count available_count - 1, update_time NOW() WHERE id #{bookId} AND available_count 0;这一步如果更新行数为0说明已经被别人借走了直接返回失败。再配合外层事务并发超借问题基本可以解决。5.2 还书定位记录、计算逾期、恢复库存还书流程相对借书更直观核心是找到那条“未归还”的记录再看是否逾期Transactional(rollbackFor Exception.class) public Result returnBook(Long recordId) { BorrowRecord record borrowRecordMapper.selectById(recordId); if (record null || record.getStatus() ! 0) { return Result.error(借阅记录不存在或已归还); } LocalDateTime now LocalDateTime.now(); record.setReturnTime(now); if (now.isAfter(record.getDueTime())) { long overdueDays Duration.between(record.getDueTime(), now).toDays(); record.setStatus(2); // 逾期已归还 // 如果业务需要生成罚款这里可以再插入一条罚金记录 } else { record.setStatus(1); // 正常归还 } borrowRecordMapper.updateById(record); // 还书后恢复可借数量 bookMapper.increaseAvailableCount(record.getBookId()); return Result.success(还书成功); }做逾期判定的关键是用当前时间和due_time做比较不要拿字符串硬比大小。天数计算可以用Duration.between(dueTime, now).toDays()如果业务要求“超过一天才算逾期”再在时长基础上做调整。5.3 查询与统计条件组合、模糊搜索与分页图书检索是项目里最频繁被点到的页面之一常见的搜索条件有书名模糊搜索、ISBN精确匹配、分类筛选。如果直接用SQL拼接条件一多维护难度就上去了。可以用MyBatis Plus的条件构造器代码更清晰public IPageBook searchBooks(BookQuery query) { LambdaQueryWrapperBook wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(query.getTitle())) { wrapper.like(Book::getTitle, query.getTitle()); } if (StringUtils.hasText(query.getIsbn())) { wrapper.eq(Book::getIsbn, query.getIsbn()); } if (StringUtils.hasText(query.getCategory())) { wrapper.eq(Book::getCategory, query.getCategory()); } wrapper.orderByDesc(Book::getCreateTime); return bookMapper.selectPage( new Page(query.getPageNum(), query.getPageSize()), wrapper ); }使用框架自带的QueryWrapper还有个好处它是一个条件构建器内部会帮你把空值判断和SQL拼接隔离开不需要自己写一堆if else拼字符串。查询借阅记录的时候也是一样按readerId或者bookId筛选再按借阅日期倒序排列即可。5.4 给前端提供的数据结构与异常处理一个容易被新手忽略的事所有接口最好返回统一结构。我习惯定义一个Result类里面有三个字段code表示成功或失败message给前端提示data放业务数据。好处很直接前端不用每次猜测返回体长什么样后端所有异常也有统一的表达方式。再加一个RestControllerAdvice做全局异常处理遇到未知异常就统一返回“系统繁忙”而不是让页面弹出一堆看不懂的报错。6. 跑通过程中的高频问题从乱码到事务失效项目能写出来不等于能顺利跑通。这一章我总结了自己和身边朋友踩过的最多的一批问题每一个都是真实环境里会反复遇到的。6.1 数据库连接串里的时区与驱动版本问题用MySQL 8连接时最常见的报错之一是关于serverTimezone的。数据库连接串如果不带时区参数可能插入时间后看到的数值差了好几个小时或者连接阶段直接报时区相关的异常。我习惯用下面这一段配置jdbc:mysql://localhost:3306/library?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai驱动版本也要和数据库版本匹配。如果连的是MySQL 8不要在pom里放一个5.x的驱动连接协议会报“Communications link failure”这类让你误以为是网络故障的错误。排查此类问题时先把数据库版本和驱动版本两列对照着看能省很多时间。6.2 中文乱码的三个来源中文乱码在SSH时代几乎是必踩的坑现在Spring Boot下稍微好一点但仍然分三种情况页面乱码、请求参数乱码、写入数据库乱码。排查原则是先定位哪一步开始乱码。如果是页面显示乱码检查模板文件是否设置了UTF-8编码前端页面里有没有meta charsetUTF-8如果是写入数据库后发现乱码检查建表字符集是否使用了utf8mb4以及连接串是否带characterEncodingutf8。注意“utf8”和“utf8mb4”在MySQL里不是一回事utf8mb4才是完整的大字符串方案。6.3 事务失效最典型的几种情况很多知识点平时看着会写代码时才发觉并不牢固。事务失效最常见的四种场景第一启动类上没加EnableTransactionManagementSpring Boot项目一般会自动配置但单独搭Spring项目时会遇到第二在Service内部的同一个类里一个方法调用另一个带有Transactional的方法代理不生效事务就没了第三把异常catch住吞掉了事务感知不到异常自然不会回滚借书时库存扣了记录没插入就是这种坑第四抛出的是业务自定义异常但注解上没写rollbackFor Exception.class默认只回滚运行时异常。我用一个容易记的口诀总结事务方法必须是public由外部调用异常往外抛回滚范围要写明。6.4 库存超借两个用户同时借最后一本书之前借书代码里已经用条件更新解决了这个问题这里再强调一下为什么“先查询再判断”不行。假设最后一本书用户A和用户B都查询到available_count1也都通过了校验然后A执行update把数量改成0B也执行update再把数量改成-1。这时候没有报错数据却已经错了。条件更新WHERE available_count 0配合数据库的行锁能保证A成功减1后B的那条update影响行数为0从而让B走失败分支。如果希望更严谨可以在book表加一个version版本号字段做乐观锁但课程设计阶段用条件更新加事务已经足够了不需要过度设计。6.5 日期处理LocalDateTime与前端显示实体类用LocalDateTime后很多人在序列化阶段会遇到一个奇怪现象前端拿到的日期变成一串数组比如[2025, 1, 15, 10, 30]。这是Jackson不知道该把这个Java时间对象序列化成什么格式导致的。解决办法是在配置里统一处理约定所有日期字段输出格式比如yyyy-MM-dd HH:mm:ss这样前端拿到的就是一对规则的字符串。另一个坑是后端拿前端传回来的日子字符串反序列化为LocalDateTime时格式不一致直接报错所以前后端的日期格式约定最好一开始就明确。7. 从“能交作业”到“能写进简历”扩展与重构方向如果你的项目已经顺利跑通接下来要考虑的是“再加点什么”。这一步的关键是分清两个问题这个扩展能不能讲清业务价值扩展用到的技术是不是这个项目真实需要的7.1 功能层预约、续借、逾期提醒、统计图表预约功能很实用当库存为0时读者登记预约图书被归还时通知预约者优先借阅。这个功能会引出“预约排队”和“通知机制”可以做得很浅也可以逐步加深。续借功能则要在还书判断前增加校验只有在未逾期、且续借次数没超限时才允许延期。逾期提醒适合用定时任务扫描未归还记录再发站内信或邮件这里能接触到任务调度。统计图表可以把按月的借阅量、热门图书排行展示出来前端用图表库后端写聚合SQL非常能体现业务理解能力。7.2 工程化单元测试、缓存、日志、统一异常与接口文档功能之外工程化方向是简历上更有区分度的部分。给借书和还书Service写几个单元测试重点测并发超借、逾期判定、读者冻结这些边界情况面试时能拿真实测试代码来讲说服力很强。查询热点数据比如图书列表可以加一层缓存但要注意缓存和数据库的一致性更新。日志方面结合AOP记录关键操作的访问日志可以讲一讲“怎么通过日志追踪一次借书请求的完整流程”。接口文档如果用的是Spring Boot整理成REST风格接口配合OpenAPI注解生成文档也会比一堆无规范的Servlet接口专业得多。7.3 哪些扩展是伪需求什么时候该收手这个提醒我觉得比“怎么做扩展”更重要。很多人在小项目里硬上微服务、分布式事务、Kafka消息队列、Docker集群看起来技术栈非常豪华但如果问你“这个图书管理系统到底哪里需要分布式事务”你自己都说不出来那这就是典型的伪需求。图书管理系统的并发量级用一个规范的单体应用加合理索引完全能扛住硬堆分布式组件只会暴露你对技术边界的不理解。与其追求技术名词的数量不如把借书并发、逾期规则、事务边界、数据库索引这些真正会被追问的点做深。说白了面试官怕的不是项目简单怕的是你对自己写的代码说不出“为什么”。最后说点我自己的体会。我最初做这个项目时也犯过把“是否借出”直接写进图书表的错也遇到过事务失效导致库存数据错乱的问题花了整整一个晚上对比日志才找到原因。但恰恰是这些踩坑的过程让我真正理解了为什么要独立建记录表、为什么要条件更新、为什么要分层。如果你们正在从Java基础过渡到第一个完整项目我的建议是别急着追求华丽先把借书还书这条主线彻底打通把每个“为什么这么做”都能讲明白。能讲清楚这个项目的每一处设计决策它就不再只是一个作业而是你技术能力的一块真实拼图。