设备台账这类文档型业务落到 MongoDB 上,Java 侧最常见的两个持久层选择是 Spring Data MongoDB Repository 和 MyBatis。前者靠方法名自动生成查询,后者靠手写 SQL 精细控制。这篇笔记用同一个设备集合(Device)把两边拆开对比,顺带记录一个真实踩过的并发坑。框架没有绝对优劣,分界线在于:你要的是「少写代码」还是「控制语句」。
Repository:方法名就是查询语句
定义一个接口,继承 MongoRepository,查询方法按命名规则声明即可:
@Repository
public interface DeviceRepository extends MongoRepository<Device, String> {
Device findByDeviceId(String deviceId);
List<Device> findByDeviceType(String deviceType);
List<Device> findByStatus(String status);
}
findByDeviceId 会被框架解析成 { "deviceId" : 参数 } 的 MongoDB 查询,启动时动态代理生成实现类,XML 映射文件完全不存在。基础增删改查由父接口自带:save()、findById()、findAll()、delete()。
实体类用注解完成映射:
@Document(collection = "device")
public class Device {
@Id
private String id; // MongoDB内置主键 _id
@Indexed(unique = true)
private String deviceId; // 业务设备编号(业务唯一,≠_id)
private String deviceType;
private String status;
@Field("stationCode")
private String station;
}
几个注解的分工要分清:@Document 指定集合名;@Id 映射内置主键 _id;@Indexed(unique=true) 给业务编号建唯一索引;@Field 处理文档字段名与 Java 驼峰不一致的情况。
多条件复杂查询走 @Query 写原生 JSON:
@Query("{ 'status': 'online', 'station': ?0 }")
List<Device> findOnlineByStation(String station);
方法名的解析规则值得花两分钟记住:findByXxx 生成等值查询,findByXxxAndYyy 拼条件,findByXxxIgnoreCase 附加修饰符。凡是方法名能表达的单字段、简单组合查询,优先走命名推导;一旦涉及嵌套文档、数组条件或聚合,别硬凑方法名,直接上 @Query——可读性和可维护性都更好。业务层的典型用法也保持极简:查出来、改字段、save 回去三步。
一个真实踩过的坑:save 的并发覆盖
Kafka 消息并发消费场景下,多条消息同时读到同一份文档,各自改完字段再调 save(),后保存的会把前面的修改整个覆盖掉,中间状态直接丢失。这是「读-改-全量写」模式的固有缺陷,save() 的更新依据又是内置 _id 而不是业务字段,碰撞概率比直觉高。
修复思路是把全量覆盖改成原子局部更新:
@Query("{ 'deviceId': ?0 }")
Device findByDeviceId(String deviceId);
// 原子更新指定字段,避免读改写窗口
mongoTemplate.updateFirst(
Query.query(Criteria.where("deviceId").is(deviceId)),
Update.update("status", "online"), Device.class);
另一个前置防线是唯一索引。deviceId 在业务上必须唯一,索引建好之后重复插入会直接报 MongoDB 的 E11000 错误——这本身就是一次有效的写入侧校验,可以用 db.device.createIndex({deviceId:1},{unique:true}) 在集合上先行建立,再由 @Indexed 注解在应用层声明。
还有个版本相关的注意点:多文档事务需要 MongoDB 4.0 以上的副本集部署,单机版跑不起事务,Spring Data 侧的 @Transactional 在单机上会静默失效。测试环境和生产拓扑保持一致,别在单机上验证事务逻辑。
顺着唯一索引再延伸一步:业务键和内置主键的分离要在团队里形成共识。所有按 deviceId 的关联查询、跨服务引用一律走业务键,_id 只留给框架内部使用——不然哪天换库或做数据迁移,靠 _id 关联的数据会全部悬空。插入侧的错误处理也要预设好:唯一索引冲突抛出的异常要转成业务层的「设备已存在」响应,而不是让 500 直接漏到调用方。
MyBatis 侧的对照
同样的查询,MyBatis 要写接口加 XML 两份:
public interface DeviceMapper {
Device findByDeviceId(String deviceId);
List<Device> findByDeviceType(String deviceType);
int updateStatus(Device device);
}
<select id="findByDeviceId" resultType="Device">
select id,device_id,device_type,status from device where device_id=#{deviceId}
</select>
MyBatis 的定位是关系型数据库:SQL 全部手写(XML 或注解),没有自带 CRUD,字段映射靠 resultMap 配置,动态代理只是执行预定义的 SQL。换来的是对语句的完全控制力,复杂关联查询、数据库方言优化都是它的主场。
选型对照
| 对比项 | Spring Data Mongo | MyBatis |
|---|---|---|
| 数据库 | MongoDB 文档库 | MySQL 等关系库 |
| 映射文件 | 不需要 XML | Mapper.xml 或注解 SQL |
| 简单查询 | 方法名自动解析 | 全部手写 |
| CRUD | 父接口自带 | 自己写 |
| 实体映射 | @Document/@Field 注解 |
resultMap 配置 |
| 主键 | 内置 _id 与业务键分离 |
表主键自定义 |
一句话收束:MongoDB 上做以 CRUD 为主的快速开发,Repository 的方法名驱动能把样板代码压到最低;落到关系库、需要 SQL 级控制时再选 MyBatis。真正决定成败的往往不是框架,而是 _id 与业务键的区分、唯一索引的落位、并发写入的原子性这几个细节——它们在两种框架里都存在,只是表现形式不同。

评论0