SpringCloud微服务系列写到第五篇,先聊点实际的。前几篇我们把服务注册发现、配置中心、网关路由这些“骨架”搭好了,服务也能互相调通了,但一个服务真正跑起来,99%的接口最后都要落到数据库操作上。这个时候团队里特别容易开始吵架:订单状态到底存数字还是字符串?扩展字段要不要拆表?分页查询为什么每个服务写一套?这篇我就把持久层这三个高频痛点一次性理清楚:枚举处理器、JSON处理器、分页插件,顺便给出在SpringCloud微服务里可以直接抄走的完整实现方案。
这篇内容适合谁?只要是正在做SpringCloud微服务落地,或者想把传统单体的MyBatis层迁移过来的,都可以对照着用。尤其是负责公共模块、基础框架的开发者,读完可以直接把这一套沉淀成团队内部的starter,让业务同学写代码时根本不需要关心枚举映射和JSON转换。
1. 为什么微服务要专门处理枚举、JSON和分页
很多人一开始会有个疑问:这些不是MyBatis的基础功能吗,跟微服务有什么关系?我的理解是,单体应用里这些问题可以靠“人肉约定”解决,大家改同一个代码库,代码 review 时能发现;到了微服务里,服务拆开了,团队也拆开了,如果不在框架层面统一约定,同一个枚举值在订单服务里存1,在用户服务里存true,在支付回调里又成了字符串,最后联调时全在排查数据不一致。所以这一篇表面上是三个工具类功能,实际上是在帮微服务团队建立持久层的“通用协议”。
1.1 先聊聊这三件事各自解决了什么
先看枚举处理器。业务里到处都是状态:订单状态、支付方式、用户类型、审批结果。数据库里存数字最高效,也最省空间,但Java代码里如果用Integer, 那每一次判断都得写魔法值,if (status == 1) 这种代码过两周自己都看不懂。用枚举类型,代码可读性上来了,但MyBatis默认不知道枚举怎么存怎么读,所以要靠TypeHandler把“数据库数字”和“Java枚举”打通。
再看JSON处理器。微服务拆分后,很多表为了让下游扩展字段时不改表结构,会留一个JSON类型的列。比如商品表里加一个 attributes 字段,存一些不固定的属性;或者订单表里存一个 JSON 格式的回调信息。Java实体里如果直接声明为String,那业务里每次都要手动序列化、反序列化,还要处理异常,很繁琐,而且类型不安全。JSON处理器的价值就是让实体里可以直接写一个类或List,读写数据库时自动转换。
最后是分页插件。微服务接口只要做列表查询,基本都逃不开分页。如果每个团队都手写 LIMIT、手写 COUNT,很容易出现“页数越界不统一”“返回结构不一致”“count和list数据对不上”的问题。分页插件把这块统一了,同时能针对不同数据库方言做改写,避免我们在PostgreSQL、MySQL之间迁移时还要改SQL。
1.2 技术选型背后的考量
在SpringCloud技术栈里,持久层方案目前主流还是MyBatis系列。我这次选的是MyBatis-Plus 3.5.x,一个很现实的原因:它把枚举、JSON字段、分页这三个需求都原生覆盖了,而且和SpringBoot、SpringCloud Alibaba集成成本极低。如果你团队还在用原生MyBatis + PageHelper,思路是一样的,只是注解和配置稍有不同。
我建议版本这这样配:Spring Boot 2.7.x、Spring Cloud Alibaba 2021.x、MyBatis-Plus 3.5.2。这组版本我实测过很多次,稳定,网上资料也多。如果你用的是Spring Boot 3.x,那就得换MyBatis-Plus 3.5.5以上版本,注意看官方发布说明,别一上来就踩兼容性坑。
1.3 这套方案在微服务链路里的位置
在微服务架构里,通常一个请求会经过网关、服务提供者、数据库。我们说的这三个处理器都发生在服务提供者这一层,也就是业务侧Mapper往下到数据库的区间。当上游通过Feign调用时,传输对象里很可能包含枚举字段、JSON字段,返回结构里的分页信息也需要统一包装。
我的习惯是:在公共模块里定义好 BasePageResponse ,所有服务接口都返回这个结构给网关或前端。这样前端不用对每个服务单独适配,后端写分页接口的时候也不会几个人写出来好几种格式。而这个结构里的data列表,经由JSON序列化时,枚举和JSON字段都已经在TypeHandler层面转成了前端友好的数据。
2. 枚举处理器:代码里写枚举,数据库存数字
先上结论:强烈建议数据库存数字,Java里用枚举。字符串可读性是好,但一旦你要改枚举名,数据库里的历史数据就全部作废;数字则没有这个问题,只要保证数字和业务的映射关系不变就行。下面我们直接在MyBatis-Plus工程里把枚举处理器跑起来。
2.1 数据库枚举字段的两种常规玩法
很多人早期是这么做的:数据库字段用int,Java实体用Integer,然后service层写一个static方法做转换。
public static String getStatusName(Integer status) { switch (status) { case 0: return "待支付"; case 1: return "已支付"; ... } }这种方式写起来快,但问题很多。一旦其他服务也用到这个枚举,要么复制粘贴这段代码,要么单独抽一个枚举工具类。更麻烦的是,前端展示和数据库写入顺序一旦错位,排查成本特别高。用Java枚举加TypeHandler之后,实体字段直接声明为OrderStatus类型,Mapper查询完返回的就是OrderStatus,写入时传OrderStatus也能自动变成数字。
2.2 基于MyBatis-Plus的枚举处理器完整实现
我用一个订单状态枚举来做示例,这是最常见的场景。先定义一个枚举类:
public enum OrderStatus { PENDING(0, "待支付"), PAID(1, "已支付"), SHIPPED(2, "已发货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); @EnumValue private final Integer code; @JsonValue private final String desc; OrderStatus(Integer code, String desc) { this.code = code; this.desc = desc; } }这里有两个关键注解:
- @EnumValue 来自MyBatis-Plus,它告诉MyBatis-Plus:枚举里哪个字段对应数据库里的值。注意是code,不是name,这样数据库存的是0、1、2而不是PENDING。
- @JsonValue 来自Jackson,它告诉Jackson在序列化枚举的时候,输出desc而不是默认的枚举name。这一步特别重要,不然后端往前端传时,前端看到的是“PAID”,还得自己翻译。
然后配置一下application.yml:
mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler其实MyBatis-Plus 3.5.x在扫描到@EnumValue之后会自动启用这个Handler,但显式写出来能避免某些特殊场景下不生效,推荐写上。实体里直接这样声明:
public class Order { private Long id; private OrderStatus status; // ... }这样写完后,你插入一条订单的时候,status字段传OrderStatus.PAID,MyBatis-Plus会自动把PAID对应的code=1写到数据库。查询出来时,数据库里的1也会自动变成OrderStatus.PAID。整条链路对业务代码是透明的。
2.3 枚举处理器原理与匹配逻辑
说到底,MyBatis的TypeHandler做的是两件事:写入时setNonNullParameter把Java枚举转成JDBC可以接收的值,读取时getNullableResult把数据库的值转回Java枚举。MyBatis-Plus的MybatisEnumTypeHandler只是把这一步标准化了,它会读取枚举类里标注了@EnumValue的字段,把这个字段的值作为映射的key。
有个小细节:一个枚举类里只能有一个@EnumValue字段,如果标注了多个,启动时会报错。另外@EnumValue字段的类型可以是Integer、Long、String,甚至Short,但一定要和数据库字段类型兼容,否则写入时JDBC会抛异常。
知道了原理之后,排查问题就简单了。如果查询结果里枚举为null,大概率是数据库的值不在枚举定义的code列表里,或者TypeHandler没有被加载。别一上来就怀疑框架,先拿SQL工具查一下实际值是什么。
2.4 注意事项与日常踩坑
我在实际项目里看到不少人忘了处理“新增枚举”的问题。比如线上已经存在status=4的数据,新版本里你把4从“已取消”改成了“售后中”,那历史订单展示就会全部错乱。所以给枚举加新值,只允许追加,不允许改已有code的含义。这是团队规范问题。
还有个坑是Feign调用。如果你的微服务之间通过Feign传输包含枚举字段的DTO,消费者反序列化时也需要知道如何把“PAID”或“已支付”转成OrderStatus。建议在枚举类里加上@JsonCreator,并兼容多种输入形式:
@JsonCreator public static OrderStatus fromCode(Integer code) { for (OrderStatus status : values()) { if (status.code.equals(code)) return status; } return null; }别问我为什么同时有@JsonValue和@JsonCreator不冲突,前者管出,后者管进,搭配使用才能保证Feign调用端也能正确转出枚举。
3. JSON处理器:让数据库JSON列变成实体对象
微服务里非常常见的一个诉求是“给一张表留一个扩展字段”。比如用户表加一个profile_json,存头像、昵称、个性化配置;订单表加一个extra_info,存优惠券信息、赠品列表、买家留言。如果把这些都拆成独立表,数据库结构会很碎,而且很多信息本身就是偶发的,拆表反而增加JOIN成本。JSON字段就是这个场景的最优解。
3.1 什么场景适合用JSON字段
我的建议是“需要结构灵活、但是不参与核心查询逻辑”的字段可以考虑JSON。常见的有:
- 业务扩展属性:不同来源的订单,可能携带不同的扩展数据
- 埋点数据:请求来源、页面参数、A/B实验标记
- 配置快照:下单时的商品快照、优惠快照
- 第三方回调原始报文:支付、物流回调信息
另外要注意,JSON字段在MySQL 5.7+有了原生支持,可以直接用JSON_EXTRACT等函数做提取,但索引、JOIN、排序都不是强项。如果你确定某个字段要频繁where查询,那更合适的方案是把关键字段冗余出来一个普通列,JSON里只存附属信息。
3.2 自己写JSON TypeHandler还是用自带功能
MyBatis-Plus提供了JacksonTypeHandler,可以直接覆盖大多数场景。但它有一个要求:实体字段需要加上@TableName(autoResultMap = true)和@TableField(typeHandler = JacksonTypeHandler.class)。如果少了autoResultMap,查询时MyBatis-Plus无法自动为这个字段指定typeHandler,就会把它当做普通对象处理,结果自然是空。
我最初接手一个老项目时,团队用的是自己手写的TypeHandler,我后来换成了JacksonTypeHandler。区别不大,但自带的省事,而且JacksonTypeHandler用Jackson序列化,和我们SpringBoot里默认的JSON库一致,不容易出现“这边写进去那边读出来格式不一样”的情况。
3.3 实现JSON字段读写完整流程
先定义一个实体类,比如用户表的扩展信息:
public class UserProfile { private String nickname; private Integer age; private String avatar; private List<String> tags; }然后实体类:
@TableName(value = "user", autoResultMap = true) public class User { @TableId(type = IdType.ASSIGN_ID) private Long id; private String name; @TableField(typeHandler = JacksonTypeHandler.class) private UserProfile profile; }数据库列类型如果是MySQL,建议写成json类型:
CREATE TABLE `user` ( id BIGINT PRIMARY KEY, `name` VARCHAR(64), profile JSON );插入数据时,给profile赋值一个UserProfile对象即可,MyBatis-Plus会序列化成JSON字符串写入。查询时,JacksonTypeHandler会把JSON字符串反序列化成UserProfile对象。注意UserProfile需要有无参构造方法,以及有getter/setter,否则Jackson反射会失败。
如果你要存的是List对象,其实也可以直接声明List ,但有时候泛型擦除会让反序列化变成List edhashmap> 。稳妥的写法是定义一个包装类:
public class UserProfileList { private List<UserProfile> profiles; }然后再把字段类型声明为UserProfileList,这样Jackson就能准确定位到反序列化目标,避免很多莫名其妙的问题。
3.4 JSON字段与查询过滤的边界
JSON列能直接作为where条件吗?可以,MySQL支持JSON_EXTRACT,但性能一般,而且加了函数之后索引基本失效。我见过有的项目把某个业务标签放在了JSON里,然后后台管理列表想按标签筛选,结果一条SQL就能拖垮整个库。后来方案改成:标签单独抽出一个关联表,JSON里保留原始快照。这叫“用空间换易用性”,在微服务里尤其重要。
另外,JSON字段和分页插件完全可以共存。分页插件会拦截Mapper方法,生成带LIMIT的SQL,JSON列只是作为一个普通字段来回传递,不影响分页逻辑。如果非要针对JSON字段排序,最好用生成列或冗余列,不要在线上狠命造。
4. 分页插件:微服务分页查询的正确姿势
分页是微服务里每天都要写、但很容易写乱的功能。有人喜欢手写LIMIT,有人喜欢PageHelper,有人喜欢MyBatis-Plus自带的Page。我建议团队里统一用一个,方便维护。下面我重点讲MyBatis-Plus分页插件的集成和常见坑。
4.1 PageHelper vs MyBatis-Plus分页插件
先做个对比,方便团队选型时决策:
| 对比项 | PageHelper | MyBatis-Plus 分页插件 |
|---|---|---|
| 核心原理 | 拦截器解析SQL,自动拼接count和limit | 拦截器解析SQL,自动拼接count和limit |
| 使用方式 | 先PageHelper.startPage(x,y),再执行查询 | Mapper方法接收IPage参数,返回IPage |
| 和MyBatis-Plus实体配合 | 可以,但需要在启动类排除冲突 | 原生支持,Mapper方法返回值可以是IPage |
| 跨数据库方言 | 通过dialect参数配置 | 通过DbType指定,枚举支持MySQL、PostgreSQL等 |
| 多数据源复杂度 | 需要额外设置方言 | 每个数据源可以配不同拦截器 |
如果你已经从零开始使用MyBatis-Plus,那就别PageHelper了,直接用MyBatis-Plus自带的分页插件,少引入一个依赖,代码也少一层。如果团队是老的MyBatis工程,保留PageHelper也完全可以。
4.2 分页插件到底是怎么改SQL的
分页插件的核心是MyBatis的Interceptor机制。它在Executor执行查询前拦一下,拿到原始SQL,然后做两件事:先执行一条COUNT语句统计总数,再执行一条带上LIMIT的查询语句。这两个动作对业务代码透明,我们只需要把当前页码和每页大小传给插件就行。
这里有个关键点:COUNT语句的生成不是直接包一层select count(*) from (原SQL),那样性能很差。MyBatis-Plus的分页插件会尽量简化COUNT SQL,比如去掉order by、去掉select list,只保留必要的from和where。但遇到复杂JOIN、UNION时,它也会退回子查询统计,这时候就需要我们手动优化SQL。
在微服务场景,我们通常还要考虑多租户。MyBatis-Plus的TenantLineInnerInterceptor和PaginationInnerInterceptor可以叠加使用,只要在配置MybatisPlusInterceptor时注意顺序,一般把多租户拦截器放在分页前面。
4.3 集成步骤与配置
先引入依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency>然后配置分页拦截器Bean:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); // 超过最大页数时返回到第一页,避免恶意大页码拖垮数据库 pagination.setOverflow(true); // 单页最大500条,防止接口被刷 pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }两个参数建议一定要设置:setOverflow(true)和setMaxLimit(500L)。前一个避免页数越界,后一个避免有人把pageSize传成10000。
Service里这么写:
public PageResult<OrderVO> queryOrderPage(OrderQuery query) { Page<Order> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Order::getStatus, query.getStatus()) .orderByDesc(Order::getCreateTime); Page<Order> result = orderMapper.selectPage(page, wrapper); return PageResult.of(result); }注意这里的selectPage方法,一定不要自己再拼接LIMIT,否则分页插件会重复加LIMIT导致SQL语法错误。
4.4 分页联调、动态条件组合与返回格式统一
微服务接口一般不希望直接把IPage暴露给前端,因为Page对象字段太多,而且带上了一些MyBatis-Plus内部的属性。我习惯在公共模块定义一个统一的PageResult:
public class PageResult<T> { private Long total; private List<T> records; private Long pageNum; private Long pageSize; }这样前端结构稳定,后端也方便在网关层统一处理。
动态条件和分页组合时,最需要注意的是条件构造器的顺序。MyBatis-Plus的LambdaQueryWrapper是线程不安全的,每次请求都要新建,不要把它放在静态变量里复用。另外所有查询条件都要通过wrapper的eq、like等方法生成,避免用字符串拼接SQL导致注入。
还有一个常见问题:MyBatis-Plus分页插件只对Mapper层的selectPage生效。如果你在Mapper里写的自定义SQL是selectPage(Page page, @Param("cond") Cond cond),那SQL里不需要写LIMIT,插件会帮你在后面追加。但前提是方法的第一个参数必须是IPage,返回值可以是IPage或者List,这两个都能被识别。
5. 常见问题与排查技巧实录
这三块功能看着简单,但集成进微服务里之后,总会冒出一些奇奇怪怪的问题。我把自己和团队踩过的坑整理成了一张速查表,再挑几个典型的排查过程展开讲讲。
5.1 高频问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 枚举字段查询返回null | 数据库值不在枚举code列表;TypeHandler未生效 | 检查数据库值;确认@EnumValue配置和default-enum-type-handler |
| 前端展示枚举变成了“PAID” | 没有加@JsonValue | 给枚举的输出字段加@JsonValue或改全局Jackson配置 |
| Feign调用时枚举反序列化失败 | 枚举类缺少@JsonCreator或构造方法不匹配 | 添加@JsonCreator,兼容code和name输入 |
| JSON字段查询为null | 实体没配@TableName(autoResultMap = true) | 加上autoResultMap;检查typeHandler类名 |
| JSON字段插入报SQLException | 数据库列类型不是json或text | 改成text/json;确认列够长 |
| 分页count正确但records为空 | 原SQL里已经手动写了LIMIT | 去掉手动LIMIT,让插件统一处理 |
| 分页查询很慢 | count子查询在复杂JOIN上走了全表 | 优化SQL,减少不必要的JOIN,必要时拆查询 |
| 多数据源时分页方言不对 | 每个数据源都应配置对应DbType | 按数据源分别添加PaginationInnerInterceptor |
这张表只是起点。真遇到问题时,先关掉分页插件跑一遍原SQL,再确认枚举和JSON的TypeHandler是否生效,不要一上来就怀疑SpringCloud配置。
5.2 一次真实的排查:前端枚举值显示成了name
有一次订单列表页面突然显示状态为“PAID”,而不是“已支付”。排查下来发现是新同事在DTO里直接用了OrderStatus枚举,而OrderStatus当时的@JsonValue注解还没加。后端日志里数据一切正常,因为对数据库来说存的是1,但Jackson序列化时默认调用了枚举的name(),所以前端看到了“PAID”。
加完@JsonValue后,前端就显示“已支付”了。后来我在团队规范里加了一条:所有返回给前端的枚举,必须保证输出的是业务描述而不是枚举类名。这个规范并不是强迫所有人背下来,而是通过代码审查和公共模块统一约束。
还有一次更有意思,Feign调用时带上OrderStatus枚举,消费方死活反序列化不了。后来发现消费方依赖的OrderStatus是另一个jar包里的同名枚举,代码字段不一样,搞得两边数据都错乱了。微服务里这个坑尤其多,所以我的建议是:跨服务传输的枚举,最好放在一个独立的公共依赖里,并且通过@JsonCreator兼容多种输入,别在多个服务里重复定义同一个业务枚举。
5.3 性能与安全细节
分页看似简单,但性能问题不容忽视。有一次我们有个订单查询接口,单表数据几百万,之前的写法是三表JOIN后分页,每页20条都要全表扫描count。后来我把主查询和分页拆开,先分页查主表ID,再回表查详情,直接把RT从2秒降到了100毫秒。分页插件只是帮你生成SQL,SQL本身的质量还是得自己把关。
再强调一个安全细节:排序字段一定不能直接拼接前端传进来的字符串。有的接口为了支持前端排序,把orderBy字段直接拼进SQL,结果被人用SQL注入拖库。我的建议是维护一个字段白名单,比如“createTime”“updateTime”“id”,前端只能从这里面选,后台再映射成真正的数据库列名。
JSON字段的读写也要注意数据校验。如果接口允许用户传JSON扩展信息,一定要加上长度限制和字段白名单。否则一个用户把整个对象塞进去,不仅浪费存储,还可能在反序列化时导致内存溢出。我的规则是:JSON列最大不超过16KB,入参结构由DTO约束,入库前统一做一次校验。
结尾
这三个功能单独拎出来都不难,但真正放到SpringCloud微服务里,它们组合起来的效果才是团队最需要的。枚举处理器让业务语义和存储值解耦,JSON处理器让扩展字段不再频繁改表,分页插件让接口返回结构统一。我在实际项目里的感受是,把这三件事沉淀成公共能力之后,业务开发同学写新接口的速度明显快了,联调期因为字段类型扯皮的次数也少了很多。
最后再分享一个小心得:不要等到出问题才做公共组件。我们在搭建微服务框架的第一周,就把枚举、JSON、分页统一成了一套基础starter,所有新服务直接引用。后面几个项目上线时,几乎没有人再为这些基础功能写过第二遍代码。如果你正在做SpringCloud系列,建议这期内容里的代码和配置直接落进公共模块,后面你会感谢当时的决定。