Java后端实战:法律咨询系统从表设计到并发调优全解析
2026/9/17 18:15:43 网站建设 项目流程

简介:基于Java的法律咨询系统设计与实现毕业设计文档,面向计算机相关专业毕业生、Java开发学习者及法律信息化项目从业者。文档以传统法律咨询与法规信息管理效率低、容错率低为切入点,完整给出基于Mysql数据库、Java语言和SSM(Spring、SpringMVC、MyBatis)框架的解决方案,内容涵盖法规管理、法律咨询管理、论坛管理、法规留言管理、公告管理等核心功能模块。资源包内共1个docx格式文档,大小3.5MB,便于直接阅读与二次编辑。目前已有83人学习下载。读者可从中获得完整的系统分析与设计思路、数据库表结构设计、后端框架集成方法以及安全性设计要点,既能作为毕业设计撰写参考,也可为实际法律咨询信息管理项目提供可落地的技术蓝本。

1. 法律咨询系统不是聊天室,Java 后端要解决的是业务闭环

做法律咨询系统最容易踩的坑,是把它做成一个“用户提问—律师回答”的留言板。真实的法律咨询场景里,用户要预约、要上传证据材料、要知道案件进展;律师要做日程排期、要填写咨询记录、要能按案件类型检索过往答复;运营方还需要留痕审计、统计咨询量和结案率。所以一套能上线用的法律咨询系统,核心是用 Java 把“咨询—接单—答复—归档”这条业务链做成带状态和权限控制的闭环,而不是只做消息推送。

这个标题里的“设计与实现”,本质上是问你四件事:数据模型怎么建、核心流程怎么串、查询和权限怎么做、部署后怎么调。本文以 Java 语言为主线,用 Spring Boot + MyBatis-Plus 这类从业者最常用的组合,把系统从表结构设计到接口实现再到性能调优的完整路径讲清楚,适合刚做完 Java 基础学习、想拿一个完整项目练手的人,也适合后端工程师快速评估这类业务系统的设计要点。

2. Java 技术栈选型与项目骨架搭建,先把环境跑通再谈业务

2.1 为什么是 Java:生态成熟不是空话

法律咨询系统属于典型的 MIS(管理信息系统)业务,特点是表多、状态多、权限分明、并发量不高但准确性要求极高。Java 在这一领域有天然优势:Spring Boot 把事务、日志、JSON 序列化这些事已经封装到了“开箱即用”的程度,MyBatis-Plus 又解决了单表 CRUD 的重复劳动,而且 Java 的强类型体系在处理“咨询类型”“案件状态”这类枚举型字段时,比动态语言更不容易在重构中出错。

如果你有印象,Java 面试八股文里反复考的事务传播机制、动态代理、线程池,在这个项目里全部能落到实处。比如 Spring 声明式事务的@Transactional,在处理“创建咨询订单 + 扣减律师时段 + 生成待办通知”这种多表操作时就非常重要。

2.2 基础环境:JDK 版本和构建工具的选择

建议使用 JDK 1.8 或 JDK 11。如果你的机器上还没装好 Java,先检查环境变量配置是否正确,在命令行里执行java -version,确认输出不是“不是内部或外部命令”。JDK 安装后必须配置JAVA_HOMEPATHCLASSPATH三个环境变量,这一步是后面所有工作的前提。

项目构建工具用 Maven,版本 3.6+ 即可。Maven 的核心作用是统一管理依赖版本,避免“本地能跑,同事那编译失败”的悲剧。在pom.xml里维护统一版本,核心依赖如下:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这段配置里,spring-boot-starter-parent锁定 Spring Boot 版本,能有效规避依赖版本冲突。mybatis-plus-boot-starter引入 ORM 框架,让你不需要手写简单的单表 SQL。如果你用的 JDK 版本高于 8,会遇到 Lombok 插件不兼容的问题,报错信息类似you aren't using a compiler supported by lombok,这时把 Lombok 依赖升级到 1.18.30 以上即可解决。

2.3 项目目录结构与四层分包

常见的做法是把分包方式按“controller → service → mapper → entity”四层切,再加一个config包放全局配置。具体结构如下:

com.law.consult ├── controller // HTTP 接口层 ├── service // 业务逻辑层,接口 + 实现 │ └── impl ├── mapper // MyBatis-Plus 数据访问层 ├── entity // 数据库实体 ├── dto // 前端入参/出参对象 ├── config // 配置类(WebMvc、拦截器、全局异常) └── common // 统一返回结果、枚举、工具类

