ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java旅游管理系统设计与实现:从CRUD到高并发后端架构

Java旅游管理系统设计与实现:从CRUD到高并发后端架构 简介这份资源是一份基于JSP技术的旅游管理系统设计与实现完整文档面向具备一定Java Web基础、希望学习B/S架构项目开发的技术人员。系统以MyEclipse8.5为开发工具采用SqlServer2012数据库和Tomcat6.0服务器功能涵盖旅游景点管理、线路管理、在线预订、网站论坛及公告管理等模块并区分管理员与会员两类用户平台能有效解决旅游信息整合与在线服务效率问题。包内为1个docx格式的设计文档压缩包约934KB内容包含摘要、需求分析、数据库设计、功能模块说明及详细目录结构便于按章节查阅。已有67人浏览学习适合作为课程设计、毕业设计或入门实践参考。通过学习该文档读者可掌握从可行性分析到系统实现的关键环节了解权限管理、用户体验优化等设计思路为独立开发同类Web系统提供完整参考。1. 以项目练手启动 Java 后端能力升级“基于 java 的旅游管理系统设计与实现”这类标题大体上会出现在两类场景里在校生拿来做毕业设计以及准备跳入 Java 后端岗位的开发者把它当成一个“能讲清楚”的项目经验。它的基本面并不复杂——无非是景区信息、线路规划、订单、用户评论等几类业务数据的增删改查。但如果只把它当一个 CRUD Demo 来写那注定只有“会跑”的水平到面试环节往往扛不住追问。比如用户的并发下单怎么处理景区检索性能不行怎么办会话状态怎么在分布式环境下保持一致。实际上这些点正是把项目从“作业”拉到“工程”的关键差异。本篇文章围绕“如何设计与实现一个可落地、可演进、可写进简历的旅游管理系统”展开从技术栈选型、核心表结构、接口实现到缓存与并发优化都会给出可抄作业的方案。同样的标题有人只能写出千篇一律的 CRUD有人却能包装成一套体现设计能力的完整后端系统——差异就在这里。2. 技术栈选型与工程骨架搭建Spring Boot 3 MyBatis Plus 最小可行方案2.1 为什么是 Spring Boot 而不是 SSH/JSP 老套路很多年代久远的毕设教程还在使用 JSP Servlet JDBC 那套组合JSP 页面里塞 Java 代码数据库连接靠 DriverManager 手动创建。这种方案并不是不能跑但它和当前主流的 Java 后端开发方式差距太大尤其是单体应用常见的分层结构、依赖注入、自动配置都不好体现。如果读者在搜索“java 旅游管理系统”时看到的大部分实现都是 JSP 版本不用奇怪——这属于历史遗留的教学惯性。从工程化角度来说Spring Boot 是更合理的选择。它通过 starter 机制把 Web 容器、数据访问、参数校验、日志等能力封装成“开箱即用”的依赖开发者只需要关注业务代码不需要处理大量的 XML 配置。并且 Spring Boot 3 基于 Spring Framework 6要求 JDK 17 起步这正好是当前多数企业级项目正在迁移或已经使用的版本基线。哪怕你目标岗位是“Java 开发工程师”而不是“Spring 专家”把 Spring Boot 3 用熟练也是基本盘。另外一个选择点是 MyBatis Plus。虽然 JPA/Hibernate 也是官方推荐的持久层方案但 MyBatis Plus 在国内中小型项目里的普及率非常高它支持单表 CRUD 的零 SQL 操作同时还能在复杂查询时手写 SQL灵活性和可控性都更好。对于订单、线路这类业务手写 SQL 的调试体验远胜于 Hibernate 自动生成的 SQL。2.2 最小工程结构与关键依赖清单工程结构上采用常见的分层模式controller只做参数接收和结果封装service负责业务规则组合mapper层处理持久化。下面是一个可以直接启动的最小工程目录树travel-system/ ├── pom.xml ├── src/main/java/com/example/travel/ │ ├── TravelApplication.java │ ├── controller/ │ │ ├── ScenicController.java │ │ ├── OrderController.java │ │ └── UserController.java │ ├── service/ │ │ ├── ScenicService.java │ │ ├── OrderService.java │ │ └── UserService.java │ ├── mapper/ │ │ ├── ScenicMapper.java │ │ └── OrderMapper.java │ ├── entity/ │ │ ├── Scenic.java │ │ └── Order.java │ ├── dto/ │ │ ├── ScenicQueryDTO.java │ │ └── OrderCreateDTO.java │ └── common/ │ ├── Result.java │ └── PageResult.java └── src/main/resources/ ├── application.yml └── mapper/ ├── ScenicMapper.xml └── OrderMapper.xml这样的分包是 Java 后端最常见的规范化惯例。controller 层不直接操作数据库service 层不出现 HttpServletRequest 相关代码mapper 只负责 SQL 映射职责边界清晰之后后续引入 Redis、MQ 或者改造权限体系都不会动到骨架。pom.xml 中需要引入的核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency注意mybatis-plus-boot-starter的版本不能随便选要和你使用的 Spring Boot 主版本兼容。如果用的是 Spring Boot 3.x那么 MyBatis Plus 需要 3.5.5 以上版本低于这个版本会出现ClassNotFoundException之类的低级错误。Lombok 的作用是省略实体类的 getter/setter减少样板代码但团队协作时要统一 IDE 插件版本否则会出现“代码编译不过但 maven 命令能过”的诡异情况。2.3 统一返回结构与参数校验接口设计上第一步就是约定统一的返回结构。很多初学项目会出现每个接口返回格式都不同、前端需要单独适配的情况。这里直接使用一个固定的ResultT类Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }统一返回结构的意义不止是好看更重要的是前端可以封装同一套拦截逻辑——code 为 200 才处理数据否则直接弹出 message。后端做全局异常处理时也会轻松很多例如参数校验失败、业务异常、系统异常都有明确的 code 和 message而不是直接抛出一堆堆栈到前端。参数校验方面建议直接使用spring-boot-starter-validation通过注解解决问题。比如接收分页参数的 DTOData public class ScenicQueryDTO { NotNull(message 页码不能为空) private Integer pageNum; NotNull(message 每页大小不能为空) private Integer pageSize; private String keyword; private Integer categoryId; private String sortField; private String sortOrder; }controller 上配合Validated开启校验参数不合法就直接抛出MethodArgumentNotValidException再交给全局异常处理器输出统一格式。这一套组合拳是当前 Java 后端接口开发的标准动作不需要额外引入重量级校验框架。3. 核心表结构与数据访问实现景点、线路、订单怎么建模3.1 从业务反推表结构设计旅游管理系统的核心业务离不开三类实体用户、景点/线路、订单。围绕这三类实体表结构设计的关键不是“建几张表”而是搞清楚多对多关系如何拆解。用户和线路之间是多对多关系——一个用户可能下单多条线路一条线路可以被多个用户购买。如果直接把线路信息冗余到订单表里后续线路价格调整就会出现历史订单价格被篡改的问题。因此常见的做法是拆成用户表、线路表、订单表和订单明细表。线路表放的是“线路模板”比如“云南大理 5 日游”包含出发地、目的地、价格、出发日期区间等属性。订单表记录“谁在什么时间买了什么线路”订单明细表则记录某条线路在某一团期下的具体信息包括人数、单价、总价。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), avatar_url VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 用户表; CREATE TABLE t_scenic ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_id BIGINT, region VARCHAR(100), description TEXT, cover_url VARCHAR(255), price DECIMAL(10, 2), status TINYINT DEFAULT 1 COMMENT 1-上架 0-下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 景点/线路表; CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL UNIQUE, status TINYINT DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消, total_price DECIMAL(10, 2), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 订单表; CREATE TABLE t_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, price DECIMAL(10, 2), quantity INT, total_price DECIMAL(10, 2) ) COMMENT 订单明细表;t_order使用独立字段order_no而不是数据库自增主键作为业务编号是因为订单号需要具备不可预测性和全局唯一性数据库自增主键容易暴露业务量且分库分表时会冲突。生成订单号常用的方案是“时间戳 随机数”或者用 Redis 的 INCR 生成自增序列。3.2 MyBatis Plus 的 CRUD 与分页查询陷阱实体类写好之后mapper接口继承BaseMapperT就能获得单表 CRUD 能力这是 MyBatis Plus 最核心的便利。分页查询则需要额外配置MybatisPlusInterceptorConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(200L); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }setMaxLimit(200L)是一个容易被忽略的参数。不加这个限制时调用方传入一个极大的 pageSize 就能一次性查出全表数据不仅是安全隐患对数据库压力也很大。设定单页最大返回条数之后超出直接由拦截器修正避免查询失控。分页查询的 service 实现可以这样写Service public class ScenicServiceImpl implements ScenicService { Autowired private ScenicMapper scenicMapper; Override public PageResultScenic queryPage(ScenicQueryDTO dto) { LambdaQueryWrapperScenic wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(dto.getKeyword()), Scenic::getName, dto.getKeyword()) .eq(dto.getCategoryId() ! null, Scenic::getCategoryId, dto.getCategoryId()) .eq(Scenic::getStatus, 1) .orderByDesc(Scenic::getCreateTime); PageScenic page new Page(dto.getPageNum(), dto.getPageSize()); scenicMapper.selectPage(page, wrapper); PageResultScenic result new PageResult(); result.setTotal(page.getTotal()); result.setRecords(page.getRecords()); return result; } }LambdaQueryWrapper是类型安全的查询条件构造器它会根据实体字段的 Lambda 表达式自动解析为数据库列名避免手写字符串导致字段名拼写错误。上面用到的like方法在 keyword 为空时不会拼接进 SQL因为第一个参数是 false 条件直接短路。这种写法比 if 判断更简洁也是 MyBatis Plus 的核心用法。复杂报表类查询不要硬用LambdaQueryWrapper拼多表 join 场景放在 XML 里维护更好。比如统计“某线路的热度排名”SQL 往往涉及t_order_item和t_scenic的关联聚合在 XML 文件里用select语法既方便调试也能利用数据库执行计划优化。3.3 业务层的事务边界控制订单创建是典型需要事务保护的场景先校验用户余额或优惠券再扣减库存然后插入订单表和明细表最后更新线路销量。任何一步失败订单数据不能半落库。常见错误是直接在 controller 层写业务逻辑用TransactionTemplate或者Transactional管理事务。正确做法是事务放在 service 方法上粒度等于一个完整的业务用例。Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 校验线路是否存在且上架 Scenic scenic scenicMapper.selectById(dto.getScenicId()); if (scenic null || scenic.getStatus() ! 1) { throw new BusinessException(线路不存在或已下架); } // 2. 生成订单号 String orderNo generateOrderNo(); // 3. 插入订单主表 Order order new Order(); order.setUserId(dto.getUserId()); order.setOrderNo(orderNo); order.setStatus(0); order.setTotalPrice(scenic.getPrice().multiply(BigDecimal.valueOf(dto.getQuantity()))); orderMapper.insert(order); // 4. 插入订单明细表 OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setScenicId(scenic.getId()); item.setPrice(scenic.getPrice()); item.setQuantity(dto.getQuantity()); item.setTotalPrice(order.getTotalPrice()); orderItemMapper.insert(item); return OrderVO.from(order); }这里特别建议rollbackFor Exception.class而不是使用默认的RuntimeException。Spring 的Transactional默认只对运行时异常回滚如果业务代码里抛出一个受检异常比如IOException事务不会回滚。关键词rollbackFor就是用来强制指定“所有异常都触发回滚”。项目中统一抛出BusinessException这种自定义运行时异常配合全局异常处理器输出提示信息事务边界和接口语义都能对齐。4. 系统安全与接口鉴权设计JWT 替换传统 Session4.1 无状态会话选型从 HttpSession 到 Token传统 Java Web 项目大多依赖 HttpSession 保存用户登录状态。这在单机部署时没问题但系统一旦需要横向扩展为多实例部署Session 会面临两个问题一是 Session 默认存储在 JVM 内存中多实例之间无法共享二是 Session 依赖 Cookie 传递会话 ID移动端 App 很难优雅处理 Cookie。对于旅游管理系统这种需要面向“小程序 App Web H5”多端输出的系统无状态 Token 是目前更合适的方案。JWTJSON Web Token是无状态 Token 的一种常见落地载体。它本身是一个包含了用户标识和过期时间的自包含字符串服务端不需要保存会话数据每次请求通过拦截器解析 Token 即可识别用户身份。选型时不必恐慌于“JWT 可以被破解”这类说法——JWT 的签名机制保证了 Token 内容无法被篡改真正要保护的是签名密钥。4.2 基于 Spring Interceptor 的登录鉴权实现实现登录鉴权不需要引入 Spring Security 这种重型框架。对于以接口为主的单体系统使用 Spring MVC 的HandlerInterceptor足以覆盖大部分场景Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token) || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录); } token token.substring(7); Long userId jwtUtil.parseToken(token); if (userId null) { throw new BusinessException(401, 登录已过期); } request.setAttribute(userId, userId); return true; } }拦截器通过request.setAttribute(userId, userId)把当前请求的用户 ID 传递到 controller 层service 层再通过参数从 controller 接收该用户 ID避免业务方法里频繁从 Token 反解用户信息。注册拦截器的配置类代码如下Configuration public class WebConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/**, /api/scenic/list); } }excludePathPatterns用来放行登录注册接口和景点列表接口。通常景点列表、搜索是可以不登录访问的而创建订单、查看个人订单等接口必须要求登录。这里涉及接口设计上的一个容易被忽略的问题鉴权拦截器的放行列表和实际的接口路由必须统一维护否则会出现排错时“这个接口一会儿能访问一会儿不能”的困惑。4.3 密码加密与用户登录的完整流程用户密码不能明文存入数据库最推荐的存储方式是 BCrypt 加密。Spring Security Crypto 模块提供了独立的BCryptPasswordEncoder不依赖完整的安全框架可以直接引入dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency登录逻辑的完整写法Service public class AuthServiceImpl implements AuthService { Autowired private UserMapper userMapper; private final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); Override public LoginVO login(LoginDTO dto) { User user userMapper.selectOne(new LambdaQueryWrapperUser() .eq(User::getUsername, dto.getUsername())); if (user null) { throw new BusinessException(用户名或密码错误); } if (!encoder.matches(dto.getPassword(), user.getPassword())) { throw new BusinessException(用户名或密码错误); } String token jwtUtil.generateToken(user.getId(), user.getUsername()); LoginVO vo new LoginVO(); vo.setToken(token); vo.setUserId(user.getId()); vo.setNickname(user.getNickname()); return vo; } }注意“用户名不存在”和“密码错误”返回同样的提示信息这是防止恶意用户通过批量探测用户名是否存在。密码加密使用BCryptPasswordEncoder时matches方法会根据密文里携带的盐自动校验不需要开发者自己管理盐值。这个实现绕过了 Session 相关的所有讨论直接指向 JWT 的场景需求——旅游管理系统的后端在分布式和小程序多端场景下请优先考虑这种无状态方案。5. 搜索与推荐场景的数据库优化关键字模糊查询的隐患与应对5.1 LIKE 查询为何在数据量上来后变慢旅游管理系统最常见的查询行为是“搜索目的地”和“按线路名称模糊搜索”。大多数初版实现会直接在t_scenic表的name字段上做LIKE %大理%。这种查询在几千条数据时响应时间在毫秒级但到几十万条线路数据之后全表扫描的成本就不可接受了。原因在于LIKE %xxx%无法利用 B Tree 索引的有序性即使name字段建了索引前导通配符也会让索引失效。慢 SQL 的本质是少量高频查询拖垮了整个库的性能。优化手段依赖于业务形态常见做法有几种如果只需要按前缀匹配例如搜索“大理”时限定name LIKE 大理%就可以走索引。如果必须处理任意位置匹配优先引入全文索引MySQL 的 FULLTEXT 索引或使用 ngram 解析器。数据量进一步增长时引入 Elasticsearch 做专门的搜索服务MySQL 只负责事务性写入。对于旅游管理系统这种体量推荐采用 MySQL 全文索引方案因为不需要引入额外的中间件开发成本最低。5.2 MySQL 全文索引在景点搜索中的具体配置t_scenic表的name和description字段如果都需要支持模糊搜索可以用 FULLTEXT 索引覆盖多个字段ALTER TABLE t_scenic ADD FULLTEXT INDEX ft_scenic_search (name, description) WITH PARSER ngram;这里的WITH PARSER ngram是 MySQL 内置的全文检索解析器对中文分词有原生支持。默认的全文索引解析器对中文不友好有了 ngram 之后查询语句的写法如下Select(SELECT * FROM t_scenic WHERE MATCH(name, description) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)) ListScenic searchByKeyword(Param(keyword) String keyword);实际执行时MySQL 会先对待搜索的 keyword 做 ngram 分词再基于倒排索引快速定位匹配行。相比全表扫描性能提升是数量级的。需要注意全文索引只支持 MyISAM 和 InnoDB 引擎现在的项目统一使用 InnoDB这一点没有兼容性问题。5.3 搜索结果的排序策略与翻页性能使用全文索引后排序方式不能再用常规的ORDER BY create_time DESC因为全文索引的默认相关性排序已经通过MATCH ... AGAINST计算出了 score按照 score 降序才是用户期望的搜索结果。修改后的查询语句SELECT id, name, description, price, MATCH(name, description) AGAINST(#{keyword}) AS score FROM t_scenic WHERE MATCH(name, description) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) ORDER BY score DESC LIMIT #{offset}, #{pageSize}这里出现了深分页问题当用户翻到第 100 页时OFFSET会跳到第 990 条之后才开始读取前 990 条数据虽然不返回给客户端但数据库依然扫描并丢弃了它们。业务上更合适的做法是通过“上一页最后一条数据的 score”作为游标来翻页WHERE MATCH(name, description) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) AND score #{lastScore} ORDER BY score DESC LIMIT #{pageSize}游标翻页的代价是去掉了跳页功能但对旅游搜索场景来说用户通常只浏览前几页这种方式的数据获取效率和稳定性都更好。如果产品经理坚持保留跳页可以在t_scenic表的普通字段上创建覆盖索引来优化回表成本但 MySQL 的LIMIT深分页始终是存在上限的业务增长后要考虑 ES 方案。6. 订单并发与库存扣减方案从乐观锁到 Redis 预扣库存6.1 并发下单场景中的超卖问题旅游线路通常有库存概念比如某条线路的某个团期只有 20 个名额。两个用户同时下单时如果不做任何控制会发生库存超卖两个请求都读到库存为 1都判断“库存足够”都执行扣减最终库存变成 -1。解决超卖最常见的手段是数据库行锁和乐观锁。乐观锁在t_scenic表增加version字段更新时检查版本号Update(UPDATE t_scenic SET stock stock - 1, version version 1 WHERE id #{scenicId} AND version #{version} AND stock 0) int deductStock(Param(scenicId) Long scenicId, Param(version) Integer version);这种写法依赖于“受影响行数”来判断库存是否扣减成功。返回值为 0 说明版本号不匹配或库存不足业务层再决定是重试还是返回“手慢了”。这个方案适合并发量在几十 TPS 以内的系统实现简单不加额外组件。6.2 Redis 预扣库存模式把库存计算前移到缓存层并发量上来之后数据库乐观锁能保证不错但压力都在数据库上。常见的做法是在 Redis 中维护库存键用 Lua 脚本原子地完成“检查库存并扣减”public boolean deductStockWithRedis(Long scenicId, Integer quantity) { String key stock:scenic: scenicId; String luaScript local stock redis.call(get, KEYS[1]) if not stock or tonumber(stock) tonumber(ARGV[1]) then return 0 end redis.call(decrby, KEYS[1], ARGV[1]) return 1; Long result redisTemplate.execute( new DefaultRedisScript(luaScript, Long.class), List.of(key), quantity.toString() ); return Long.valueOf(1).equals(result); }redis.call(get, KEYS[1])会读取当前库存如果不存在或库存不足则返回 0表示扣减失败否则执行decrby完成扣减。Lua 脚本在 Redis 中是原子执行的整个流程不会被打断所以不能拆成多个 Redis 命令在 Java 代码里逐条发送——非原子的 check-then-act 依然有超卖风险。Redis 预扣库存的另一个关键细节是数据一致性。真正的库存以数据库为准Redis 只是挡在数据库前的“流量闸门”。用户支付成功后要通过事务更新数据库中的库存字段用户取消订单则要执行 Redis 补偿回补。补偿回补操作要考虑幂等性否则一个用户重复提交取消请求库存会被多次回补。6.3 本地接口幂等设计与订单防重并发场景中防重是另一件容易被忽略的事。用户快速点击两次“提交订单”前端的 disabled 状态并不总是可靠后端接口必须具备幂等性。解决方案是在订单创建接口上增加一个“请求唯一标识”参数PostMapping(/api/order/create) public ResultOrderVO createOrder(RequestHeader String requestId, RequestBody OrderCreateDTO dto) { boolean isFirst tryLock(requestId); if (!isFirst) { throw new BusinessException(请勿重复提交); } return Result.success(orderService.createOrder(dto)); } private boolean tryLock(String requestId) { // SET NX EX 55 秒内重复请求都会被拦截 Boolean result redisTemplate.opsForValue() .setIfAbsent(order:lock: requestId, 1, Duration.ofSeconds(5)); return Boolean.TRUE.equals(result); }setIfAbsent对应 Redis 的SET NX命令同一 requestId 只有第一次能成功写入相当于一把分布式锁锁的过期时间设为 5 秒足以覆盖一次请求的正常处理时长。这种幂等方案不需要引入 Redisson 等重量级组件对旅游管理系统的主流程足够用。真正涉及分布式锁的复杂场景再考虑 Redisson 的可重入锁和看门狗续期机制也不迟。7. Java 面试视角的项目复盘从“会做”到“能讲清楚”7.1 几个容易被面试官追问的设计决策如果这个项目最终要写进简历以下几个点一定会被重点追问。提前演练好回答比多写一万行代码更有效率。第一个问题为什么使用 MyBatis Plus 而不是 MyBatis 原生回答的逻辑不能停留在“省代码”层面要从项目演进角度阐述——项目初期 CRUD 占大头BaseMapper提供的通用方法能减少重复的 XML 文件编写后期遇到复杂统计查询时又可以利用自定义 SQL 精确优化执行计划。两层配合才能兼顾开发效率与运行性能。第二个问题Redis 缓存和数据库的一致性怎么保证答案的核心是 Cache Aside Pattern读的时候先读缓存缓存没有则读数据库并回填写的时候先更新数据库再删除缓存。关键点在“先更新数据库再删缓存”的顺序选择。如果反过来先删缓存再更新数据库期间会有并发请求读到旧数据并回填缓存造成数据不一致。即使采用“先更新库再删缓存”删除缓存失败时也要通过延迟双删或者消息队列补偿具体取舍可以根据系统容忍度来聊。第三个问题单机限流还是分布式限流最简单的方案是基于 Guava RateLimiter 做单机限流但多实例部署时总量不可控。更实用的是在 Nginx 层配置limit_req或者用 Redis Lua 实现分布式令牌桶。回答时不妨先给定一个业务背景——旅游系统的热点是节假日抢购线路门票——接口 TPS 预期不高但突发流量明显这时候用 Redis 的 Lua 脚本做滑动窗口限流足够。7.2 项目中最容易翻车的三个坑及预防第一个坑订单号生成方案。很多初学者喜欢用UUID.randomUUID().toString().replace(-, )生成订单号这种订单号是纯随机字符串无法排序也不利于数据库索引的局部性。更合理的方案是使用时间戳 用户ID后四位 随机数的组合既能保持可读性也能在日志中快速定位订单所属用户。第二个坑BigDecimal 用于金额计算而不是 Double。double在二进制浮点运算下会出现精度丢失比如 0.1 0.2 不等于 0.3。订单金额、退款金额必须用BigDecimal并且构造时直接传字符串而不是传 double 值new BigDecimal(19.99)。这是一个开发规范问题面试被问到的概率也很高。第三个坑删除缓存和重试机制。如果 Redis 删除缓存失败后续读取就会命中脏数据。常见的补偿手段是把删除操作发到消息队列异步重试或者使用 RocketMQ 事务消息。小体量项目也可以接受“缓存 30 秒过期允许秒级短暂不一致”的折中方案把这个取舍写在项目文档里面试时主动讲出来反而说明你思考过。7.3 使用 Java 策略模式优化景点价格计算项目里如果存在“不同用户等级享受不同折扣”的业务规则用 if-else 写死会越来越难以维护。用 Java 策略模式可以把这个规则抽象为策略接口每个策略类处理一种计价逻辑底部用工厂或者Map进行路由。这种方式高度契合“java 策略模式多种组合”的搜索结果也是代码可维护性的加分项。public interface PriceStrategy { BigDecimal calculate(BigDecimal originalPrice, Integer level); } Component public class NormalUserPriceStrategy implements PriceStrategy { public BigDecimal calculate(BigDecimal originalPrice, Integer level) { return originalPrice; } } Component public class VipUserPriceStrategy implements PriceStrategy { public BigDecimal calculate(BigDecimal originalPrice, Integer level) { return originalPrice.multiply(new BigDecimal(0.9)); } }spring 容器初始化时把每种策略的 Bean 按类型放入MapString, PriceStrategy根据用户级别取出对应策略避免在业务代码里写if (level 1)之类的分支。这段代码虽然小但在简历项目描述里写一笔“使用策略模式处理不同用户等级的差异化计价”比平铺直叙“实现了价格计算功能”更能证明设计意识。本文还有配套的精品资源点击获取
返回列表