ARTICLE DETAIL

资讯详情

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

基于Java核心技术的汽车CRM系统:从数据库设计到源码实现

基于Java核心技术的汽车CRM系统:从数据库设计到源码实现 简介一套以Java核心技术为基础的汽车客户关系管理系统设计源码面向需要掌握CRM业务建模、前后端分离开发及Maven工程管理的Java学习者也可用于毕业设计或课程项目参考。系统采用MVC架构前端由Vue组件配合CSS、JavaScript构建模块化页面后端以Java源文件与class文件完成业务逻辑和数据处理其中Model层负责数据模型设计View层负责界面展示Controller层处理交互请求职责划分明确。压缩包共532个文件主要涵盖Vue组件、JavaScript脚本、CSS样式、SVG/GIF图片、XML配置、SQL脚本及字体文件等体积约11.01MB导入IDE即可查看完整工程。资源涵盖客户管理、订单处理、详情查询等典型业务模块并附带pom.xml、readme.txt等文件便于理解依赖配置、构建方式与运行说明。该项目已有265人学习下载对研究汽车行业CRM实现细节的Java全栈开发者有较高参考价值。1. 汽车客户关系管理系统到底在管什么从Java核心技术到可交付的源码一个4S店销售手里握着三张Excel表、两个微信群和一堆聊天记录客户跟到一半人就离职了后续线索全断。这就是大多数中小车商做客户管理的真实起点。所谓基于Java核心技术的汽车客户关系管理系统就是把客户、车辆、试驾、成交这四件事放进同一套结构化流程里用面向对象建模、JDBC访问、Servlet分发这些Java基础功夫搭出一个可运行的Web系统。它适合两类人一类是拿Java做课程设计、Java毕业设计的学生想找一个贴近真实业务的完整源码当参考另一类是想低成本信息化的小型车商找一个能部署在自己服务器上的轻量CRM。别一上来就对标Salesforce先把一条线索从进店到提车这条链路跑通这个系统的价值就出来了。2. 把客户状态转换成数据库表结构汽车CRM的数据地基怎么打做CRM的第一步永远是建模不是写界面。汽车行业和通用CRM的差异在数据形态上很明显客户可能同时看两个品牌的车一辆车可以有多位试驾人一条线索要经历从建档、跟进、试驾、报价到成交的完整状态流转。如果一开始把客户和订单塞进一张表后面每加一个功能都是灾难。我在做这类Java源码项目时会先把业务流拆成五张核心表再决定字段和索引。2.1 汽车CRM的5张核心表客户、车辆、跟进记录、试驾、成交单汽车CRM的最小闭环是“客户进来了销售跟进了客户试驾了客户下单了”。围绕这个闭环最少需要下面五张表。客户主表customer保存客户的基本信息和归属关系。汽车行业的特殊点在于一个家庭可能共用一个手机号来询价因此客户身份不能只看手机号还要存性别、年龄段、意向车型、购车预算。车辆表vehicle维护可售车辆资源包括品牌、车型、VIN码、颜色、指导价和当前状态。注意车辆是“资源”而不是“商品”因为同一辆车可以被多次试驾只有成交后才变成订单里的标的物。跟进记录表follow_record是CRM的灵魂。汽车销售平均要跟进5到8次才能成交每一次电话、微信、到店接待都要留痕表里记follow_type电话/微信/到店/试驾记下次跟进时间财务上经常用它判断销售活动是否健康。试驾表test_drive把客户和车辆关联起来包含试驾时间、试驾结果、陪同销售。这里最容易踩的坑是时间冲突一辆车在同一时间段只能有一组试驾人必须在数据层面约束住。成交单表sale_order承载最终合同字段包括订单号、客户、车辆、合同金额、实收金额、付款状态、交车日期。订单号要独立于客户ID生成因为一个客户可能买两辆车也可能退单后再买。这套表结构不追求大而全但把汽车CRM区别于通用CRM的“车和人绑定”关系表达出来了。后续要加保养提醒、保险续费、推荐奖励都在这个骨架上扩展。2.2 字段类型和索引怎么定手机号与身份证号的存储边界建表时的字段类型选择直接决定系统能跑多久。我见过太多Java课设源码把手机号设成int等用户输入“13800138000”直接溢出闪退。手机号必须用varchar(20)而不是int或bigint因为手机号是“号码”不是“数字”没有参与加减乘除的语义而且将来可能存 “86 138 0013 8000” 这类格式。身份证号更明确用char(18)它的长度固定用char比varchar省掉一个长度字节。不要在数据库里明文存整张身份证照片或银行卡号合规风险太大。课程设计可以只存后四位做脱敏显示这本身就是一个能写进简历的细节。状态字段status用tinyint并用注释写明枚举含义例如0待跟进、1跟进中、2已成交、3已流失。用整数而不是字符串是因为SQL里查status1比查statusFOLLOWING更能命中索引也省空间。Java侧用一个枚举类或常量类对应不要在业务代码里散落裸数字。索引设计我一般分三类考虑唯一索引保护业务规则普通索引加速高频查询联合索引服务特定页面。customer表里不给手机号建唯一索引因为前面说了家庭共用手机号是常态但sale_order表里order_no必须建唯一索引这是业务单据的天然约束。follow_record表重点建(customer_id, created_at)联合索引用来支撑“客户跟进历史”页面。test_drive表建(vehicle_id, test_drive_time)联合索引既加速试驾安排查询也配合代码层做冲突判断。还有一条经验外键能不用就不用。物理外键在导入导出、分库、删数据时会变成锁和报错的重灾区。业务关系用代码维护外键只画在E-R图里。这对Java单体系统来说是最稳妥的取舍。2.3 DDL脚本落地一套能直接建库的MySQL语句下面给出一套能直接执行的MySQL建库建表脚本编码统一utf8mb4引擎InnoDB。utf8mb4不是可选项客户的备注字段里可能存Emoji表情编码不对JSP页面上就是一串 “???”这种问题排查起来极费时间。CREATE DATABASE IF NOT EXISTS auto_crm DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE auto_crm; CREATE TABLE customer ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 客户ID, name VARCHAR(50) NOT NULL COMMENT 客户姓名, phone VARCHAR(20) NOT NULL COMMENT 联系电话, id_card_tail CHAR(4) DEFAULT NULL COMMENT 身份证后四位用于身份核对, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, budget_min DECIMAL(12,2) DEFAULT NULL COMMENT 预算下限, budget_max DECIMAL(12,2) DEFAULT NULL COMMENT 预算上限, level TINYINT NOT NULL DEFAULT 1 COMMENT 意向级别 1低 2中 3高, source_channel VARCHAR(20) DEFAULT NULL COMMENT 来源渠道DCC/自然到店/转介绍/网络, status TINYINT NOT NULL DEFAULT 0 COMMENT 0新线索 1跟进中 2已成交 3已流失, owner_id BIGINT DEFAULT NULL COMMENT 归属销售员工IDNULL表示公海, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT 软删除标记, PRIMARY KEY (id), KEY idx_phone (phone), KEY idx_owner_status (owner_id, status) ) ENGINEInnoDB COMMENT客户主表; CREATE TABLE vehicle ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 车辆ID, brand VARCHAR(30) NOT NULL COMMENT 品牌, model VARCHAR(50) NOT NULL COMMENT 车型, vin VARCHAR(17) NOT NULL COMMENT 车架号, color VARCHAR(20) DEFAULT NULL, guide_price DECIMAL(12,2) NOT NULL COMMENT 指导价, stock_status TINYINT NOT NULL DEFAULT 0 COMMENT 0在库 1已预订 2已售出, PRIMARY KEY (id), UNIQUE KEY uk_vin (vin), KEY idx_brand_model (brand, model) ) ENGINEInnoDB COMMENT车辆资源表; CREATE TABLE follow_record ( id BIGINT NOT NULL AUTO_INCREMENT, customer_id BIGINT NOT NULL, follow_type TINYINT NOT NULL COMMENT 0电话 1微信 2到店 3试驾, content VARCHAR(500) DEFAULT NULL COMMENT 跟进内容, next_follow_time DATETIME DEFAULT NULL COMMENT 下次跟进时间, follow_by BIGINT NOT NULL COMMENT 跟进人员工ID, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_customer_time (customer_id, created_at), KEY idx_next_follow (next_follow_time) ) ENGINEInnoDB COMMENT客户跟进记录表; CREATE TABLE test_drive ( id BIGINT NOT NULL AUTO_INCREMENT, customer_id BIGINT NOT NULL, vehicle_id BIGINT NOT NULL, drive_time DATETIME NOT NULL COMMENT 预约试驾时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0已预约 1已完成 2已取消, comment VARCHAR(255) DEFAULT NULL COMMENT 试驾评价, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_vehicle_time (vehicle_id, drive_time) ) ENGINEInnoDB COMMENT试驾预约表; CREATE TABLE sale_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单编号ORDyyyyMMdd随机数, customer_id BIGINT NOT NULL, vehicle_id BIGINT NOT NULL, contract_amount DECIMAL(12,2) NOT NULL COMMENT 合同金额, actual_amount DECIMAL(12,2) NOT NULL COMMENT 实收金额, payment_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未付 1部分 2已付清, delivery_date DATETIME DEFAULT NULL COMMENT 交车日期, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_customer (customer_id), KEY idx_vehicle (vehicle_id) ) ENGINEInnoDB COMMENT汽车成交订单表;脚本里有几个值得说明的参数选择。customer表不建uk_phone唯一索引就是为了允许“一个手机号对应多个联系人”的真实场景这是汽车获客的特点。vehicle表的VIN码建唯一索引因为车架号是车辆的唯一身份标识靠它防止同一辆车被录两遍。所有时间字段用DATETIME而不是TIMESTAMP因为TIMESTAMP只到2038年而一辆车从入库到报废远超这个时间跨度DATETIME还能避免MySQL服务器时区设置引发的 “时间莫名差8小时” 的经典问题。在这套表结构上一个Java后端要做的事就是把customer、vehicle这些实体映射为JavaBean把上面的SQL封装进DAO再在Service层控制状态流转。表设计对了后面写Servlet、JSP都不会返工表设计错了每加一个功能都要回改建表语句那才是真正的噩梦。3. 三层架构与JDBC封装在不引入重型框架的前提下让业务代码可维护JetBrains和Eclipse建个Java Web项目很简单点几下下一步就有了。但源码交到答辩老师或下一任维护者手里时能不能让人十分钟内找到“客户分配 ”这个功能的入口取决于包结构整理得干不干净。课程设计和中小型车商系统都不必引Spring全家桶用Servlet JSP JDBC这套Java核心技术就能把问题表达清楚还顺便把Java基础面试里常问的面向对象、反射、泛型都用上了。3.1 分层之后类该怎么摆entity、dao、service、servlet的职责边界我习惯把源码按这种包结构组织每个包干一类事com.crm ├── entity -- 对应数据库表的JavaBean ├── dao -- JDBC访问只负责SQL和结果集映射 ├── service -- 业务逻辑、事务控制 ├── servlet -- 接收请求、调用service、返回响应 ├── util -- DB连接、字符串工具、分页工具 └── common -- Result统一响应、BizException、常量类entity包里的Customer类和数据库表的字段一一对应。属性用包装类型而不是基本类型比如Integer而不是int这样数据库里NULL才能被正确映射为Java的null而不是0。用基本类型int接数据库NULL值会静默变成0展示层就看到一个“预算0元”的可笑数据。dao包只做数据访问。方法命名用insert、selectByCondition、updateStatus这种动词开头让人不用看实现就知道在干什么。service包是业务逻辑的家事务边界在这里控制。servlet包里的类尽量薄只做参数接收、调用service、封装Result返回。一个常见误用是让JSP页面直接拼JDBC代码这种源码能跑但改起来就是地狱。翻新一个页面时JSP里的%Java脚本片段和HTML标签混在一起Eclipse里满屏红色专业感直接归零。用上面这个分层JSP只负责渲染拿到的是已经准备好的数据。3.2 一个拿得出手的JDBC连接工具类连接池与防SQL注入的预处理JDBC是Java核心技术里最不能跳过的一环。很多源码为了省事每次请求都DriverManager.getConnection一次这种写法在本地开发时没感觉一旦把系统部署到服务器上并发一高数据库连接就耗尽。我一般用Druid连接池它既能复用连接又能通过监控页面看到慢SQL对课程设计和真实项目都够用。package com.crm.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; public class JdbcUtils { private static DataSource ds; static { try (InputStream in JdbcUtils.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(in); ds DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }配一个druid.propertiesurljdbc:mysql://localhost:3306/auto_crm?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的密码 initialSize5 maxActive20 maxWait3000 validationQuerySELECT 1 testWhileIdletrue参数说明initialSize是启动时创建的物理连接数maxActive是连接池最大连接数maxWait是拿连接的超时毫秒数超过这个时间直接抛异常避免请求无限排队。连接URL里带useSSLfalse是因为本地开发一般没配SSL证书不写会报SSL告警日志serverTimezoneAsia/Shanghai解决时区问题。生产环境再按实际改这些参数。防SQL注入不能靠拼字符串必须用预处理。下面这段是CustomerDao里的查询写法注意占位符和参数绑定public ListCustomer selectByCondition(String keyword, Integer status, int offset, int limit) { String sql SELECT id, name, phone, level, status, owner_id, created_at FROM customer WHERE is_deleted 0 ; ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql AND (name LIKE ? OR phone LIKE ?) ; String like % keyword.trim() %; params.add(like); params.add(like); } if (status ! null) { sql AND status ? ; params.add(status); } sql ORDER BY id DESC LIMIT ?, ?; params.add(offset); params.add(limit); try (Connection conn JdbcUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } try (ResultSet rs ps.executeQuery()) { ListCustomer list new ArrayList(); while (rs.next()) { Customer c new Customer(); c.setId(rs.getLong(id)); c.setName(rs.getString(name)); c.setPhone(rs.getString(phone)); c.setLevel(rs.getInt(level)); c.setStatus(rs.getInt(status)); c.setOwnerId(rs.getLong(owner_id)); c.setCreatedAt(rs.getTimestamp(created_at)); list.add(c); } return list; } } catch (SQLException e) { throw new RuntimeException(查询客户列表失败, e); } }这段代码把动态SQL的拼装和参数绑定分开了keyword和status的可变条件按需追加所有外部输入都通过setObject绑定进预处理语句。这里面其实隐藏着一个Java面试题里反复出现的考点PreparedStatement为什么能防SQL注入因为参数和SQL结构被MySQL服务端分开编译处理用户输入永远是“数据”不会被解析成“命令”。还应准备一个统一响应类Result 用泛型包装返回给前端的数据。它的结构是code、message、data三个字段成功返回Result.success(data)失败返回Result.error(客户已存在)。这样Servlet和JSP之间传递数据的格式是固定的前端拿到code就能判断成败不用每次写一堆散乱的HashMap。这是从大型项目接口设计中“借”过来的习惯用在单体系统里同样清爽。到这里地基已经完成。表结构立住了连接池和Dao层可以跑通基本增删改查。接下来最核心的业务逻辑会进Service层那里面才是汽车CRM和其他管理系统的真正区别所在。4. 线索管理到试驾预约核心业务链路的代码实现客户管理系统的价值不在录入界面而在几条核心链路里线索进公海后怎么分配给销售销售怎么跟进不会漏试驾预约怎么避免撞车订单怎么流转到交车。这一章把这三条链路各抽一段关键代码讲透这些代码是可以在源码里直接复用、当“压舱石”的核心逻辑。4.1 线索分配用一个事务保证客户数据不被两个人抢走数据库层面已经有了owner_id字段。公海线索被某个销售认领时程序要做的不只是“把owner_id改成自己”还要保证两个销售同时点“认领”时只有一个人成功。我以前见过一个源码用先SELECT再UPDATE的写法两个连接同时读到owner_id是NULL然后都执行UPDATE最后一个把前一个的覆盖了客户被两个人同时拥有销售之间当场翻脸。正确做法是让数据库用受影响行数当裁判把“检查与更新”合成一条UPDATE语句public boolean claimCustomer(Long customerId, Long ownerId) { String sql UPDATE customer SET owner_id ?, level 2, status 1, updated_at NOW() WHERE id ? AND owner_id IS NULL AND is_deleted 0; try (Connection conn JdbcUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, ownerId); ps.setLong(2, customerId); int rows ps.executeUpdate(); return rows 1; } catch (SQLException e) { throw new RuntimeException(认领线索失败, e); } }关键在SQL的WHERE条件owner_id IS NULL。Affected rows返回1说明这次认领成功返回0说明在读秒的间隙里客户已经被别人认领了这次操作直接失败。Service层拿到false后返回“该客户已被认领”不用回滚任何数据。这个方案的本质是“乐观锁”代价为零也不需要对整张表加锁。在这类并发冲突中我优先采用这种数据库原子操作而不是在Java里用synchronized。因为Web应用是多个进程运行的synchronized只能锁住当前JVM里的线程集群环境下根本锁不住。线索认领成功之后还要顺手写一条跟进记录这两件事必须在一个事务里完成。在Service层这样组合public void claimWithFollowUp(Long customerId, Long ownerId) { Connection conn JdbcUtils.getConnection(); boolean oldAutoCommit conn.getAutoCommit(); try { conn.setAutoCommit(false); boolean success customerDao.updateOwner(conn, customerId, ownerId); if (!success) { throw new BizException(手速慢了客户已被其他销售认领); } FollowRecord record new FollowRecord(); record.setCustomerId(customerId); record.setFollowType(0); record.setContent(首次认领已通过电话联系客户确认购车意向); record.setNextFollowTime(...); followDao.insert(conn, record); conn.commit(); } catch (Exception e) { conn.rollback(); throw new RuntimeException(e); } finally { conn.setAutoCommit(oldAutoCommit); conn.close(); } }注意这里手动setAutoCommit(false)把共性事务控制集中起来。JDBC的原子性体现在这里认领客户和写跟进记录要么全部成功要么全部回滚。如果漏了事务控制客户被认领了跟进记录却没写成功销售系统里就出现“客户无声无息分配掉”的诡异状态。这也是Java面试题里“怎么保证数据一致性”的一个具体答案不是靠某个魔法而是靠事务边界。4.2 试驾预约与到期提醒时间字段的数学运算试驾预约是汽车CRM比较特殊的业务节点。车辆是稀缺资源一辆热门试驾车周末一天可能排4组客户预约时必须校验时间冲突。public boolean isVehicleBusy(Long vehicleId, Date startTime, Date endTime) { String sql SELECT COUNT(*) FROM test_drive WHERE vehicle_id ? AND status ! 2 AND drive_time BETWEEN ? AND ?; try (Connection conn JdbcUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, vehicleId); ps.setTimestamp(2, new Timestamp(startTime.getTime())); ps.setTimestamp(3, new Timestamp(endTime.getTime())); try (ResultSet rs ps.executeQuery()) { rs.next(); return rs.getInt(1) 0; } } catch (SQLException e) { throw new RuntimeException(查询试驾冲突失败, e); } }这段逻辑的原理就是“时间段与时间段是否存在交集”用BETWEEN做开区间判断。status ! 2 排除了已取消的预约这样取消掉的时间段可以重新被约。注意时间的边界试驾开始时间和上一组结束时间重叠是否允许要由业务定我这里选择“上一组完全结束才释放”。到了“下一步该跟进谁”这个场景就用好下次跟进时间。一个销售负责三四十个客户不可能靠脑子记谁该今天跟。我在源码里会这样查“今日待跟进线索”String sql SELECT id, name, phone, level FROM customer WHERE owner_id ? AND is_deleted 0 AND EXISTS (SELECT 1 FROM follow_record fr WHERE fr.customer_id customer.id AND fr.next_follow_time BETWEEN ? AND ?) ORDER BY id DESC;这里EXISTS子查询和小标量子查询的区别在于EXISTS只要找到一条匹配记录就返回适合“有没有”这种判断换成IN (SELECT customer_id ...)则会把所有匹配的ID先查出来再比对当某天要跟进的客户多时性能差别就很明显。时间计算还有一个坑Java里的new Date()拿到的是当前时刻但“今天”的SQL范围应该是00:00:00到23:59:59。我在项目里常用LocalDate.now().atStartOfDay()拿今天零点再plusDays(1)拿明天零点SQL参数就传这两个边界值。如果直接传new Date()你会发现“今天待跟进”列表到了下午就莫名少了几条数据。5. Java核心技术做系统时最常见的5个坑避坑章这部分整理的是我做汽车类管理系统时反复被问到、也反复翻车的五个真实问题。每条都按现象说起再讲原因和解决方便你遇到类似情况直接按“症状”找对策。5.1 现象客户列表翻页时出现重复数据需求是列表每页显示10条第一页和第二页里同时出现了同一个客户。我排查时先查了SQL分页用的LIMIT没写错但ORDER BY的字段是updated_at而这个字段的值在批量导入时高度相同MySQL在排序字段相等时返回顺序不稳定导致同一行数据在两次查询里落进不同页面。原因就是排序字段没有唯一性约束。解决也很简单在ORDER BY里加副排序键用ORDER BY updated_at DESC, id DESC。id是主键每行必唯一主次排序组合后顺序就确定下来了。这是SQL分页的通用准则凡是排序列不能保证全局唯一就必须追一个主键列做兜底。5.2 现象删除客户后历史成交订单里客户名变成空白业务人员删除一个误录入的客户结果早先关联的销售订单列表里客户姓名那一栏全是空白。原因是程序用了物理删除直接把customer表里的行删掉了而sale_order表在查询时通过customer_id去关联关联不到就返回null页面渲染null就成了空白。汽车行业的数据是要追溯的已经发生过的试驾和成交不能跟着客户主数据的删除而消失。正确的做法是软删除删客户时只执行UPDATE customer SET is_deleted1 WHERE id?查询列表默认过滤掉已删除项但订单联查时包含这些历史数据。这样“删客户”的语义变成“移入回收站”随时能找回也保留了业务链路完整性。我接手的源码里凡是做物理删除的基本都会被业务方在下一次月度复盘时要求改回来。5.3 现象同一辆试驾车同一时间段被预约了两组客户系统里试驾模块上线后后台出现一条试驾冲突投诉说两拨客户在同一辆车前碰面了。检查代码发现保存预约时先查了这个时间段是否有预约没查到再执行INSERT但两个请求同时通过查询都认为该时段空闲于是都插入了。这是典型的“先查后写”并发问题。单靠Java代码里的同步块解决不了多实例部署的问题正确做法是把冲突判断放进数据库写入的约束里。在test_drive表加一个唯一索引(vehicle_id, drive_time)但因为这个方案没法同时保留“已取消”状态我更推荐用事务加行锁在事务里先SELECT ... FOR UPDATE锁住vehicle表对应行再执行查询和插入。行锁会把并发预约串行化第二组预约必须等第一组事务结束才能判断此时查询结果已经能看到冲突。代价是响应时间会被拉长几毫秒但对试驾预约这种低频操作毫无压力。5.4 现象JSP页面上满屏红色脚本片段改样式时小心翼翼打开listCustomer.jsp发现上面用%和%包着大段Java代码里面还嵌套着HTML标签。功能确实能跑但看代码就像看意大利面条。这是直接用脚本片段写业务逻辑的后果New一个DAO在JSP里就直接查库所有的输出都在页面里处理。这种设计在答辩和团队协作时都很吃亏无法测试无法复用改个查询条件要整页翻。治本之法是三层架构Java逻辑全部下沉到Servlet和ServiceJSP里只放EL表达式${c.name}和JSTL标签c:forEach。我通常会写一个BaseServlet用反射把actionclaim这类参数自动路由到对应方法这样少写十几二十个Servlet类代码量直接下来。public class BaseServlet extends HttpServlet { protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (action null || action.isEmpty()) { action list; } try { Method method this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(404, 未知的 action: action); } catch (Exception e) { throw new ServletException(执行 action 失败, e); } } }这里的反射用法是Java核心里的经典考点也顺带优化了整个项目的类数量。注意Method.invoke这样用的时候方法名就是action值因此action参数在Servlet入口处要做一个白名单校验防止被传入非法方法名。5.5 现象查询汽车库存列表时页面转圈10秒才出结果库存表只有一千多条数据但每次刷新列表都像卡死一样。查了慢查询日志发现SQL走了全表扫描。原因有两层一是vehicle表里对brand字段做了LIKE查询且没有索引二是列表页每次加载都把车辆信息全部查出包括备注大字段而页面根本用不上。解决方式分两步。第一步给查询加上索引比如对(brand, model)建联合索引LIKE的前缀匹配能命中。第二步是对列表查询做瘦身在SQL的SELECT子句里只列出页面展示需要的列把guide_price, color这些留着但把其他备注字段去掉减少MySQL传输的数据量。这一步做完同样的接口耗时从10秒降到200毫秒效果立竿见影。如果排序和过滤条件经常变化可以打开MySQL慢查询日志定位这类毛刺。配置如下threshold调成1秒SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;修改GLOBAL变量在MySQL8.0里是SESSION级即时生效的但仍建议在配置文件里固定下来否则MySQL重启就恢复默认。日志会打印执行慢的SQL语句和实际执行时长从EXPLAIN SELECT ...能看到走了哪个索引、有没有全表扫描。这是每个Java后端都要具备的排查基本功别等用户把卡顿截图甩到群里才去找原因。6. 给统计看板加一个60秒内存缓存CRM启动后的第一个性能优化系统跑通之后下一步很容易想到的是“给4S店老板做个数据看板”今日新增客户数、本周试驾次数、本月成交额、每个销售的战绩排行。这些统计接口和普通列表有一个本质区别它们的输入基本不变但首页一打开就同时发好几个聚合SQL每个都要扫全表COUNT、SUM。最省事且有效的做法不是马上上Redis而是用ConcurrentHashMap做一个带过期时间的本地缓存。缓存周期60秒看板数据允许延迟一分钟刷新业务完全能接受。package com.crm.common; import java.util.concurrent.ConcurrentHashMap; public class StatsCache { private static final ConcurrentHashMapString, CacheItem CACHE new ConcurrentHashMap(); private static class CacheItem { Object value; long expireAt; CacheItem(Object value, long expireAt) { this.value value; this.expireAt expireAt; } } public static Object get(String key) { CacheItem item CACHE.get(key); if (item null) { return null; } if (System.currentTimeMillis() item.expireAt) { CACHE.remove(key); return null; } return item.value; } public static void put(String key, Object value, int ttlSeconds) { long expireAt System.currentTimeMillis() ttlSeconds * 1000L; CACHE.put(key, new CacheItem(value, expireAt)); } }Service层这样用拿不到缓存就走统计SQL再回填public MapString, Object dashboard() { String key dashboard_stats_202405; Object cached StatsCache.get(key); if (cached ! null) { return (MapString, Object) cached; } MapString, Object stats statsDao.loadDashboardStats(); StatsCache.put(key, stats, 60); return stats; }这里的String key可以直接用一个常数因为看板数据是全局的不需要按用户区分如果以后要按销售维度出个人榜单key改成leaderboard_user_1024这种“业务前缀对象ID”的格式就行。缓存失效策略走时间过期不主动更新。为什么不做主动失效因为看板数据每分钟推一次足够主动清理反而引入缓存与数据库一致性的复杂讨论。这个技巧的启示是性能优化要从最高频、最耗时的读接口下手而不是一开始就上分布式缓存。我自己的习惯是每次写完统计查询先问一句能接受延迟多少秒大多数看板场景的答案是“晚一分钟也看不太出来”那就用60秒本地缓存搞定。希望帮到你。本文还有配套的精品资源点击获取
返回列表