在分布式系统架构中,数据访问层的性能与稳定性直接影响整个微服务体系的健康度。MyBatis-Plus作为MyBatis的增强工具包,通过简化CRUD操作和提供丰富的功能扩展,已经成为Java技术栈中处理关系型数据库的首选方案之一。但在真实的微服务场景下,我们往往面临几个典型问题:
去年在重构某电商平台的订单服务时,我们曾遇到一个典型案例:在促销活动期间,由于商品详情页的关联查询没有合理优化,导致单台MySQL实例的QPS峰值突破8000,整个数据库集群几乎崩溃。这个教训让我深刻认识到,仅仅会使用MyBatis-Plus的基础CRUD功能是远远不够的。
在微服务架构中,分库分表是应对数据增长的常见方案。MyBatis-Plus通过AbstractRoutingDataSource可以实现动态数据源切换,但需要特别注意几个关键点:
java复制// 基于ThreadLocal的数据源上下文
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setDataSource(String dsName) {
CONTEXT.set(dsName);
}
public static String getDataSource() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
// 自定义路由数据源
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
重要提示:在多线程异步场景下,必须确保数据源切换与业务操作在同一个线程上下文内。我们在实践中发现,当使用@Async注解或CompletableFuture时,如果不做特殊处理,会导致数据源切换失效。
雪花算法(Snowflake)是MyBatis-Plus默认的分布式ID方案,但在容器化部署环境中需要注意:
yaml复制mybatis-plus:
global-config:
worker-id: ${SERVER_WORKER_ID:0}
datacenter-id: ${SERVER_DATACENTER_ID:0}
当服务实例数量超过1024时,需要自定义ID生成器。我们改进的方案是结合ZK的持久顺序节点来分配workerId:
java复制public class ZkWorkerIdAssigner implements WorkerIdAssigner {
private final CuratorFramework client;
@Override
public long assignWorkerId() {
String path = "/snowflake/" + serviceName;
try {
String node = client.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath(path);
return Long.parseLong(node.substring(node.length() - 4)) % 1024;
} catch (Exception e) {
throw new RuntimeException("Get workerId from zk failed", e);
}
}
}
我们为商品服务设计的缓存策略如下表所示:
| 缓存层级 | 存储介质 | 过期策略 | 适用场景 |
|---|---|---|---|
| L1 | 本地Caffeine | 10秒TTL | 极端热点数据 |
| L2 | Redis集群 | 30分钟TTL + 主动更新 | 常规热点数据 |
| L3 | MySQL | 按业务需求 | 全量数据 |
在MyBatis-Plus中实现二级缓存需要自定义Cache实现:
java复制public class RedisCache implements Cache {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public Object getObject(Object key) {
String cacheKey = generateKey(key);
Object value = redisTemplate.opsForValue().get(cacheKey);
if (value == null) {
value = delegate.getObject(key);
if (value != null) {
redisTemplate.opsForValue().set(cacheKey, value, 30, TimeUnit.MINUTES);
}
}
return value;
}
}
我们对不同批量插入方式进行了基准测试(单位:ms/万条):
| 操作方式 | 单线程 | 线程池(8) | 备注 |
|---|---|---|---|
| 循环insert | 4200 | 1100 | 产生大量网络IO |
| saveBatch | 3800 | 900 | MyBatis-Plus默认批处理 |
| 手动拼接SQL | 800 | 300 | 需要防注入处理 |
| JDBC批处理 | 600 | 200 | 性能最佳但灵活性低 |
实际项目中我们采用的折中方案:
java复制public class BatchInsertHelper {
private static final int BATCH_SIZE = 1000;
public static <T> boolean batchInsert(IService<T> service, List<T> list) {
SqlSession session = SqlHelper.sqlSessionBatch(service.getEntityClass());
try {
int size = list.size();
for (int i = 0; i < size; i += BATCH_SIZE) {
String sqlStatement = sqlStatement(service.getEntityClass());
session.insert(sqlStatement, list.subList(i, Math.min(i + BATCH_SIZE, size)));
}
session.commit();
return true;
} finally {
session.close();
}
}
}
在订单支付场景中,我们采用"最终一致性+本地事务"的混合策略:
MyBatis-Plus需要配合Seata使用时,要特别注意@GlobalTransactional的传播特性:
java复制@Transactional
@GlobalTransactional
public void createOrder(OrderDTO dto) {
// 本地事务操作
orderService.save(dto);
// 发送分布式事务消息
rocketMQTemplate.sendInTransaction(
"order-topic",
MessageBuilder.withPayload(dto).build(),
null
);
}
我们基于MyBatis-Plus的TenantLineInnerInterceptor实现了动态租户过滤:
java复制public class TenantInterceptor extends TenantLineInnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) {
// 从当前线程获取租户ID
String tenantId = TenantContext.getCurrentTenant();
if (StringUtils.isNotBlank(tenantId)) {
super.beforeQuery(executor, ms, parameter, rowBounds,
resultHandler, boundSql);
}
}
}
对应的租户处理器:
java复制public class CustomTenantHandler implements TenantHandler {
@Override
public Expression getTenantId() {
return new StringValue(TenantContext.getCurrentTenant());
}
@Override
public boolean ignoreTable(String tableName) {
return !"t_order,t_item".contains(tableName);
}
}
我们扩展了MyBatis-Plus的PerformanceInterceptor:
java复制public class EnhancedPerformanceInterceptor extends PerformanceInterceptor {
private final MeterRegistry meterRegistry;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
String method = ms.getId();
meterRegistry.timer("sql.execute.time")
.tags("method", method)
.record(cost, TimeUnit.MILLISECONDS);
if (cost > 500) {
log.warn("Slow SQL detected: {} - {}ms",
ms.getBoundSql(invocation.getArgs()[1]).getSql(), cost);
}
}
}
}
经过压测验证的Druid配置参数:
yaml复制spring:
datasource:
druid:
initial-size: 5
min-idle: 5
max-active: 50
max-wait: 3000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1
test-while-idle: true
test-on-borrow: false
filters: stat,wall
在K8s环境中,我们还需要考虑就绪探针的配置:
yaml复制readinessProbe:
exec:
command:
- /bin/sh
- -c
- mysql -h$DB_HOST -u$DB_USER -p$DB_PASS -e 'SELECT 1' || exit 1
initialDelaySeconds: 30
periodSeconds: 10
我们在生产环境中遇到的几个典型案例:
案例一:分页查询内存溢出
现象:PageHelper.startPage()后出现OOM
根因:先全量查询再内存分页
解决方案:改用MyBatis-Plus的IPage接口
java复制// 错误用法
List<User> list = userMapper.selectList(null);
PageInfo<User> page = new PageInfo<>(list);
// 正确用法
IPage<User> page = new Page<>(1, 10);
userMapper.selectPage(page, null);
案例二:逻辑删除字段冲突
现象:更新操作不生效
根因:实体类中同时存在@TableLogic和@Version
解决方案:修改逻辑删除策略
java复制// 问题配置
@TableLogic
private Integer deleted;
@Version
private Integer version;
// 解决方案
@TableLogic(delval = "1", value = "0")
private Integer isDeleted;
案例三:Lambda查询性能问题
现象:复杂Lambda表达式执行缓慢
根因:反射调用过多
优化方案:改用普通QueryWrapper
java复制// 优化前
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery();
wrapper.eq(User::getName, "test")
.nested(i -> i.gt(User::getAge, 18).or().isNotNull(User::getEmail));
// 优化后
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("name", "test")
.and(i -> i.gt("age", 18).or().isNotNull("email"));