线上问题——【面试题】Mybatis-plus 整合 ShardingSphere-JDBC,调用批量方法执行更新操作扫所有分表问题

记录下 ShardingSphere 整合 mybatis-plus 进行批量更新时扫所有分表问题的原因及解决方案。ShardingSphere 整合 mybatis-plus 与整合 mybatis 流程是一样的,一个是导入 mybatis 包,一个是导入 mybatis-plus 包,在 ShardingSphere-JDBC —— 数据分片详细讲解 介绍了 ShardingSphere 分布分表及整合 mybatis 的使用示例,这里就不在赘述整合使用过程了。

过程及问题描述

在使用 ShardingSphere 整合 mybatis-plus 并调用 saveOrUpdateBatch() 批量插入或更新方法,发现数据批量插入时,ShardingSphere 的分片规则会根据分片键的值将数据正确地分散到各个分片表中;而数据批量更新时,会扫描所有分表进行更新。这是什么原因呢?

首先看我是如何使用的。

  1. 数据表的设计,表字段如下,id 自增主键、text 唯一索引。这里建 32 张分表,test_table_0 … test_table_31

    CREATE TABLE `test_table` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键id',
      `text` varchar(32) COLLATE utf8_bin NOT NULL COMMENT '文本内容',
      `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
      PRIMARY KEY (`id`),
      UNIQUE KEY `text` (`encrypt_text`) USING BTREE,
      KEY `idx_user_id` (`user_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='文本';
    
  2. ShardingSphere 相关配置,详细配置不再降速,就看看数据库表分片配置。这里设置 text 字段作为分片键。

    @Bean(name = "textTableTableRuleConfig")
    public TableRuleConfiguration textTableTableRuleConfig() {
        String tableName = "test_table";
        String actualDataNodes = "dataSource.test_table_${0..31}";
        // 分片键
        String shardingColumns = "text";
    
    	// 表规则配置
        TableRuleConfiguration configuration = new TableRuleConfiguration();
        configuration.setLogicTable(tableName);	// 设置逻辑表名
        configuration.setActualDataNodes(actualDataNodes); // 设置实际分表节点
        // 分片策略,UserEncryptTextTableAlgorithm 为自定义分片算法
        ShardingStrategyConfiguration tableShardingStrategyConfig = new StandardShardingStrategyConfiguration(
            shardingColumns, new UserEncryptTextTableAlgorithm());
    	// 数据库没分库,不用分片策略;数据表分表,设置分片策略。
        configuration.setDatabaseShardingStrategyConfig(new NoneShardingStrategyConfiguration());
        configuration.setTableShardingStrategyConfig(tableShardingStrategyConfig);
        return configuration;
    }
    

    分片算法。使用 PreciseShardingAlgorithm,是 ShardingSphere 提供的精确分片算法接口,用于处理单一分片键的分片规则。

    @Configuration
    public class UserEncryptTextTableAlgorithm implements PreciseShardingAlgorithm<String> {
    
        /**
         * availableTargetNames:可用的分片目标名称集合,即分片规则所涉及的所有分片表的集合。
         * shardingValue:PreciseShardingValue 对象,表示单一分片键的值。
         */
        @Override
        public String doSharding(Collection<String> collection, PreciseShardingValue<String> shardingValue) {
            StringBuffer tableName = new StringBuffer();
            // shardingValue.getValue():分片键的值
            String tableSuffix = String.valueOf(Math.abs(shardingValue.getValue().hashCode()) % 32);
            tableName.append(shardingValue.getLogicTableName()).append("_").append(tableSuffix);
            return tableName.toString();
        }
    }
    
  3. 业务代码操作数据表,这里调用 mybatis-plus 的批量方法。

    // mybatis-plus 操作数据库服务类
    @Autowired
    private ITestTableService iTestTableService;
    
    public static void main(String[] args) {
    	List<TestTable> testTableList = new ArrayList<>();
    	TestTable updateInfo· = new TestTable();
        updateInfo1·.setText("test1");
        updateInfo1·.setId(1);
        updateInfo1·.setCreateTime(new Date());
        testTableList.add(updateInfo1);
        TestTable updateInfo1 = new TestTable();
        updateInfo2.setText("test2");
        updateInfo2.setId(2);
        updateInfo2.setCreateTime(new Date());
        testTableList.add(updateInfo2);
        iTestTableService.saveOrUpdateBatch(testTableList);
    }
    
  4. 运行结果

    配置 ShardingSphere 数据源时开启sql打印,方便在控制台查看输出的sql语句。

    Properties prop = new Properties();
    prop.setProperty(ShardingPropertiesConstant.SQL_SHOW.getKey(), true);
    

    运行条件:假设 updateInfo1 的数据已存在与表中,updateInfo2 的数据在表中不存在。在进行 iTestTableService.saveOrUpdateBatch(testTableList); 运行后。

    insert 语句只打印了一次。
    INSERT INTO test_table_10(id, text, create_time, update_time) VALUES (?,?, ?, ?);

    update 语句只打印了32次。
    UPDATE table_name_0 SET id = ?, text= ?, create_time = ?, update_time = ? WHERE id = ?;
    UPDATE table_name_1 SET id = ?, text= ?, create_time = ?, update_time = ? WHERE id = ?;

    UPDATE table_name_32 SET id = ?, text= ?, create_time = ?, update_time = ? WHERE id = ?;

    看表中数据,32 张表中只有一张表中插入了 updateInfo2 的数据,说明只执行了一次 insert 操作,而且按分片规则落到指定的分表中;而 updateInfo1 在表中的数据已被修改了,说明更新了,是否是只执行了一次 update 操作呢?继续分析。

  5. 分析及结论

    那打印了 32 次 update 语句,是否执行了 32 次 update 操作呢?首先看打印的 sql,saveOrUpdateBatch() 批量 update 操作打印的sql,where 条件是 id = ?,那我在另外一张不存在 updateInfo1 数据的表中插入一条数据,其 id 也等于1,再执行一遍更新操作,发现两张表的 updateInfo1 数据都进行了更新,说明确实执行了 32 次 update 操作。

    查看打印的 update sql 语句,可以看出 mybatis-plus 的 saveOrUpdateBatch() 操作批量更新时,是以 id 主键作为 where 条件来索引更新,而 id 主键又不是分片键,分片规则失效,所以每一次 update 都会扫所有分表。而对于 insert 操作,可以理解插入的字段包含 id 主键和 text 唯一索引,且 text 作为分片键,所以插入时会解析到包含分片键 text,会先进行分表定位到具体表,再插入。