分包的核心逻辑是单向依赖:controller 只调 service 接口,service 实现里操作 mapper,谁都不允许越层访问。这个约束对 5 年以上经验的开发者来说是肌肉记忆,但对新手却是能救命的结构,它能保证后续加功能时不需要把整个项目翻一遍。

2.4 配置文件里的必调参数

application.yml是项目跑起来的动力源。除了常见的数据源配置,有四个参数值得专门说明:

spring: datasource: url: jdbc:mysql://localhost:3306/law_consult?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

参数逻辑说明:serverTimezone=Asia/Shanghai解决 MySQL 8.x 的时区报错;map-underscore-to-camel-case让数据库字段user_name自动映射到实体属性userName,少写一堆@TableField注解;logic-delete-field是 MyBatis-Plus 的逻辑删除配置,业务数据不物理删除,只打标记,法律咨询记录需要留存备查,这一点尤其重要。

3. 法律咨询系统的数据库设计,先把六张核心表建模

3.1 表结构设计原则:状态驱动而非流程驱动

法律咨询业务的关键实体是用户、律师、咨询订单、咨询回复、法规库文件、操作日志。其中咨询订单表是全局的中枢,几乎所有的查询和统计都围绕它进行。设计时需要注意两个核心原则:

第一个原则是冗余关键字段,避免频繁联表。比如咨询订单表里同时冗余律师姓名和用户姓名,而不是每次查询都去 JOIN 用户表,这是典型的牺牲存储换查询性能的做法。

第二个原则是用状态字段控制流程,不用物理行消除。咨询状态从待接单 → 已接单 → 咨询中 → 已完成 → 已取消流转,每次状态变化写入操作日志表,方便审计回溯。

3.2 咨询订单表的核心字段与建表 SQL

下表是consult_order表的字段清单,也是整个系统里最核心的一张表:

字段名类型说明
idbigint主键,雪花算法生成
order_novarchar(64)业务单号,前端展示用
user_idbigint咨询用户 ID
lawyer_idbigint接单律师 ID
consult_typetinyint咨询类型(1婚姻家事,2劳动纠纷,3合同纠纷,4刑事咨询)
descriptiontext用户提交的问题描述
statustinyint咨询状态(0待接单,1已接单,2咨询中,3已完成,4已取消)
appointment_timedatetime预约开始时间
duration_minutesint预计持续分钟数
fee_amountdecimal(10,2)咨询费用
deletedtinyint逻辑删除标记

对应的建表 SQL 如下:

