简介这份资源是面向Java全栈学习者与中医药信息化方向开发者的知识科普平台完整源码基于SpringBoot与Vue前后端分离架构整合MyBatis、MySQL、ElasticSearch与Redis解决中医药知识分散、检索效率低、传播渠道有限的问题适合作为毕业设计、课程项目或企业级搜索类应用的参考模板。压缩包共151个文件约327KB以124个Java源码为核心涵盖控制器、服务层与搜索、缓存等业务实现另含12个JSON配置、7个XML映射、1个YML配置、1个SQL示例数据文件及Dockerfile、说明文档等结构清晰便于二次开发。已有98人学习下载。读者可从中获得一套可直接运行的中医药知识库工程骨架理解MyBatis数据持久化、ElasticSearch全文检索与Redis缓存加速的整合思路并参考Docker部署与示例数据快速搭建本地环境适合需要完整项目实战与排错参考的中高级开发者。1. 中医药知识库系统从 SpringBoot 到 ElasticSearch 的落地拆解中医药知识科普平台这个方向看起来像是内容站实际做起来会发现它比普通博客复杂得多。普通博客只需要一张文章表加一个模糊查询但中医药知识库要处理的是方剂、药材、症状、证候之间的多对多关系还要支持用户用「气虚发热」「肝郁脾虚」这类复合词去检索MySQL 的 LIKE 查询在数据量过万之后基本就废了。这个标题里给出的技术栈——SpringBoot、Vue、MyBatis、MySQL、ElasticSearch、Redis——恰好覆盖了这类系统最核心的六个环节后端接口、前端交互、数据持久化、结构化存储、全文检索和缓存加速。适合正在做毕业设计、课程项目或者想从零搭一个垂直领域知识库的开发者。接下来我会按实际搭建顺序把每个环节的选型理由、配置参数和踩坑点讲清楚让你能照着复现一套能跑起来、能检索、能抗住一定并发的系统。2. 后端骨架与数据层SpringBoot 整合 MyBatis 和 MySQL2.1 为什么中医药数据不适合直接上 JPA中医药知识库的数据模型有一个显著特点实体之间的关系复杂且查询模式多变。一个方剂对应多味药材一味药材出现在多个方剂中同时药材还有性味归经、功效分类等属性。如果用 JPA 的实体关联映射很容易写出 N1 查询而且中医药的检索条件经常是「功效包含某关键词且性味为温」这种动态组合JPA 的 Criteria API 写起来非常啰嗦。MyBatis 的优势在这里就体现出来了SQL 完全可控动态 SQL 用if和foreach就能拼出复杂的检索条件而且中医药领域的分页查询往往需要配合全文检索做二次过滤手写 SQL 比 ORM 自动生成更灵活。常见做法是用 MyBatis-Plus 作为增强工具它内置的分页插件能省掉手写 limit 的麻烦同时保留 XML 写复杂 SQL 的能力。2.2 SpringBoot 项目初始化与 MyBatis 配置先建一个标准的 SpringBoot 项目依赖选 Web、MyBatis Framework、MySQL Driver、Redis、ElasticSearch Client。如果用 IDEA 的 Spring Initializr注意 SpringBoot 版本选 2.7.x 或 3.x 时 ElasticSearch 客户端版本差异很大后面会专门讲。!-- pom.xml 关键依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-elasticsearch/artifactId /dependencyMyBatis-Plus 的版本要和 SpringBoot 版本匹配3.5.x 系列对 SpringBoot 2.7 和 3.x 都有对应支持。MySQL 驱动在 SpringBoot 3.x 里建议换成com.mysql:mysql-connector-j否则启动时会报驱动类找不到。# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/tcm_knowledge?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 5000ms elasticsearch: uris: http://localhost:9200 connection-timeout: 5s socket-timeout: 30s mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case让数据库的created_at自动映射到 Java 的createdAt省掉大量 resultMap 配置。log-impl设为 StdOutImpl 可以在控制台看到实际执行的 SQL调试动态 SQL 时非常有用但上线前记得关掉否则日志量会爆炸。逻辑删除字段deleted是中医药知识库的刚需——药材和方剂的数据需要保留历史版本不能物理删除。2.3 中医药核心表设计与分页查询中医药知识库最少需要这几张核心表药材表herb、方剂表formula、症状表symptom、方剂药材关联表formula_herb、用户收藏表user_favorite。药材表的关键字段包括名称、别名、性味、归经、功效、用法用量、禁忌。方剂表包括方名、出处、组成、功用、主治、加减法。CREATE TABLE herb ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 药材名称, alias VARCHAR(128) COMMENT 别名逗号分隔, nature VARCHAR(16) COMMENT 性味如温、寒、平, meridian VARCHAR(64) COMMENT 归经, efficacy TEXT COMMENT 功效, usage_dosage TEXT COMMENT 用法用量, contraindication TEXT COMMENT 禁忌, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0, INDEX idx_name (name), INDEX idx_nature (nature) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;分页查询用 MyBatis-Plus 的Page对象配合自定义 XML 做多条件筛选。下面是一个按性味和功效关键词检索药材的示例// HerbMapper.java public interface HerbMapper extends BaseMapperHerb { IPageHerb selectByCondition(PageHerb page, Param(nature) String nature, Param(keyword) String keyword); }!-- HerbMapper.xml -- select idselectByCondition resultTypecom.tcm.entity.Herb SELECT * FROM herb WHERE deleted 0 if testnature ! null and nature ! AND nature #{nature} /if if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR efficacy LIKE CONCAT(%, #{keyword}, %)) /if ORDER BY id DESC /selectMyBatis-Plus 的分页插件会自动在 SQL 后面拼接 limit 语句不需要手写。注意Page对象要作为第一个参数传入否则插件拦截不到。Param注解在多个参数时必须加否则 XML 里只能用param1、param2这种默认名可读性极差。提示MySQL 的 LIKE 查询在数据量超过 5 万行后性能下降明显中医药知识库如果收录上万条药材和方剂必须把全文检索交给 ElasticSearchMySQL 只做精确匹配和关联查询。3. ElasticSearch 全文检索中医药术语的分词与索引设计3.1 为什么 MySQL 的 LIKE 撑不住中医药检索中医药检索的难点在于术语的特殊性。用户搜「气虚」时希望匹配到功效包含「补气」的药材搜「肝郁脾虚」时希望匹配到主治包含「疏肝健脾」的方剂。这种语义相关性用 LIKE 完全做不到而 ElasticSearch 的倒排索引加上自定义分词器可以很好地解决。另一个现实问题是性能。MySQL 的LIKE %关键词%无法走索引每次都是全表扫描。当药材表加方剂表的数据量到十万级一次检索可能要几百毫秒甚至更久。ElasticSearch 的倒排索引可以把检索时间压到毫秒级而且支持高亮、聚合、拼写纠错等高级功能。3.2 安装 ElasticSearch 与中文分词插件Windows 下安装 ElasticSearch 最简单的方式是下载 zip 包解压后运行bin/elasticsearch.bat。注意 ElasticSearch 8.x 默认开启安全认证会生成证书和随机密码本地开发嫌麻烦可以在config/elasticsearch.yml里把xpack.security.enabled设为 false。启动后访问http://localhost:9200看到集群信息就算成功。中文分词必须装 IK 插件。下载对应版本的 ik 压缩包解压到plugins/ik目录重启 ElasticSearch。验证分词效果curl -X POST http://localhost:9200/_analyze -H Content-Type: application/json -d { analyzer: ik_max_word, text: 肝郁脾虚型失眠 }ik_max_word会把「肝郁脾虚」拆成「肝郁」「脾虚」「肝郁脾虚」等多个词条适合索引阶段使用ik_smart只做粗粒度拆分适合搜索阶段。常见做法是索引时用ik_max_word提高召回率搜索时用ik_smart提高准确率。3.3 SpringBoot 整合 ElasticSearch 与索引映射SpringBoot 2.7 用spring-boot-starter-data-elasticsearch时底层是 ElasticSearch 7.x 的客户端SpringBoot 3.x 默认用 ElasticSearch 8.x 的 Java API Client写法差异很大。下面以 SpringBoot 2.7 ElasticSearch 7.x 为例用ElasticsearchRestTemplate操作。// HerbDocument.java Document(indexName tcm_herb) public class HerbDocument { Id private Long id; Field(type FieldType.Text, analyzer ik_max_word, searchAnalyzer ik_smart) private String name; Field(type FieldType.Text, analyzer ik_max_word, searchAnalyzer ik_smart) private String efficacy; Field(type FieldType.Keyword) private String nature; Field(type FieldType.Text, analyzer ik_max_word) private String meridian; // getter/setter 省略 }Field注解里的analyzer指定索引分词器searchAnalyzer指定搜索分词器。nature字段用Keyword类型因为性味是精确值温、寒、平不需要分词用 Keyword 可以做聚合统计。索引创建后如果映射不对需要删掉索引重建直接改注解不会自动更新映射。// HerbSearchService.java Service public class HerbSearchService { Autowired private ElasticsearchRestTemplate restTemplate; public ListHerbDocument search(String keyword, String nature, int page, int size) { NativeSearchQueryBuilder builder new NativeSearchQueryBuilder(); BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); if (StringUtils.hasText(keyword)) { boolQuery.must(QueryBuilders.multiMatchQuery(keyword, name, efficacy, meridian) .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)); } if (StringUtils.hasText(nature)) { boolQuery.filter(QueryBuilders.termQuery(nature, nature)); } builder.withQuery(boolQuery); builder.withPageable(PageRequest.of(page, size)); builder.withHighlightFields( new HighlightBuilder.Field(name).preTags(em).postTags(/em), new HighlightBuilder.Field(efficacy).preTags(em).postTags(/em) ); return restTemplate.search(builder.build(), HerbDocument.class) .getSearchHits().stream() .map(hit - { HerbDocument doc hit.getContent(); // 高亮结果处理实际项目中需要把高亮片段替换回原字段 return doc; }).collect(Collectors.toList()); } }multiMatchQuery的BEST_FIELDS策略会让匹配度最高的字段得分最高适合药材名称和功效权重不同的场景。filter里的termQuery不参与评分性能比must更好。高亮字段需要在返回结果里手动处理hit.getHighlightFields()拿到的是片段 Map要替换回原文档字段才能返回给前端。3.4 数据同步MySQL 到 ElasticSearch 的三种方案数据同步是中医药知识库最容易翻车的地方。常见做法有三种一是双写在 Service 层保存 MySQL 的同时写 ElasticSearch实现简单但容易不一致二是定时任务全量同步适合数据量小、实时性要求不高的场景三是监听 MySQL binlog 增量同步用 Canal 或 Debezium最可靠但运维成本高。我一般会先用双写加定时补偿保存时写 MySQL 和 ElasticSearch同时每天凌晨跑一次全量同步任务把 MySQL 数据重新灌到 ElasticSearch。这样即使双写失败第二天也能自动修复。全量同步用 SpringBoot 的Scheduled注解Scheduled(cron 0 0 3 * * ?) public void fullSync() { ListHerb herbs herbMapper.selectList(null); ListIndexQuery queries herbs.stream() .map(h - new IndexQueryBuilder() .withId(String.valueOf(h.getId())) .withObject(convertToDocument(h)) .build()) .collect(Collectors.toList()); restTemplate.bulkIndex(queries, HerbDocument.class); }bulkIndex批量写入比单条索引快一个数量级但要注意每批不要超过 1000 条否则内存压力大。同步前最好先删掉旧索引再重建避免残留已删除的数据。4. Redis 缓存与接口层热点数据的加速与一致性4.1 中医药知识库哪些数据值得缓存不是所有数据都适合放 Redis。中医药知识库的读多写少特征很明显药材详情、方剂详情、热门方剂列表、分类树这些数据访问频率高但更新频率低非常适合缓存。而用户的收藏列表、搜索历史这类个性化数据缓存收益不大反而增加一致性维护成本。我一般会缓存三类数据一是详情页数据key 用herb:detail:{id}过期时间设 30 分钟二是首页热门列表key 用herb:hot:top20过期时间 10 分钟三是分类和性味枚举这类数据几乎不变可以设 1 小时过期。缓存穿透用空值缓存加布隆过滤器解决缓存雪崩用随机过期时间解决。4.2 SpringBoot 整合 Redis 与缓存注解Configuration EnableCaching public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } Bean public CacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(factory).cacheDefaults(config).build(); } }GenericJackson2JsonRedisSerializer会在 JSON 里带上class字段反序列化时能自动还原对象类型但要求实体类有无参构造器。disableCachingNullValues关掉空值缓存如果要防穿透需要手动处理空值。Service public class HerbService { Autowired private HerbMapper herbMapper; Autowired private RedisTemplateString, Object redisTemplate; Cacheable(value herb, key #id, unless #result null) public Herb getById(Long id) { return herbMapper.selectById(id); } CacheEvict(value herb, key #herb.id) public void update(Herb herb) { herbMapper.updateById(herb); } }Cacheable的unless条件在返回 null 时不缓存避免空值占用内存。CacheEvict在更新时删除缓存保证下次读取时重新加载。注意Cacheable在同一个类内部方法调用时不生效因为 Spring 的缓存是基于代理的必须从外部调用。4.3 缓存与数据库的一致性处理缓存一致性是绕不开的问题。常见做法是先更新数据库再删除缓存而不是更新缓存。因为更新缓存的代价可能比删除大而且并发更新时容易产生脏数据。删除缓存后下次读取会从数据库加载最新值。如果要求强一致可以用延迟双删更新数据库后删一次缓存等 500 毫秒再删一次覆盖并发读导致的旧值回填。但中医药知识库这种场景最终一致就够了延迟双删反而增加复杂度。我一般会在更新数据库后直接删缓存然后依赖过期时间兜底。注意Redis 的Cacheable默认用 JDK 序列化存进去的值在 redis-cli 里看是乱码。换成GenericJackson2JsonRedisSerializer后虽然可读但反序列化时如果实体类字段有变化会报错上线后不要随意改实体类结构。5. 避坑与排查中医药知识库搭建中的五个血泪教训5.1 ElasticSearch 版本与 SpringBoot 不匹配导致启动报错现象SpringBoot 启动时抛NoSuchMethodError或ClassNotFoundException指向 ElasticSearch 客户端类。原因SpringBoot 2.7 默认的 ElasticSearch 客户端版本是 7.17.x如果本地安装的是 8.x或者 pom 里手动引入了其他版本的客户端就会出现类冲突。解决用mvn dependency:tree | grep elasticsearch查看实际依赖版本确保 SpringBoot 版本、客户端版本、服务端版本三者一致。SpringBoot 2.7 配 ElasticSearch 7.17SpringBoot 3.x 配 ElasticSearch 8.x不要混用。5.2 IK 分词器安装后未重启或版本不对现象_analyze接口返回的分词结果还是单字没有按中医药术语拆分。原因IK 插件没有放到plugins目录下或者插件版本和 ElasticSearch 版本不一致或者安装后没有重启 ElasticSearch。解决确认plugins/ik目录下有plugin-descriptor.properties文件版本号与 ElasticSearch 完全一致。重启后先用_cat/plugins确认插件已加载再用_analyze验证分词效果。5.3 MyBatis 分页插件不生效导致返回全量数据现象接口返回的数据没有分页total和records数量一致或者records是全部数据。原因MyBatis-Plus 的分页插件没有注册或者Page对象没有作为第一个参数传入 Mapper 方法。解决在配置类里注册MybatisPlusInterceptor并添加PaginationInnerInterceptor指定数据库类型为 MySQL。Mapper 方法的第一个参数必须是Page类型且方法返回值是IPage。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }5.4 Redis 缓存 key 冲突导致数据串台现象访问药材详情时返回了方剂的数据或者不同用户看到相同的收藏列表。原因缓存 key 没有加业务前缀或者 key 的拼接逻辑有误导致不同业务的数据覆盖了同一个 key。解决所有缓存 key 强制加业务前缀如tcm:herb:detail:{id}、tcm:formula:detail:{id}。用户维度的数据必须把用户 ID 拼进 key如tcm:user:favorite:{userId}。在 RedisConfig 里可以配置全局 key 前缀但更推荐在业务代码里显式拼接可读性更好。5.5 全文检索结果与 MySQL 数据不一致现象MySQL 里已经删除的药材在 ElasticSearch 里还能搜到或者新增的药材搜不到。原因双写时 ElasticSearch 写入失败但没有回滚 MySQL或者定时同步任务没有覆盖到增量数据。解决在双写逻辑里加 try-catchElasticSearch 写入失败时记录日志并发送告警不要影响 MySQL 事务。定时同步任务改成全量覆盖每次同步前先删除索引再重建。如果数据量大用别名切换的方式新建索引写入数据完成后把别名指向新索引再删除旧索引。6. 检索效果调优从能搜到到搜得准6.1 用同义词词典提升中医药术语召回率中医药的术语同义词非常多「气虚」和「气不足」「肝郁」和「肝气郁结」「脾虚」和「脾胃虚弱」用户搜的词和索引里的词经常对不上。ElasticSearch 的 synonym 过滤器可以解决这个问题。在索引的 settings 里配置同义词文件{ settings: { analysis: { filter: { tcm_synonym: { type: synonym, synonyms_path: analysis/tcm_synonyms.txt } }, analyzer: { ik_synonym: { type: custom, tokenizer: ik_max_word, filter: [tcm_synonym, lowercase] } } } } }同义词文件放在 ElasticSearch 的config/analysis/目录下每行一组同义词用逗号分隔。配置好后把索引的 analyzer 改成ik_synonym重建索引即可生效。同义词文件修改后需要重启 ElasticSearch 或调用_reload_search_analyzers接口重新加载。6.2 用 function_score 给权威药材加权中医药知识库里有些药材是高频核心药材如人参、黄芪、当归有些是冷门药材。用户搜索时核心药材应该排在前面。ElasticSearch 的function_score可以按字段值加权FunctionScoreQueryBuilder functionScore QueryBuilders.functionScoreQuery( boolQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{ new FunctionScoreQueryBuilder.FilterFunctionBuilder( QueryBuilders.termQuery(is_core, true), ScoreFunctionBuilders.weightFactorFunction(3.0f) ), new FunctionScoreQueryBuilder.FilterFunctionBuilder( ScoreFunctionBuilders.fieldValueFactorFunction(usage_count) .factor(0.1f).modifier(FieldValueFactorFunction.Modifier.LOG1P) ) } ).scoreMode(FunctionScoreQuery.ScoreMode.SUM);is_core是药材表里的一个布尔字段标记是否为核心药材。usage_count记录药材被收藏或查看的次数用LOG1P修饰符避免高频药材权重过大。scoreMode用SUM把多个函数的得分相加也可以用MULTIPLY做乘法。6.3 用 search_after 替代 fromsize 做深度分页ElasticSearch 的fromsize分页在翻到第 100 页以后性能急剧下降因为每个分片都要返回fromsize条数据给协调节点。中医药知识库如果支持用户翻很多页必须用search_after。SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.query(boolQuery); sourceBuilder.sort(_score, SortOrder.DESC); sourceBuilder.sort(id, SortOrder.ASC); sourceBuilder.size(10); if (lastId ! null) { sourceBuilder.searchAfter(new Object[]{lastScore, lastId}); }search_after需要有一个唯一的排序字段通常是 id作为 tiebreaker否则分页会丢数据。每次返回结果里带上最后一条的排序值下一页请求时传回来。这种分页方式不支持跳页只适合「下一页」场景但性能稳定翻到第 1000 页也不会变慢。6.4 我踩过的坑和现在的习惯刚开始做中医药知识库时我直接把 MySQL 的数据全量灌到 ElasticSearch没有做字段映射结果 ElasticSearch 自动把「性味」字段映射成了 text 类型导致聚合统计时把「温」和「微温」拆成了不同的词。后来养成了习惯索引必须先手动创建 mapping所有枚举字段用 keyword所有需要分词的字段显式指定 ik 分词器创建索引的代码和实体类放在一起版本管理。另一个教训是缓存和检索的边界。我一度把 ElasticSearch 的检索结果也缓存到 Redis结果数据更新后缓存没失效用户搜到的还是旧数据。现在我的原则是Redis 只缓存详情页和配置类数据检索结果不缓存因为检索条件组合太多缓存命中率低维护成本高。如果你正在做中医药知识库或者类似的垂直领域检索系统建议先把 MySQL 和 ElasticSearch 的同步链路跑通再逐步加缓存和调优。检索效果调优是个迭代过程先保证能搜到再追求搜得准。希望帮到你。本文还有配套的精品资源点击获取