原因及方案

针对 update 操作的问题:

  1. 于是,我再使用 mybatis-plus 的 saveOrUpdate(T entity); 方法与 saveOrUpdate(T entity, Wrapper updateWrapper); 方法单独对 updateInfo1 进行更新操作。(mybatis-plus 现象)

    iTestTableService.saveOrUpdate(updateInfo1);
    与
    iTestTableService.saveOrUpdate(updateInfo1, Wrappers.<UserEncryptText>lambdaUpdate().eq(UserEncryptText::getText, updateInfo.getText()));
    

    这两种更新操作的结果为:不带条件的还是扫描所有分表,带条件能定位到具体分表进行更新。说明在进行 saveOrUpdate(updateInfo1) 操作更新时,mybatis-plus 默认是使用主键索引作为 where 条件进行索引的;使用 saveOrUpdate(updateInfo1, Wrappers) 操作更新时才会使用指定的 where 条件。

  2. 其次,我更改分片键,使用多字段分片键的分片策略,id 与 text 字段都作为分片键。(ShardingSphere 现象)

    String shardingColumns = "id,text";
    ShardingStrategyConfiguration tableShardingStrategyConfig = new ComplexShardingStrategyConfiguration(
       shardingColumns, new UserEncryptTextTableAlgorithm());
    

    分片算法。使用 ComplexKeysShardingAlgorithm,是 ShardingSphere 中用于处理复合分片键的分片算法接口。该接口定义了根据多个分片键进行分片的方法。这里简单处理,同时存在 id、text 或 text 都映射到 text 作为分片键,只有 id 分片键就默认分到0表。

    public class UserEncryptTextTableAlgorithm implements ComplexKeysShardingAlgorithm {
        @Override
        public Collection<String> doSharding(Collection<String> collection, Collection<ShardingValue> collection1) {
            List<String> list = new ArrayList<>();
            Optional<ShardingValue> optional = collection1.stream()
                .filter(x -> StringUtils.equals(x.getColumnName(), "text")).findFirst();
            if (!optional.isPresent()){
            	StringBuffer tableName = new StringBuffer();
    	        tableName.append(shardingValue.getLogicTableName()).append("_").append(0);
                list.add(tableName.toString());
                return list;
            }
            ListShardingValue shardingValue = (ListShardingValue)optional.get();
            Collection values = shardingValue.getValues();
            values.forEach(value -> {
                StringBuffer tableName = new StringBuffer();
            	String tableSuffix = String.valueOf(Math.abs(shardingValue.getValue().hashCode()) % 32);
            	tableName.append(shardingValue.getLogicTableName()).append("_").append(tableSuffix);
                list.add(tableName.toString());
            });
    
            return list;
        }
    }
    

    1、当条件为 where id = ? 时,分片算法中的入参 collection1 集合中只有 id 分片键对象,只能使用 id 分片键进行分片算法,这里直接操作了 0 表;
    2、当条件为 where text = ? 时,分片算法中的入参 collection1 集合中只有 text 分片键对象,使用 text 分片键进行分片算法,锁定操作某一个分表;
    3、当条件为 where id = ? AND text = ? 时,分片算法中的入参 collection1 集合中有 id 和 text 分片键对象,使用 text 分片键进行分片算法,锁定操作某一个分表;
    4、当条件为 where create_time = ? 等其他非分片键时,直接跳过分片算法,不会调用到分片算法,然后会扫描所有分表;

    所以在进行 update 操作时,可以理解会先解析 where 条件,判断包含了哪些分片键,并以这些分片键按照分片算法逻辑进行分表定位到具体表,再插入;若一个分片键都不包含,分片算法失效,直接扫所有分表操作。