CREATE TABLE `consult_order` ( `id` bigint NOT NULL COMMENT '主键', `order_no` varchar(64) NOT NULL COMMENT '业务单号', `user_id` bigint NOT NULL COMMENT '用户ID', `lawyer_id` bigint DEFAULT NULL COMMENT '律师ID', `consult_type` tinyint NOT NULL COMMENT '咨询类型', `description` text COMMENT '问题描述', `status` tinyint NOT NULL DEFAULT '0' COMMENT '咨询状态', `appointment_time` datetime DEFAULT NULL COMMENT '预约时间', `duration_minutes` int DEFAULT '30' COMMENT '时长(分钟)', `fee_amount` decimal(10,2) DEFAULT '0.00' COMMENT '费用', `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_type` (`status`, `consult_type`), KEY `idx_user_id` (`user_id`), KEY `idx_lawyer_id` (`lawyer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='咨询订单表';

索引设计说明:idx_status_type是组合索引,支撑“查询所有待接单的婚姻家事咨询”这类运营端高频筛选;idx_user_ididx_lawyer_id分别服务“我的咨询列表”和“律师工作台”两个查询方向。SQL 里故意没有用外键,而是在应用层维护关联关系,这是因为法律咨询系统表数量不多,外键约束在数据量大时会影响写入性能,且容易在分库分表时成为阻碍。

3.3 文件表和日志表:容易被忽视的细节

用户在咨询前通常需要上传证据材料,比如合同照片、聊天记录截图、法院传票,所以要有一张consult_file表。设计时有几个细节值得注意:文件不能只存路径,要同时存原始文件名和文件大小,并且加上file_type字段区分”用户上传"和"律师出具"两个来源。推荐的表设计字段包括id, order_id, file_name, file_url, file_size, file_type, uploader_id, create_time

操作日志表operation_log用来记录谁在什么时间把订单状态从 A 改成了 B。字段设计为id, order_id, operator_id, operator_role, action, from_status, to_status, remark, create_time。这张表的价值在纠纷发生时能提供完整的证据链,是法律咨询系统区别于普通业务系统的关键表。

3.4 法规库表:让“无法可依”变成可检索

法律咨询系统区别于一般论坛的另一个重点,是有一个可检索的法规库。法规库表law_regulation的简化结构如下:

CREATE TABLE `law_regulation` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '法规名称', `category` varchar(50) DEFAULT NULL COMMENT '分类,如民法典、劳动法', `publish_org` varchar(100) DEFAULT NULL COMMENT '发布机构', `effective_date` date DEFAULT NULL COMMENT '生效日期', `content_text` longtext COMMENT '全文内容', `keyword` varchar(500) DEFAULT NULL COMMENT '关键词,逗号分隔', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='法规库表';

这里需要特别注意content_text字段用longtext类型,因为法律条文内容往往很长。而keyword字段的设计是给后续检索用的,运营人员录入法规时手动维护几个核心关键词,虽然原始,但在数据量不超过十万级时比引入 Elasticsearch 性价比高得多。如果想要更精准的检索效果,可以在这个字段上建 FULLTEXT 索引,用 MySQL 自带的全文检索能力。

4. 用 Spring Boot 把“咨询—接单—回复”流程串成闭环

4.1 统一返回结果与全局异常处理

做后端接口的第一步,不是写业务代码,而是把返回结构定下来。我一般会定义一个通用的Result<T>类,包含codemessagedata三个字段。所有 controller 方法都返回这个包装类型,前端拿到后统一按这个结构解析,避免有的接口返回true、有的返回{"msg":"ok"}的混乱局面。

全局异常处理是用@RestControllerAdvice注解实现的,它可以捕获所有未处理的异常并转换为标准格式返回。业务异常用自定义的BusinessException,系统异常由全局处理器统一兜底。这样做的好处是 controller 层代码不需要 try-catch,业务逻辑更清爽。

4.2 创建咨询订单接口:事务与幂等

创建订单是整个系统的起点。用户提交咨询表单后,后端需要同时做三件事:插入订单记录、锁定律师的时间段、生成一条初始操作日志。这三件事必须在一个事务里完成。

@PostMapping("/api/consult/order") public Result<Long> createOrder(@RequestBody @Valid ConsultOrderCreateDTO dto) { Long orderId = consultOrderService.createOrder(dto); return Result.success(orderId); }

ConsultOrderServiceImpl里的核心代码逻辑如下:

@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(ConsultOrderCreateDTO dto) { // 1. 幂等校验:同一用户同一时间段不能重复预约 long count = this.count(new LambdaQueryWrapper<ConsultOrder>() .eq(ConsultOrder::getUserId, dto.getUserId()) .eq(ConsultOrder::getAppointmentTime, dto.getAppointmentTime()) .ne(ConsultOrder::getStatus, ConsultOrderStatus.CANCELLED.getCode())); if (count > 0) { throw new BusinessException("您在该时间段已存在预约,请勿重复提交"); } // 2. 构建订单实体 ConsultOrder order = new ConsultOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setConsultType(dto.getConsultType()); order.setDescription(dto.getDescription()); order.setAppointmentTime(dto.getAppointmentTime()); order.setStatus(ConsultOrderStatus.PENDING.getCode()); order.setFeeAmount(calcFee(dto.getConsultType(), dto.getDurationMinutes())); this.save(order); // 3. 写入操作日志 OperationLog log = new OperationLog(); log.setOrderId(order.getId()); log.setAction("CREATE_ORDER"); log.setFromStatus(0); log.setToStatus(ConsultOrderStatus.PENDING.getCode()); operationLogService.save(log); // 4. 推送通知(异步处理,不影响主流程事务) notifyService.pushToLawyer(order.getId()); return order.getId(); }

代码逻辑说明:@Transactional保证第 2、3 步要么同时成功要么同时回滚。第 1 步的幂等校验是为了防止用户手抖点了两次提交产生重复订单。save方法由 MyBatis-Plus 内置,不用手写 INSERT 语句。generateOrderNo()生成唯一业务单号,推荐格式为“日期 + 随机数”,例如202405121030001234

如果你熟悉 Java 动态代理,会知道@Transactional的底层机制正是 Spring 通过 JDK 动态代理对带有该注解的 Bean 做增强,在方法进入前开启事务、方法退出后提交或回滚。这也能解释为什么同类调用(即一个方法内部调用另一个带@Transactional的方法)会失效——代理对象没有经过 Spring 容器。

4.3 律师接单与状态流转:乐观锁防止超接

律师工作台的核心操作是接单。也是并发冲突最容易爆发的点:多位律师同时看到同一个待接订单,谁先点“接单”谁赢。如果用update ... where id = ?的写法,后提交的请求会覆盖先提交的请求,导致两个律师都以为订单是自己的。

解决方案是在订单表加一个version字段,更新时带上版本号做条件:

@Override @Transactional(rollbackFor = Exception.class) public boolean acceptOrder(Long orderId, Long lawyerId) { // 乐观锁更新:只有 status=0 且版本号匹配才更新成功 ConsultOrder update = new ConsultOrder(); update.setId(orderId); update.setLawyerId(lawyerId); update.setStatus(ConsultOrderStatus.ACCEPTED.getCode()); update.setVersion(currentVersion); int rows = consultOrderMapper.update(update, new LambdaQueryWrapper<ConsultOrder>() .eq(ConsultOrder::getId, orderId) .eq(ConsultOrder::getStatus, ConsultOrderStatus.PENDING.getCode()) .eq(ConsultOrder::getVersion, currentVersion)); return rows > 0; }

参数说明:rows > 0说明当前请求成功更新了记录;rows = 0说明订单已被别人抢走,或者版本号已变化,当前请求直接失败,不回滚也不重试。这是典型的乐观锁实现,适合法律咨询这种低并发但要求强一致的场景。如果用悲观锁(SELECT ... FOR UPDATE),在这个场景下是对稀缺资源加行锁,性能反而更差,因为律师端的高频操作就是不断刷新抢单页面。

4.4 异步处理与线程池配置,避免接口超时

创建订单后要推送站内信、短信通知律师,接单后要通知用户。如果这些操作都放在主线程同步执行,接口响应时间会被拉长到几百毫秒甚至几秒。常见的做法是用@Async注解把通知类操作异步化。

这是需要重点配置的线程池参数:

@Configuration public class AsyncConfig implements AsyncConfigurer { @Override @Bean("lawExecutor") public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); // 核心线程数 executor.setMaxPoolSize(10); // 最大线程数 executor.setQueueCapacity(200); // 队列容量 executor.setThreadNamePrefix("law-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }

参数的选择逻辑:核心线程数 4 是因为推送通知是 IO 密集型任务,CPU 不是瓶颈;队列容量 200 起到削峰填谷的作用,短期打满线程池不会立刻触发拒绝策略;拒绝策略用CallerRunsPolicy,当任务实在排不进去时由调用线程自己执行,保证通知业务不丢失。这比直接丢弃或抛异常要稳妥得多。

以“Java 线程等待都完成”为例,如果需要批量推送邮件后再给用户返回结果,可以用CountDownLatch或直接注入ThreadPoolTaskExecutor后调用executor.getThreadPoolExecutor().awaitTermination(5, TimeUnit.SECONDS),确保所有子任务处理完再结束主流程。

4.5 查询接口:分页必须用 MyBatis-Plus 的分页插件

咨询列表接口是后端最容易出现全表扫描的地方。一定要配置分页插件,否则Page对象不会自动拼接 LIMIT 语句:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

列表接口的标准写法:

@GetMapping("/api/consult/my-orders") public Result<Page<ConsultOrderVO>> myOrders(@RequestParam Long userId, @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize) { Page<ConsultOrder> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<ConsultOrder> wrapper = new LambdaQueryWrapper<ConsultOrder>() .eq(ConsultOrder::getUserId, userId) .orderByDesc(ConsultOrder::getCreateTime); Page<ConsultOrder> result = consultOrderService.page(page, wrapper); return Result.success(convertToVO(result)); }

这里的Page<>(pageNum, pageSize)是分页参数对象,LambdaQueryWrapper用类型安全的 lambda 表达式替代硬编码字符串列名,字段改名时编译器直接报错,不会在运行时才暴露问题。

5. 法规库检索、状态机设计与查询性能调参

5.1 法规库的关键词检索,先上 MySQL FULLTEXT

法规库检索的实现没有想象的复杂。当数据量在十万级以内,先用 MySQL 的 FULLTEXT 索引撑住日常使用即可,不必引入分布式搜索引擎。先给law_regulation表打上 FULLTEXT 索引,然后写查询 SQL:

SELECT id, title, category, effective_date, MATCH(title, content_text, keyword) AGAINST ('劳动合同 解除 赔偿' IN NATURAL LANGUAGE MODE) AS score FROM law_regulation WHERE MATCH(title, content_text, keyword) AGAINST ('劳动合同 解除 赔偿' IN NATURAL LANGUAGE MODE) ORDER BY score DESC LIMIT 20;

这里的AGAINST参数IN NATURAL LANGUAGE MODE是自然语言检索模式,适合中文分词后的场景;score是相关度得分,按得分降序排列能让最相关的结果排在最前面。这种检索方式对短关键词效果尚可,但如果你想支持“按发布机构筛选后再全文搜索”的复合条件,FULLTEXT 和普通 WHERE 混用时要注意执行计划,避免索引失效。

如果你要更灵活的中文分词能力,可以引入ngram全文解析器,在创建索引时指定WITH PARSER ngram,这样可以解决默认分词器对中文支持差的问题。MySQL 的ngram默认分词长度为 2,适合法律条文这种以双字词为主的中文内容。

5.2 用状态机管理咨询生命周期,拒绝散落的 if-else

咨询订单的状态流转如果到处写if (order.getStatus() == 1) { ... },代码会变得无法维护。推荐的做法是定义一个状态机枚举,把“当前状态 + 操作动作 → 目标状态”的映射集中管理。

public enum OrderStateMachine { PENDING(0, "待接单"), ACCEPTED(1, "已接单"), CONSULTING(2, "咨询中"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; private static final Map<Integer, Map<String, Integer>> TRANSITIONS = new HashMap<>(); static { Map<String, Integer> pendingMap = new HashMap<>(); pendingMap.put("ACCEPT", 1); pendingMap.put("CANCEL", 4); TRANSITIONS.put(0, pendingMap); Map<String, Integer> acceptedMap = new HashMap<>(); acceptedMap.put("START", 2); acceptedMap.put("CANCEL", 4); TRANSITIONS.put(1, acceptedMap); Map<String, Integer> consultingMap = new HashMap<>(); consultingMap.put("COMPLETE", 3); TRANSITIONS.put(2, consultingMap); } public static int next(int currentStatus, String action) { Map<String, Integer> actionMap = TRANSITIONS.get(currentStatus); if (actionMap == null || !actionMap.containsKey(action)) { throw new BusinessException("非法状态流转: " + currentStatus + " -> " + action); } return actionMap.get(action); } }

在这个设计里,所有状态跳转必须经过next()方法校验,非法操作直接抛异常。后面接入管理员后台或者其他客户端时,这套映射能被复用。如果系统的状态流转规则经常变化,可以把映射表挪到数据库里配置化,但业务规模不大时硬编码枚举反而更容易阅读。

5.3 慢查询排查与索引调优的三个常见问题

查询性能调优是后端工程师绕不开的工作,法律咨询系统虽然数据量不大,也要预防慢查询拖垮接口。

第一个常见问题是在索引列上做函数运算。常见的错误写法是WHERE DATE(create_time) = '2024-05-12',这会导致索引失效。正确做法是写成范围查询:

WHERE create_time >= '2024-05-12 00:00:00' AND create_time < '2024-05-13 00:00:00'

第二个常见问题是隐式类型转换order_no在表里是varchar类型,查询时如果传入 Long 类型数字,MySQL 会自动把字段转成数字再比较,索引同样会失效。务必保证实体类中定义的属性类型和数据库字段类型匹配。

第三个常见问题是组合索引字段顺序用反。组合索引idx_status_type(status, consult_type)能够支撑“状态 + 类型”联合查询,也能支撑只查状态的场景,但不能高效支撑只按consult_type查询的场景。如果运营端经常单独按类型筛选,需要再建一个单列索引。

排查方法可以用EXPLAIN命令:

EXPLAIN SELECT * FROM consult_order WHERE status = 0 AND consult_type = 2;

重点看type列的取值:如果显示ALL说明全表扫描,需要调整索引。如果显示ref,说明索引利用正常。type 字段从好到坏依次是system > const > eq_ref > ref > range > index > ALL,只要不是ALL基本可以接受。

另外,日志参数里log-impl: StdOutImpl会在控制台打印每一条 SQL,你可以在开发环境用它直接把EXPLAIN要分析的 SQL 原样拿出来。生产环境记得关掉或用Slf4jImpl输出到日志文件,否则控制台打印会拖慢接口响应。

5.4 Redis 缓存:把高频查询挡住

咨询类型列表、律师列表、法规条文的点击热榜这类数据,变化频率低、查询频次高,适合用 Redis 做缓存。Spring Boot 整合 Redis 的常见写法:

@Service public class RegulationCacheService { @Resource private StringRedisTemplate stringRedisTemplate; private static final String CACHE_KEY = "law:regulation:hot:"; public List<LawRegulation> getHotRegulations(int topN) { String key = CACHE_KEY + topN; String cached = stringRedisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseArray(cached, LawRegulation.class); } List<LawRegulation> list = regulationMapper.selectHotList(topN); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), 10, TimeUnit.MINUTES); return list; } }

这里缓存过期时间设 10 分钟,是权衡数据新鲜度和数据库压力后的常见选择。要注意缓存穿透问题——如果查询的 ID 在数据库里不存在,每次请求都会直接打到数据库,可以用空值缓存或者布隆过滤器解决。法律咨询系统因为数据量不大,空值缓存成本很低,推荐优先使用:

if (cached == null) { stringRedisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES); }

6. 部署前压测验证并发抢单,用乐观锁参数做极限测试

最后一章聊一个具体技巧:如何在本地模拟多位律师同时抢同一张订单,验证乐观锁和线程池配置是否真的可靠。很多系统上线后的第一个故障就出在“上线前没做过并发验证”。

最常见的做法是用 Apache JMeter 或简单的 Java 多线程程序施压。下面是一个用 Java 并发工具CountDownLatch模拟 50 个律师同时抢 1 张订单的测试代码:

public class ConcurrentAcceptTest { private static final int THREAD_COUNT = 50; public static void main(String[] args) throws InterruptedException { Long orderId = 10001L; ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch readyLatch = new CountDownLatch(THREAD_COUNT); CountDownLatch startLatch = new CountDownLatch(1); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); for (int i = 1; i <= THREAD_COUNT; i++) { final Long lawyerId = 1000L + i; executor.submit(() -> { readyLatch.countDown(); try { startLatch.await(); // 等待所有线程就绪后统一发令 boolean result = acceptOrder(orderId, lawyerId); if (result) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } catch (Exception e) { failCount.incrementAndGet(); } }); } readyLatch.await(); startLatch.countDown(); executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); System.out.println("成功接单数: " + successCount.get()); System.out.println("失败接单数: " + failCount.get()); } }

测试代码里CountDownLatch是为了保证 50 个线程尽可能同时发起请求,模拟“秒杀”场景的瞬时并发。如果实现正确,successCount应该是 1,failCount是 49。如果出现successCount大于 1 的情况,说明乐观锁更新条件没写对,或者version字段没有正确参与 WHERE 条件。

压测时建议用visualvm观察线程池队列打满后的表现,如果大量请求超时,检查线程池参数里的queueCapacity是否合理。在线程池打满、队列打满、拒绝策略触发后,观察CallerRunsPolicy是否造成了调用线程阻塞——如果阻塞时间过长,说明核心线程数设小了,可以按公式“核心线程数 = CPU 核数 ×(1 + 平均等待时间 / 平均执行时间)”做一个粗调,实际效果以压测为准。

验证完并发正确性后,再检查另外几个容易漏掉的点:确认@TransactionalrollbackFor = Exception.class,避免RuntimeException之外的异常不触发回滚;确认逻辑删除字段deleted在 MyBatis-Plus 的查询中自动拼接条件,避免查询出已被删除的记录;确认所有日期字段在返回前端时格式统一为yyyy-MM-dd HH:mm:ss,否则前端拿到2024-05-12T10:30:00这种 ISO 格式还要做额外转换。

最后别忘了在测试环境跑一遍完整的业务链路:用户创建订单 → 律师抢单 → 开始咨询 → 上传材料 → 完成归档,每一步都去operation_log表确认有对应的审计记录。这样的一套验证流程,是系统从“本地能跑通”到“线上能扛住”的关键一步。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询