简介:这是一套基于SpringBoot开发的二手车交易系统完整源码,面向计算机相关专业在校学生、教师及初级Java开发者,用于课程设计、毕业设计参考或Web全栈开发实践。系统采用B/S架构,涵盖用户管理、车辆发布、在线询价、订单处理等核心业务模块,代码含详细中文注释,经实机测试可稳定运行。资源包共782个文件,包含109个Java后端逻辑文件、157个JavaScript前端交互脚本、54个Vue组件、50个CSS样式文件及36个HTML页面,辅以SVG图标、JPG/PNG图片和配置类XML/YML文件,整体压缩后为31.75MB。已有128人学习下载,适合具备Java基础、熟悉SpringBoot与前端基础的学习者,通过阅读目录结构、调试运行及功能扩展,可深入理解MVC分层设计、RESTful接口实现与前后端协同开发流程。
1. 项目概述:从零构建一个可落地的二手车交易平台
最近几年,身边不少朋友和同行都在聊汽车后市场,尤其是二手车交易,热度一直不减。作为一个干了十多年全栈开发的老兵,我手痒也琢磨着能不能自己动手,用最主流的Spring Boot技术栈,从零开始搭一个五脏俱全的二手车交易系统。这不仅仅是为了练手,更是想深入理解一个垂直电商平台从技术选型、架构设计到具体编码实现的完整闭环。今天分享的,就是我这个“练手项目”的完整复盘,代码会附上详细的中文注释,力求让无论是刚接触Spring Boot的新手,还是想了解业务系统设计的老鸟,都能有所收获。
这个系统麻雀虽小,但涵盖了二手车交易的核心流程:车辆信息发布、多维度检索、在线咨询、订单管理、后台管控等。选择Spring Boot,看中的就是它“约定大于配置”的理念,能让我们快速搭建起稳定、可扩展的后端服务,把更多精力聚焦在业务逻辑本身。在接下来的内容里,我会拆解整个系统的设计思路、关键模块的实现细节、开发中踩过的坑以及性能优化的一些心得。如果你正打算做一个类似的系统,或者想深入学习Spring Boot在复杂业务场景下的应用,那么这篇长文应该能给你提供一份可以直接“抄作业”的实战指南。
2. 系统整体架构与核心模块设计
2.1 技术栈选型背后的考量
为什么是Spring Boot?这几乎是当前Java后端开发的事实标准。它内嵌了Tomcat,无需单独部署WAR包;提供了海量的Starter依赖,能像搭积木一样快速集成数据库、缓存、消息队列等组件;还有完善的监控(如Spring Boot Actuator)和生态。对于我们这个二手车交易系统,快速原型开发和后期易于维护是首要目标。
基于此,我确定了核心技术栈:
- 后端框架:Spring Boot 2.7.x(这是一个长期支持版本,生态稳定,避开了3.x初期可能存在的兼容性问题)。
- 数据持久层:MyBatis-Plus。它是对MyBatis的增强,提供了强大的CRUD封装和条件构造器,能极大减少单表操作的SQL编写,同时保留MyBatis定制SQL的灵活性。对于车辆、用户等实体的大量基础操作,用它再合适不过。
- 数据库:MySQL 8.0。关系型数据库在处理交易、订单这类强一致性要求的业务时依然是首选。后续会讲到如何通过分库分表(ShardingSphere)来应对数据增长。
- 缓存:Redis。主要用于高频访问的数据(如热门车辆信息、首页轮播图)、用户会话(Session)存储以及简单的分布式锁场景。
- 消息队列:RabbitMQ。用于解耦耗时操作,比如用户发布车辆后,发送系统通知、生成静态化详情页等,可以异步处理,提升接口响应速度。
- 搜索:Elasticsearch。二手车搜索条件复杂(品牌、车型、价格区间、车龄、里程等),关系数据库的
LIKE查询在性能和功能上都难以满足,必须引入专业的搜索引擎。 - 前端:Vue 3 + Element Plus。前后端分离架构,后端专注提供RESTful API。
注意:技术选型没有银弹。这里的选择是基于项目复杂度、团队熟悉度和社区活跃度的平衡。例如,如果团队对MongoDB更熟,且车辆信息文档结构多变,也可以考虑用它来存车辆基础信息。消息队列选RabbitMQ而非Kafka,是因为当前业务量级下,RabbitMQ的易用性和功能足够,且延迟更低。
2.2 核心业务模块划分与领域模型
一个交易系统,本质上是围绕“商品”(车辆)、“人”(用户)和“交易”(订单)这三个核心领域展开的。我将其拆分为以下几个微服务(初期可在一个单体应用中用模块区分,为未来拆分做准备):
- 用户中心服务:处理用户注册、登录、认证、个人信息管理。这里采用了JWT(JSON Web Token)作为无状态令牌,替代传统的Session,更适合分布式部署。
- 车辆服务:最核心的服务。负责车辆信息的发布、编辑、上下架、详情查询。这里信息结构复杂,包含基本属性(品牌、型号、颜色)、关键指标(里程、上牌时间、排放标准)、图片/视频、检测报告等。
- 搜索服务:基于Elasticsearch,提供复杂的多条件组合搜索、全文检索、排序和聚合分析(如按价格分布、品牌占比统计)。它订阅车辆服务的消息,实时同步车辆数据到ES索引。
- 交易服务:管理购物车(收藏夹)、订单创建、支付状态回调、订单状态流转(待付款、待验车、已完成、已取消等)。
- 内容与消息服务:处理用户间的在线咨询、系统通知(如订单状态变更、降价提醒)的发送。
- 后台管理服务:提供运营管理后台,用于审核车辆信息、管理用户、查看数据报表、配置系统参数等。
领域模型设计上,我遵循了DDD(领域驱动设计)的一些简单思想,尽量让代码反映业务。例如,Car(车辆)是一个聚合根,它内部包含CarImage(图片)、InspectionReport(检测报告)等值对象。Order(订单)是另一个聚合根,与Car通过车辆ID关联,而不是直接持有其引用,降低了耦合。
3. 关键功能实现与代码详解(附核心代码片段)
3.1 用户认证与权限控制:JWT与Spring Security整合
安全是系统的基石。我采用Spring Security+JWT的方案。Spring Security负责整个安全过滤链,而JWT负责在客户端和服务端之间安全地传递用户声明。
核心实现步骤:
自定义UserDetailsService:从数据库加载用户信息,并封装成
UserDetails对象。@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 查询用户 User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username)); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } // 2. 查询用户角色(这里简单处理,实际可能有多角色) List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"); // 3. 返回Spring Security识别的UserDetails对象 return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), // 数据库存储的应是BCrypt加密后的密码 authorities ); } }JWT工具类:负责生成和解析JWT令牌。
@Component public class JwtTokenUtil { private static final String SECRET_KEY = "your-secret-key-keep-it-safe"; // 必须复杂且保密 private static final long EXPIRATION = 86400000L; // 24小时,单位毫秒 public String generateToken(String username) { Map<String, Object> claims = new HashMap<>(); claims.put("sub", username); claims.put("created", new Date()); return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION)) .signWith(SignatureAlgorithm.HS512, SECRET_KEY) .compact(); } public String getUsernameFromToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody() .getSubject(); } // 省略验证过期等方法... }自定义JWT认证过滤器:继承
OncePerRequestFilter,在Spring Security过滤链中拦截请求,解析Authorization头中的JWT令牌。public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtil jwtTokenUtil; @Autowired private UserDetailsServiceImpl userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { String username = jwtTokenUtil.getUsernameFromToken(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 验证令牌有效性(此处简化,实际还应验证令牌是否在黑名单等) UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { // 令牌无效,记录日志,但继续过滤链(后续会被Spring Security的异常处理器捕获) logger.error("JWT令牌解析失败", e); } } chain.doFilter(request, response); } }配置Spring Security:在配置类中注册过滤器,并放行登录、注册等公开接口。
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() // 禁用CSRF,因为使用JWT无状态 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话 .and() .authorizeRequests() .antMatchers("/api/auth/**", "/api/cars/public/**").permitAll() // 公开接口 .anyRequest().authenticated() // 其他所有接口需要认证 .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 添加JWT过滤器 } // 配置密码编码器为BCrypt @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
实操心得:JWT令牌一旦签发,在有效期内无法主动使其失效,这是其一个特点。对于“用户登出”或“修改密码后让旧令牌失效”的需求,常见的做法是维护一个短期的“令牌黑名单”(存Redis,过期时间设为令牌剩余有效期),或者在令牌
payload中存储一个版本号,用户关键操作后递增版本号,验证令牌时检查版本号是否匹配。我们系统采用了“黑名单”方案,虽然增加了点复杂度,但安全性更高。
3.2 车辆服务:复杂实体建模与图片上传
车辆信息是系统的核心数据,其数据结构复杂,且涉及大量图片。
实体类设计(简化版):
@Data @TableName("car") // MyBatis-Plus 表名注解 public class Car { @TableId(type = IdType.AUTO) private Long id; private String title; // 标题,如“2018款 大众迈腾 330TSI 豪华型” private Long brandId; // 品牌ID private Long seriesId; // 车系ID private Integer firstRegistrationYear; // 首次上牌年份 private Integer mileage; // 行驶里程(公里) private BigDecimal price; // 售价 private String city; // 车辆所在城市 // ... 数十个其他字段 @TableField(exist = false) // 非数据库字段,用于业务逻辑 private List<CarImage> images; @TableField(exist = false) private InspectionReport inspectionReport; } @Data @TableName("car_image") public class CarImage { @TableId(type = IdType.AUTO) private Long id; private Long carId; private String url; // 图片在OSS上的访问地址 private Integer sortOrder; // 排序号 private Integer isMain; // 是否主图 }图片上传实现:为了解耦和扩展,图片、文件等静态资源一定要上传到对象存储服务(OSS),如阿里云OSS、腾讯云COS,而不是保存在应用服务器本地。这里以阿里云OSS为例。
OSS配置与工具类:
@Component public class OssTemplate { @Value("${aliyun.oss.endpoint}") private String endpoint; @Value("${aliyun.oss.accessKeyId}") private String accessKeyId; @Value("${aliyun.oss.accessKeySecret}") private String accessKeySecret; @Value("${aliyun.oss.bucketName}") private String bucketName; private OSS ossClient; @PostConstruct public void init() { ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); } public String upload(MultipartFile file, String filePath) throws IOException { // 生成唯一文件名,防止覆盖 String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; String fullPath = filePath + "/" + fileName; ossClient.putObject(bucketName, fullPath, new ByteArrayInputStream(file.getBytes())); // 返回文件的完整访问URL return "https://" + bucketName + "." + endpoint + "/" + fullPath; } // 省略其他方法如delete... }控制器层处理上传:
@RestController @RequestMapping("/api/cars") public class CarController { @Autowired private OssTemplate ossTemplate; @Autowired private CarService carService; @PostMapping("/{carId}/images") public Result uploadImages(@PathVariable Long carId, @RequestParam("files") MultipartFile[] files, @RequestParam(value = "isMain", defaultValue = "0") Integer isMainIndex) { // 1. 验证车辆是否存在且属于当前用户(权限校验) // 2. 遍历files,调用ossTemplate.upload上传,返回url列表 List<CarImage> imageList = new ArrayList<>(); for (int i = 0; i < files.length; i++) { MultipartFile file = files[i]; if (!file.isEmpty()) { String url = ossTemplate.upload(file, "car-images/" + carId); CarImage image = new CarImage(); image.setCarId(carId); image.setUrl(url); image.setSortOrder(i); image.setIsMain(i == isMainIndex ? 1 : 0); // 设置主图 imageList.add(image); } } // 3. 批量保存图片记录到数据库 carService.saveCarImages(imageList); return Result.success(imageList); } }
注意事项:上传接口一定要做严格的校验。包括:文件大小限制(在
application.yml中配置spring.servlet.multipart.max-file-size)、文件类型白名单(防止上传恶意脚本)、以及业务层面的权限校验(确保用户只能修改自己发布的车辆图片)。此外,对于大量图片,可以考虑前端使用分片上传,后端提供预签名URL让前端直传OSS,以减轻服务器带宽压力。
3.3 集成Elasticsearch实现智能搜索
这是提升用户体验的关键。我们使用Spring Data Elasticsearch来简化操作。
定义ES文档实体:
@Document(indexName = "car_index") // 对应ES索引 @Data public class CarEsDocument { @Id private Long id; // 对应数据库车辆ID private String title; @Field(type = FieldType.Keyword) private String brandName; // 品牌名,用于精确匹配和聚合 @Field(type = FieldType.Keyword) private String seriesName; // 车系名 @Field(type = FieldType.Integer) private Integer firstRegistrationYear; @Field(type = FieldType.Integer) private Integer mileage; @Field(type = FieldType.Double) private Double price; // ES建议用Double @Field(type = FieldType.Keyword) private String city; @Field(type = FieldType.Date) private Date publishTime; // 发布时间,用于排序 // 对于需要全文检索的字段,使用Text类型 @Field(type = FieldType.Text, analyzer = "ik_max_word") // 使用IK中文分词器 private String description; }创建Repository:
public interface CarEsRepository extends ElasticsearchRepository<CarEsDocument, Long> { // 方法名查询:根据品牌和城市查找,价格区间排序 Page<CarEsDocument> findByBrandNameAndCity(String brandName, String city, Pageable pageable); // 使用@Query注解进行更复杂的DSL查询 @Query("{\"bool\": {\"must\": [{\"range\": {\"price\": {\"gte\": ?0, \"lte\": ?1}}}, {\"term\": {\"city\": \"?2\"}}]}}") Page<CarEsDocument> findByPriceBetweenAndCity(Double minPrice, Double maxPrice, String city, Pageable pageable); // 更复杂的多条件搜索,建议使用`NativeSearchQueryBuilder`在Service层构建 }构建复杂搜索服务:
@Service public class CarSearchService { @Autowired private CarEsRepository carEsRepository; @Autowired private ElasticsearchRestTemplate elasticsearchRestTemplate; public Page<CarEsDocument> complexSearch(CarSearchCondition condition, Pageable pageable) { NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 品牌精确匹配 if (StringUtils.hasText(condition.getBrandName())) { boolQuery.must(QueryBuilders.termQuery("brandName", condition.getBrandName())); } // 城市精确匹配 if (StringUtils.hasText(condition.getCity())) { boolQuery.must(QueryBuilders.termQuery("city", condition.getCity())); } // 价格区间 if (condition.getMinPrice() != null || condition.getMaxPrice() != null) { RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price"); if (condition.getMinPrice() != null) { rangeQuery.gte(condition.getMinPrice()); } if (condition.getMaxPrice() != null) { rangeQuery.lte(condition.getMaxPrice()); } boolQuery.must(rangeQuery); } // 里程区间 if (condition.getMaxMileage() != null) { boolQuery.must(QueryBuilders.rangeQuery("mileage").lte(condition.getMaxMileage())); } // 标题或描述全文检索 if (StringUtils.hasText(condition.getKeyword())) { boolQuery.must(QueryBuilders.multiMatchQuery(condition.getKeyword(), "title", "description")); } queryBuilder.withQuery(boolQuery); // 排序:默认按发布时间倒序 queryBuilder.withSort(SortBuilders.fieldSort("publishTime").order(SortOrder.DESC)); // 分页 queryBuilder.withPageable(pageable); // 高亮显示 HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("title").preTags("<em>").postTags("</em>"); queryBuilder.withHighlightBuilder(highlightBuilder); NativeSearchQuery searchQuery = queryBuilder.build(); SearchHits<CarEsDocument> searchHits = elasticsearchRestTemplate.search(searchQuery, CarEsDocument.class); // 处理高亮结果并转换为Page对象返回... return ...; } }数据同步:当车辆信息在MySQL中发生增删改时,需要通过消息队列(如RabbitMQ)发送一个事件,搜索服务消费该事件,同步更新ES索引。这保证了数据的最终一致性。
// 在车辆服务中,发布车辆更新事件 @Service public class CarService { @Autowired private RabbitTemplate rabbitTemplate; public void updateCar(Car car) { // ... 更新数据库逻辑 // 发送消息到交换机 rabbitTemplate.convertAndSend("car.exchange", "car.update", car.getId()); } } // 在搜索服务中,监听消息并更新ES @Component public class CarDataSyncHandler { @Autowired private CarEsRepository carEsRepository; @Autowired private CarServiceClient carServiceClient; // Feign客户端,调用车辆服务获取详情 @RabbitListener(queues = "car.update.queue") public void handleCarUpdate(Long carId) { // 根据carId从车辆服务获取最新车辆详情 CarDetailDTO carDetail = carServiceClient.getCarDetail(carId); // 转换为CarEsDocument CarEsDocument doc = convertToEsDocument(carDetail); // 保存或更新到ES carEsRepository.save(doc); } }
踩坑记录:ES的字段类型一旦定义,修改起来比较麻烦。所以在设计索引映射(Mapping)时,一定要考虑周全。特别是对于可能用于范围查询(如价格、里程)或聚合(如品牌、城市)的字段,要设置为
keyword或numeric类型,而不是text。text类型会被分词,不适合精确匹配和聚合。初期我们曾把brandName设成了text,导致聚合结果不准确,后来重建了索引。
3.4 订单与交易状态机设计
交易流程涉及多个状态,如待付款、待验车、已支付、已完成、已取消等。状态流转必须有严格的逻辑控制,不能随意跳转。这里我采用了状态机模式。
定义订单状态枚举和事件枚举:
public enum OrderStatus { UNPAID(0, "待付款"), PAID(1, "已支付"), VERIFIED(2, "已验车"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); // ... 构造方法、getter } public enum OrderEvent { PAY_SUCCESS, // 支付成功 USER_CANCEL, // 用户取消 ADMIN_CANCEL, // 管理员取消 VERIFY_PASS, // 验车通过 VERIFY_FAIL, // 验车不通过 CONFIRM_RECEIPT // 用户确认收货 }使用Spring StateMachine实现状态机:
@Configuration @EnableStateMachineFactory public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStatus, OrderEvent> { @Override public void configure(StateMachineStateConfigurer<OrderStatus, OrderEvent> states) throws Exception { states .withStates() .initial(OrderStatus.UNPAID) // 初始状态 .states(EnumSet.allOf(OrderStatus.class)); } @Override public void configure(StateMachineTransitionConfigurer<OrderStatus, OrderEvent> transitions) throws Exception { transitions .withExternal() .source(OrderStatus.UNPAID).target(OrderStatus.PAID) .event(OrderEvent.PAY_SUCCESS) .and() .withExternal() .source(OrderStatus.UNPAID).target(OrderStatus.CANCELLED) .event(OrderEvent.USER_CANCEL) .and() .withExternal() .source(OrderStatus.PAID).target(OrderStatus.VERIFIED) .event(OrderEvent.VERIFY_PASS) .and() .withExternal() .source(OrderStatus.PAID).target(OrderStatus.CANCELLED) .event(OrderEvent.VERIFY_FAIL) // 验车不通过,取消订单并退款 .and() .withExternal() .source(OrderStatus.VERIFIED).target(OrderStatus.COMPLETED) .event(OrderEvent.CONFIRM_RECEIPT); } @Override public void configure(StateMachineConfigurationConfigurer<OrderStatus, OrderEvent> config) throws Exception { config .withConfiguration() .listener(new StateMachineListener()); // 可以添加监听器记录状态变更日志 } }在Service层应用状态机:
@Service public class OrderService { @Autowired private StateMachineFactory<OrderStatus, OrderEvent> stateMachineFactory; @Autowired private OrderMapper orderMapper; public boolean payOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != OrderStatus.UNPAID.getCode()) { throw new BusinessException("订单状态不允许支付"); } // 调用支付网关... boolean paySuccess = paymentGateway.pay(...); if (paySuccess) { // 获取状态机实例,通常以订单ID作为机器ID StateMachine<OrderStatus, OrderEvent> stateMachine = stateMachineFactory.getStateMachine(orderId.toString()); // 发送支付成功事件 boolean accepted = stateMachine.sendEvent(OrderEvent.PAY_SUCCESS); if (accepted) { OrderStatus newStatus = stateMachine.getState().getId(); // 更新数据库订单状态 order.setStatus(newStatus.getCode()); orderMapper.updateById(order); // 触发后续动作,如发送通知、更新库存(二手车是唯一商品,可标记为已售)等 return true; } } return false; } }
实操心得:状态机将状态流转规则集中管理,避免了在业务代码中到处写
if...else判断状态,逻辑清晰且易于维护。当增加新的状态或事件时,只需修改配置类,业务代码改动很小。此外,所有状态变更都可以通过监听器记录下来,便于后续审计和问题排查。
4. 性能优化与生产环境部署考量
4.1 缓存策略设计与实践
缓存是提升性能的利器,但用不好就是“坑”。我们主要在两个层面使用Redis:
查询缓存:对于首页轮播图、热门品牌列表、城市列表等变化不频繁但访问量大的数据,可以缓存起来。
@Service public class CarBrandService { @Autowired private RedisTemplate<String, Object> redisTemplate; private static final String BRAND_LIST_KEY = "car:brand:list"; private static final long EXPIRE_TIME = 3600L; // 1小时 public List<Brand> getBrandList() { // 1. 先查缓存 List<Brand> brands = (List<Brand>) redisTemplate.opsForValue().get(BRAND_LIST_KEY); if (brands != null && !brands.isEmpty()) { return brands; } // 2. 缓存未命中,查数据库 brands = brandMapper.selectList(null); // 3. 写入缓存 redisTemplate.opsForValue().set(BRAND_LIST_KEY, brands, EXPIRE_TIME, TimeUnit.SECONDS); return brands; } // 当品牌数据有更新时,需要删除或更新缓存 public void updateBrand(Brand brand) { brandMapper.updateById(brand); // 删除缓存,下次查询自动加载最新数据 redisTemplate.delete(BRAND_LIST_KEY); } }分布式锁:在“秒杀”或“抢购”热门车源时,防止超卖。这里使用Redis的
SETNX命令实现简单的分布式锁。public boolean tryLock(String key, String value, long expireSeconds) { Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } public void unlock(String key, String value) { // 只有锁的持有者才能释放锁,防止误删 String currentValue = (String) redisTemplate.opsForValue().get(key); if (value.equals(currentValue)) { redisTemplate.delete(key); } } // 在创建订单时使用 public boolean createOrderWithLock(Long carId, Long userId) { String lockKey = "order:lock:car:" + carId; String lockValue = UUID.randomUUID().toString(); try { // 尝试获取锁,超时时间5秒 if (tryLock(lockKey, lockValue, 5)) { // 检查车辆状态是否可售 Car car = carService.getById(carId); if (car.getStatus() != CarStatus.FOR_SALE) { return false; } // 创建订单... orderService.createOrder(carId, userId); // 更新车辆状态为“已锁定”或“已售” carService.updateStatus(carId, CarStatus.RESERVED); return true; } else { // 获取锁失败,可能是其他用户正在下单 throw new BusinessException("车辆正在被其他用户购买,请稍后再试"); } } finally { // 务必在finally块中释放锁 unlock(lockKey, lockValue); } }
注意事项:缓存一定要设置合理的过期时间,避免脏数据长期存在。对于分布式锁,务必在
finally块中释放,并且释放时要判断锁的value是否是自己设置的,防止因业务执行超时导致锁自动过期后,误删了其他线程的锁。更复杂的场景可以考虑Redisson客户端提供的可重入锁、读写锁等。
4.2 数据库优化:索引、分库分表与读写分离
随着数据量增长,数据库会成为瓶颈。
SQL索引优化:这是成本最低的优化手段。通过
EXPLAIN分析慢查询,为WHERE、ORDER BY、GROUP BY、JOIN的字段建立索引。例如:car表:在brand_id,series_id,city,price,publish_time,status上建立组合索引,需根据实际查询模式调整顺序。order表:在user_id,car_id,status,create_time上建立索引。
心得:索引不是越多越好。每增加一个索引,都会降低写操作(INSERT, UPDATE, DELETE)的速度,因为索引也需要维护。需要定期审查和清理无效或重复的索引。
读写分离:使用主从复制,将写操作指向主库,读操作分摊到多个从库。可以通过
ShardingSphere-JDBC或MyCat这样的中间件透明地实现。分库分表:当单表数据超过千万,查询性能会明显下降。对于
car表,可以按城市(city)进行水平分片。对于order表,可以按用户ID(user_id)哈希取模分表。同样,ShardingSphere是很好的选择。# ShardingSphere 数据源配置示例 (application-sharding.yml) spring: shardingsphere: datasource: names: ds0, ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://master-host:3306/car_db?useUnicode=true&characterEncoding=utf8 username: root password: root ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://slave-host:3306/car_db?useUnicode=true&characterEncoding=utf8 username: root password: root rules: sharding: tables: car: actual-data-nodes: ds$->{0..1}.car_$->{0..15} # 分到2个库,每个库16张表 table-strategy: standard: sharding-column: city_code # 按城市代码分片 sharding-algorithm-name: car-table-inline key-generate-strategy: column: id key-generator-name: snowflake sharding-algorithms: car-table-inline: type: INLINE props: algorithm-expression: car_$->{city_code % 16} # 简单取模,实际应使用一致性哈希
### 4.3 接口性能监控与调优 上线后,必须监控系统性能。Spring Boot Actuator提供了丰富的端点(endpoints),可以暴露健康检查、指标、日志等信息。集成Prometheus和Grafana可以搭建可视化的监控面板。 1. **添加依赖和配置**: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> ``` ```yaml management: endpoints: web: exposure: include: health,info,metrics,prometheus # 暴露prometheus端点 metrics: tags: application: used-car-system # 为所有指标添加应用标签 ``` 2. **使用AOP监控接口耗时**: ```java @Aspect @Component @Slf4j public class ApiPerformanceAspect { @Around("@annotation(org.springframework.web.bind.annotation.GetMapping) || " + "@annotation(org.springframework.web.bind.annotation.PostMapping) || " + "@annotation(org.springframework.web.bind.annotation.RequestMapping)") public Object monitorApiPerformance(ProceedingJoinPoint joinPoint) throws Throwable { long startTime = System.currentTimeMillis(); String methodName = joinPoint.getSignature().toShortString(); try { return joinPoint.proceed(); } finally { long duration = System.currentTimeMillis() - startTime; log.info("API {} executed in {} ms", methodName, duration); // 可以推送到时序数据库,如推送到Micrometer MeterRegistry if (duration > 1000) { // 超过1秒记录警告 log.warn("API {} is slow, took {} ms", methodName, duration); } } } } ``` 3. **分析慢SQL**:除了监控接口,还要监控数据库。开启MySQL的慢查询日志(`slow_query_log`),定期分析。也可以使用`p6spy`这样的工具,在开发测试阶段打印所有SQL及其执行时间。 ## 5. 开发与部署中的常见问题排查 在实际开发和部署中,总会遇到各种“坑”。这里记录几个典型问题及其解决方案。 ### 5.1 事务与分布式事务问题 **场景**:创建订单时,需要扣减车辆库存(或标记状态)、生成订单记录、记录流水。这些操作必须在一个事务里,要么全部成功,要么全部回滚。 **解决方案**:在单体应用中,使用Spring的`@Transactional`注解即可。但在微服务架构下,车辆服务和订单服务是分开的,这就涉及分布式事务。 * **本地消息表**:这是最常用、最终一致性方案。在订单服务本地数据库中,订单表和一张“消息事件表”在同一个本地事务中创建。然后有一个定时任务扫描“消息事件表”,将未发送的消息投递给MQ。车辆服务消费消息,更新车辆状态。如果成功,则回调订单服务确认消息;如果失败,则重试。保证了最终一致性。 * **Seata**:阿里开源的分布式事务解决方案,支持AT、TCC等模式。功能强大但引入复杂度较高,适合对一致性要求极高的金融场景。对于我们二手车交易,**本地消息表+可靠消息队列(RabbitMQ确认机制)** 的最终一致性方案通常足够。 ### 5.2 循环依赖与Bean初始化问题 Spring Boot项目大了之后,很容易出现`BeanCurrentlyInCreationException`,即循环依赖。 **典型报错**:`Requested bean is currently in creation: Is there an unresolvable circular reference?` **排查与解决**: 1. **检查构造函数注入**:如果两个Bean互相通过构造函数注入对方,Spring无法解决。应改为使用`@Autowired`字段注入或setter方法注入。 2. **使用`@Lazy`注解**:在其中一个注入点添加`@Lazy`,延迟加载,打破循环。 3. **重新设计**:循环依赖往往是设计有问题的信号。考虑是否可以将互相依赖的部分抽取到第三个Bean中,或者使用事件监听(`ApplicationEvent`)来解耦。 ### 5.3 生产环境配置文件管理与敏感信息泄露 **问题**:数据库密码、OSS密钥、第三方API密钥等写在`application.yml`里,提交到代码仓库导致泄露。 **解决方案**: 1. **使用Profile**:建立`application-dev.yml`, `application-prod.yml`,在启动时通过`--spring.profiles.active=prod`指定。 2. **使用环境变量**:将敏感信息配置在服务器的环境变量中,在`application.yml`里使用占位符引用。 ```yaml # application-prod.yml aliyun: oss: accessKeyId: ${OSS_ACCESS_KEY_ID:defaultKey} # 优先从环境变量读取,没有则用defaultKey(不推荐写死) accessKeySecret: ${OSS_ACCESS_KEY_SECRET} ``` 3. **使用配置中心**:如Spring Cloud Config, Apollo, Nacos。将配置集中管理,支持动态刷新,是更专业的做法。 ### 5.4 内存泄漏与JVM调优 **现象**:服务运行一段时间后,响应变慢,甚至OOM(OutOfMemoryError)崩溃。 **排查工具**: * `jps`:查看Java进程。 * `jstat -gcutil [pid] 1000`:每秒打印一次GC情况,观察各分区使用率和GC次数/时间。 * `jmap -heap [pid]`:查看堆内存概要。 * `jmap -histo:live [pid]`:查看堆中对象统计,找疑似泄漏的类。 * **图形化工具**:`jvisualvm` 或 `Arthas`(阿里开源,功能强大,适合线上诊断)。 **常见原因与解决**: 1. **静态集合类持有对象引用**:例如用`static Map`做缓存且未清理。确保有淘汰策略(如LRU)或设置软/弱引用。 2. **未关闭资源**:数据库连接、文件流、网络连接等未在`finally`块或try-with-resources中关闭。务必确保释放。 3. **不当的线程池使用**:创建了大量未复用的线程。使用`ThreadPoolExecutor`并合理配置核心/最大线程数、队列容量和拒绝策略。 4. **JVM参数不当**:堆内存设置过小。根据服务器内存调整`-Xms`(初始堆)和`-Xmx`(最大堆),一般设为相同值避免动态调整开销。新生代和老年代比例(`-XX:NewRatio`)、垃圾收集器(如G1)也需要根据应用特点调整。 ### 5.5 第三方服务调用超时与熔断降级 **场景**:车辆服务调用支付网关,或者前端调用地图API获取地理位置时,对方服务响应慢或不可用,导致我们自己的服务线程池被占满,引发雪崩。 **解决方案**:使用Resilience4j或Spring Cloud Circuit Breaker实现熔断降级。 1. **超时控制**:为FeignClient或RestTemplate配置连接超时和读取超时。 ```yaml feign: client: config: default: connectTimeout: 5000 readTimeout: 10000 ``` 2. **熔断器**:当失败率达到阈值,熔断器打开,后续请求直接快速失败(走降级逻辑),不再调用远程服务。过一段时间进入半开状态,尝试放一个请求过去,如果成功则关闭熔断器。 ```java @Service public class PaymentService { @CircuitBreaker(name = "paymentService", fallbackMethod = "fallbackPayment") public PaymentResult pay(Order order) { // 调用第三方支付接口 return paymentClient.pay(order); } // 降级方法 public PaymentResult fallbackPayment(Order order, Exception e) { log.error("支付服务调用失败,订单号:{}", order.getOrderNo(), e); // 返回一个默认结果,或者将订单标记为“支付中”,后续通过定时任务补偿查询 return PaymentResult.ofFail("支付系统繁忙,请稍后查看订单状态"); } } ``` 3. **限流**:使用Resilience4j的`RateLimiter`或Sentinel,限制单位时间内的请求数,保护自身和下游服务。 构建一个完整的二手车交易系统,远不止写CRUD那么简单。它涉及业务建模、架构设计、技术选型、性能优化、安全防护和运维部署等多个维度。这个项目让我重新梳理了一遍中大型业务系统的开发脉络。代码和详细注释我已经整理到了GitHub上,你可以克隆下来,对照着文章一步步跑起来,相信会比只看文档有更深的理解。在实际开发中,最重要的是保持好奇心,多问“为什么这么设计”,遇到问题多查资料、多动手实验,积累的经验才是最宝贵的。 <p> <a href="https://download.csdn.net/download/weixin_66413741/89801714" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>