最终如何进行批量更新操作:

  1. 方案一:轮询使用带条件的更新操作,如:saveOrUpdate(updateInfo1, Wrappers) 、update(updateInfo1, Wrappers)
  2. 方案二:若要使用 saveOrUpdateBatch() 方法,将 id 设置为分片键,并且要保证 id 分片键与 text 分片键进行分片算法时能得到相同的分表。(上面示例并没有实现,只是为了简单演示。)
当然可以!下面我将**详细展开讲解第 1 个项目:基于 Spring Boot + Vue 的电商平台(含高并发秒杀模块)**,从整体架构设计、核心功能实现、关键技术点解析到性能优化策略,帮助你深入理解这个项目的每一个细节,并具备在面试中清晰表达的能力。 --- ## 🎯 项目目标 构建一个具备完整电商流程的系统,支持商品浏览、购物车、下单、支付模拟等基础功能,并重点实现一个**高并发秒杀系统**,用于应对短时间内大量用户抢购限量商品的极端场景。 该项目不仅能展示你的全栈开发能力,更能体现你在**分布式、缓存、消息队列、限流降级、防止超卖**等方面的综合技术深度。 --- ## 一、系统架构图 ``` +------------------+ +------------------+ | Vue3 前端 | <---> | Nginx (静态资源) | +------------------+ +--------+---------+ | +--------v--------+ | Spring Cloud Gateway 或 API 网关 | +--------+---------+ ↓ +------------------------------------+ | Spring Boot 微服务集群 | +----------------+-------------------+ ↓ +---------------+-----------------+ | 用户服务 | 商品服务 | 订单服务 | 秒杀服务 | +---------------+-----------------+ 中间件: - Redis:缓存库存、用户登录态、防刷限流 - RabbitMQ:异步下单、削峰填谷 - MySQL:持久化数据(主从读写分离) - Sentinel:接口限流与熔断 - Caffeine:本地缓存热点数据(如商品信息) 部署: - Docker 打包 Java 应用 - Nginx 反向代理前后端 - 使用 JMeter 进行压测验证性能 ``` > 💡 **说明**:初期可不做微服务拆分,以单体应用起步;后期可按业务拆分为 user-service、product-service、order-service、seckill-service。 --- ## 二、核心模块功能说明 | 模块 | 功能 | |------|------| | 用户管理 | 注册、登录、JWT 认证、权限控制(ROLE_USER / ADMIN) | | 商品管理 | 商品增删改查、分类、图片上传、上下架状态 | | 购物车 | 添加/删除商品、数量修改、选中结算 | | 下单流程 | 创建订单、扣减库存、生成订单号、跳转“支付页”(模拟) | | 支付对接 | 模拟支付宝/微信支付回调逻辑(定时任务更新订单状态) | | 秒杀活动 | 特定时间开放抢购,限量商品低价出售,高并发场景 | --- ## 三、核心技术实现详解 ### ✅ 1. 登录认证:JWT + Redis 使用 JWT 实现无状态登录,避免 Session 共享问题。Redis 存储 token 黑名单(用于登出)、限制登录尝试次数。 ```java // 登录成功后签发 Token String token = JwtUtil.sign(user.getId(), user.getUsername()); // 设置 Redis 缓存(自动续期) redisTemplate.opsForValue().set("login:token:" + token, "1", 7, TimeUnit.DAYS); ``` **拦截器校验 Token:** ```java public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (StrUtil.isBlank(token)) throw new UnauthorizedException(); try { Claims claims = JwtUtil.parseToken(token); Long userId = Long.valueOf(claims.getSubject()); request.setAttribute("userId", userId); } catch (Exception e) { throw new UnauthorizedException(); } return true; } } ``` --- ### ✅ 2. 秒杀核心流程设计 #### 🔁 正常下单 vs 秒杀下单对比 | 对比项 | 正常下单 | 秒杀下单 | |-------|----------|-----------| | 请求量 | 一般 | 极高(瞬时万级 QPS) | | 库存操作 | 数据库直接扣减 | Redis 预减库存 | | 数据一致性 | 强一致 | 最终一致 | | 是否走 MQ | 否 | 是(异步落库) | | 是否限流 | 否 | 必须限流 | #### 🌟 秒杀流程图 ``` 用户点击【立即抢购】 ↓ [网关层] → Sentinel 限流(每秒最多1000请求通过) ↓ [Redis 层] → 执行 Lua 脚本:原子性判断库存 > 0 并预扣库存 ↓ 是 生成唯一秒杀订单 ID(用户ID+商品ID+时间戳) ↓ 发送消息到 RabbitMQ(异步创建订单) ↓ 返回“抢购成功,请等待确认”给前端 ↓ 消费者监听 MQ → 校验用户是否已抢过 → 创建订单 → 扣真实库存 → 更新订单状态 ``` --- ### ✅ 3. 防止超卖:Redis + Lua 脚本(原子操作) 关键代码:使用 Lua 脚本保证“检查库存 + 扣减库存”是原子的。 ```lua -- KEYS[1]: 秒杀商品库存 key -- ARGV[1]: 用户ID(防止重复抢购) -- 返回值:0=失败,1=成功 if redis.call('get', KEYS[1]) == false then return 0 end local stock = tonumber(redis.call('get', KEYS[1])) if stock <= 0 then return 0 end -- 判断用户是否已经抢过(Set结构) if redis.call('sismember', 'user:seckill:set:' .. KEYS[1], ARGV[1]) == 1 then return 0 end -- 扣减库存 redis.call('decr', KEYS[1]) -- 加入已抢集合 redis.call('sadd', 'user:seckill:set:' .. KEYS[1], ARGV[1]) return 1 ``` Java调用: ```java DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(luaScript); script.setResultType(Long.class); String key = "seckill:stock:" + seckillId; Long result = redisTemplate.execute(script, Arrays.asList(key), userId); if (result == 1) { // 抢购成功,发消息进 MQ rabbitTemplate.convertAndSend("seckill-exchange", "seckill.create", orderDto); return Result.success("抢购成功!"); } else { return Result.fail("手慢了,已售罄或重复抢购"); } ``` --- ### ✅ 4. 削峰填谷:RabbitMQ 异步下单 秒杀成功后不立即写数据库,而是发送消息到队列,由消费者异步处理订单创建。 ```java // 消费者:处理秒杀订单 @RabbitListener(queues = "queue.seckill.order") public void handleSeckillOrder(SeckillOrderDTO dto, Message message) { try { // 再次校验用户是否重复下单(双重保险) if (seckillOrderService.checkUserHasOrder(dto.getUserId(), dto.getSeckillId())) { log.warn("用户重复下单,忽略消息: {}", dto); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); return; } // 创建真实订单 Order order = new Order(); order.setUserId(dto.getUserId()); order.setProductId(dto.getProductId()); order.setPrice(dto.getPrice()); order.setStatus(0); // 待支付 orderService.save(order); // 扣除数据库真实库存(可加锁 or 乐观锁) productMapper.decrementStock(dto.getProductId(), 1); log.info("秒杀订单创建成功: orderId={}", order.getId()); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { log.error("处理秒杀订单失败", e); // 失败重试机制(延迟重试 or 死信队列) channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); } } ``` --- ### ✅ 5. 多级缓存设计:Caffeine + Redis 减少对数据库的压力,采用两级缓存: - **Caffeine(本地缓存)**:缓存热点商品信息(TTL=5分钟),避免频繁访问 Redis。 - **Redis(分布式缓存)**:缓存库存、活动信息、用户黑名单等共享数据。 ```java @Service public class ProductService { @Autowired private RedisTemplate<String, Object> redisTemplate; private final Cache<String, Product> cache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); public Product getProduct(Long id) { // 先查本地缓存 Product product = cache.getIfPresent("product:" + id); if (product != null) { return product; } // 查 Redis product = (Product) redisTemplate.opsForValue().get("product:info:" + id); if (product == null) { // 查数据库 product = productMapper.selectById(id); if (product != null) { redisTemplate.opsForValue().set("product:info:" + id, product, 30, TimeUnit.MINUTES); } } if (product != null) { cache.put("product:" + id, product); } return product; } } ``` --- ### ✅ 6. 限流防护:Sentinel 或 RateLimiter 防止恶意刷接口导致系统崩溃。 #### 方式一:使用 Alibaba Sentinel(推荐) 配置 `/seckill/do_seckill` 资源的 QPS 上限为 1000。 ```java @SentinelResource(value = "doSeckill", blockHandler = "handleBlock") @PostMapping("/do_seckill") public Result doSeckill(@RequestBody SeckillRequest request) { // 正常逻辑 } public Result handleBlock(BlockException ex) { return Result.fail("活动太火爆,请稍后再试"); } ``` #### 方式二:Guava RateLimiter(单机限流) ```java private final RateLimiter rateLimiter = RateLimiter.create(1000); // 每秒放行1000个 if (!rateLimiter.tryAcquire(1, TimeUnit.SECONDS)) { return Result.fail("请求过于频繁"); } ``` --- ### ✅ 7. 数据库优化:读写分离 + 分库分表(可选) 当数据量增长到百万级以上时,考虑以下优化: #### (1)读写分离(MyBatis Plus 多数据源) ```yaml spring: datasource: master: url: jdbc:mysql://localhost:3306/blog?useSSL=false username: root password: root slave: url: jdbc:mysql://localhost:3307/blog?useSSL=false username: root password: root ``` 使用 `@DS("master")` 或 `@DS("slave")` 注解切换数据源。 #### (2)分库分表ShardingSphere) 对订单表按用户 ID 分片: ```yaml sharding: tables: t_order: actual-data-nodes: ds$->{0..1}.t_order_$->{0..3} table-strategy: inline: sharding-column: user_id algorithm-expression: t_order_$->{user_id % 4} database-strategy: inline: sharding-column: user_id algorithm-expression: ds_$->{user_id % 2} ``` --- ## 四、部署方案(Docker + Nginx) ### 1. 后端打包 Docker 镜像 ```dockerfile FROM openjdk:8-jre-alpine COPY target/ecommerce.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"] ``` ```bash docker build -t ecommerce:latest . docker run -d -p 8080:8080 --name app ecommerce:latest ``` ### 2. Nginx 配置反向代理 ```nginx server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } ``` --- ## 五、面试亮点提炼(简历怎么写?) > **项目名称**:基于 Spring Boot 的高并发电商平台(含秒杀系统) > **技术栈**:Spring Boot、Vue3、Redis、RabbitMQ、MySQL、Sentinel、Caffeine、JWT、Docker、Nginx > **项目描述**: > - 设计并实现完整的电商系统,涵盖商品、购物车、订单、权限控制等核心模块; > - 独立开发高并发秒杀系统,采用 Redis + Lua 脚本实现原子性扣减库存,有效防止超卖; > - 引入 RabbitMQ 异步处理订单,实现削峰填谷,提升系统吞吐量; > - 使用 Caffeine + Redis 构建多级缓存,降低数据库压力,热点商品访问延迟 < 10ms; > - 集成 Sentinel 实现接口限流与降级,保障系统稳定性; > - 通过 Docker 容器化部署,Nginx 反向代理,支持快速上线与横向扩展。 --- ## 六、可扩展方向(加分项) - ✅ 使用布隆过滤器防止无效请求穿透到 Redis - ✅ 增加分布式锁(Redisson)保护关键资源 - ✅ 使用 Elasticsearch 实现商品搜索 - ✅ 接入 SkyWalking 监控接口性能 - ✅ 实现库存预热、定时任务自动上下架秒杀活动 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值