news 2026/9/30 8:37:51

MyBatis-Plus Wrapper 实战:Lambda 写法、查询构造与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis-Plus Wrapper 实战:Lambda 写法、查询构造与避坑指南

如果你用 Java 写后端,大概率已经被 MyBatis-Plus 的 Wrapper 包围了。不管是简单列表查询、后台管理筛选,还是批量更新,几乎每个 Controller 里都有 LambdaQueryWrapper 或 QueryWrapper 的身影。我之前带过几个新人,看到eq(User::getStatus, 1)这种写法都会问一句:这不是 SQL 吗,怎么塞在 Java 里了?这其实是 MyBatis-Plus 给出的“动态条件构造器答案”,代码里不用再拼一堆 if + SQL 字符串,Wrapper 会帮你把 Java 条件翻译成 where 子句。这篇文章就专聊 Wrapper 的 Lambda 写法、实战套路,以及我踩过的各种坑,适合刚接触 MyBatis-Plus 的新手,也适合正在被复杂条件拼接折磨的老手。

1. Wrapper 是如何从 MyBatis 动态 SQL 里杀出来的

1.1 为什么 Service 层拼接条件这么难受

早期用原生 MyBatis,写一个列表查询通常要维护一份 XML:

<select id="selectUserList" resultType="User"> SELECT * FROM user <where> <if test="username != null and username != ''"> AND username LIKE CONCAT('%', #{username}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> </select>

每个查询都要开 XML、写 if、写 concat,字段一变,Mapper 接口、XML、参数对象都要跟着改。查询条件一多,XML 里 if 嵌套 if,时间长了连自己都懒得翻。

Wrapper 把条件拿到 Java 代码里来拼,例如:

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1);

在 Service 层就能直接看出查询意图,不用来回切文件。而且 Wrapper 支持 condition 参数,可以实现“该条件为空就不拼接”,比 XML 里写一堆<if>要轻量得多。

这里也要说清楚:我不是否定 XML。复杂统计、多表 join、特殊 SQL 场景,我反而会主动回到 XML。Wrapper 解决的是 80% 的日常动态查询,不是所有问题。

1.2 QueryWrapper 与 LambdaQueryWrapper 怎么选

很多初学者会纠结到底用哪个,我把对比直接列出来:

对比项QueryWrapperLambdaQueryWrapper
字段写法字符串 "username"方法引用 User::getUsername
编译期检查无,写错到运行期才报有,编译期就能发现
字段改名全局搜索字符串,容易漏IDE 重构自动改
拼接聚合列方便,select("count(id) as cnt")相对麻烦
适合场景统计、报表、临时 SQL业务 CRUD、动态查询

说实话,没有哪个绝对好。日常 CRUD 我优先 LambdaQueryWrapper,因为类型安全。但遇到分组统计、要写复杂字段别名时,QueryWrapper 的字符串列名反而更直接。两者是同一套条件体系的两种表达,没必要硬分高下,实际项目里共存很常见。

1.3 Lambda 解析原理和它天然的类型安全感

为什么能用User::getUsername代替"username"?原理是 MP 拿到这个 Lambda 后,会解析成 SerializedLambda,反射出实现方法名getUsername,再转成属性名username,最后配合驼峰转下划线得到数据库列名。中间这些过程 MP 会做缓存,性能开销基本可以忽略。

既然能拿到方法名,就天然避免了字符串写错。最常见的体验是:实体字段重构改名为userName,用 QueryWrapper 的地方可能漏改,而 Lambda 写法会在编译期直接报错。用惯之后,确实回不去了。

不过,这个解析机制也不是绝对稳,热部署、内部类、泛型继承都可能触发异常,我放在第 4 部分细讲。

2. LambdaQueryWrapper 高频 API 的实用写法

2.1 等值、范围、模糊:动态条件先判断再拼接

基础等值查询:

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1) .eq(User::getType, "admin");

但实际接口入参大概率是可能为 null 的,直接 eq 会把 null 也拼进条件,影响结果。所以动态查询我习惯用带 boolean 的重载:

String username = request.getParameter("username"); Integer status = request.getParameter("status") == null ? null : Integer.valueOf(...); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(username), User::getUsername, username) .eq(status != null, User::getStatus, status);

第一个参数是布尔值,只有为 true 时才会拼接这个条件。这样即便前端漏传参数,SQL 也不会被污染。类似方法ge、le、gt、lt、like、in、ne都支持这种带 condition 的写法,建议养成习惯。

范围查询:

wrapper.ge(startTime != null, User::getCreateTime, startTime) .lt(endTime != null, User::getCreateTime, endTime);

模糊查询:

wrapper.like(StringUtils.hasText(keyword), User::getNickname, keyword); // LIKE '%keyword%' wrapper.likeRight(StringUtils.hasText(keyword), User::getPhone, keyword); // LIKE 'keyword%'

需要提醒的是,like默认是全模糊。如果用户输入%、_,在 SQL 里会被当成通配符,出现“明明按关键词搜,却把全表捞出来”的情况。业务上能接受的话,直接限制输入长度和特殊字符;不能接受就做转义处理。

空值判断用:

wrapper.isNull(User::getDeletedAt); wrapper.isNotNull(User::getUpdatedAt);

2.2 排序、限定字段:少查一列是一列

排序:

wrapper.orderByDesc(User::getCreateTime) .orderByAsc(User::getId);

对应 SQL 是ORDER BY create_time DESC, id ASC。如果希望 id 优先排序,就要把 id 写在前面。

只查询需要的字段:

wrapper.select(User::getId, User::getUsername, User::getStatus);

默认selectList是 SELECT 全部字段。表里如果有 text、mediumtext 这种大字段,列表页会白白把所有大字段读出来。只 select 业务需要的列,查询速度往往立竿见影。

还有一个细节:列名是数据库关键字时,比如字段叫order、group、desc,建议在实体字段上用@TableField("order")显式声明,否则 MP 生成的 SQL 在 MySQL 里可能报语法错误。这个坑我踩过一次。

2.3 and/or 嵌套:别把括号拼错了地方

最常见的错误是把 or 直接写在条件外层。比如“状态为 1,且昵称包含张 或 手机号以 138 开头”,有人会写:

wrapper.eq(User::getStatus, 1) .like(User::getNickname, "张") .or() .likeRight(User::getPhone, "138");

这个 SQL 语法没错,语义却是status = 1 AND nickname LIKE '%张%' OR phone LIKE '138%',因为 AND 优先级高于 OR,整个条件被拆成了两个独立条件。正确写法是用and方法包一层:

wrapper.eq(User::getStatus, 1) .and(w -> w.like(User::getNickname, "张") .or() .likeRight(User::getPhone, "138"));

生成的 SQL:

WHERE status = 1 AND (nickname LIKE '%张%' OR phone LIKE '138%')

反过来,如果要在 or 里再组合 and,就调or(w -> w.eq(...).eq(...))。核心原则是:需要括号的地方一律用 and/or 加 Lambda 参数包起来,不要图省事在外面直接or()。

带 boolean 的and也一样存在:wrapper.and(StringUtils.hasText(keyword), w -> ...),条件为 false 时整个括号不拼接。

3. 四个可复制实战场景

3.1 用户管理列表:keyword、状态、时间范围的组合查询

后台用户列表是最典型的动态查询场景。需求一般是:根据昵称/手机号/用户名模糊搜索,筛选用户状态,指定创建时间范围,分页返回。

// 入参 String keyword = req.getKeyword(); Integer status = req.getStatus(); LocalDate startDate = req.getStartDate(); LocalDate endDate = req.getEndDate(); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); // 关键字同时匹配三个字段,注意括号 wrapper.and(StringUtils.hasText(keyword), w -> w.like(User::getUsername, keyword) .or().like(User::getNickname, keyword) .or().like(User::getPhone, keyword)); // 状态 wrapper.eq(status != null, User::getStatus, status); // 创建时间范围,用 ge + lt 而不是 between,避免 endDate 当天用户被漏掉 if (startDate != null) { wrapper.ge(User::getCreateTime, startDate.atStartOfDay()); } if (endDate != null) { wrapper.lt(User::getCreateTime, endDate.plusDays(1).atStartOfDay()); } wrapper.orderByDesc(User::getId); Page<User> page = new Page<>(req.getPageNum(), req.getPageSize()); IPage<User> result = userMapper.selectPage(page, wrapper);

这段代码我在中后台项目里直接用过很多次。注意 where 条件与 Page 搭配时,MP 分页插件会自动生成 count 查询,一般不需要手动 count。日期范围用ge/lt是为了查“截至 endDate 当天 23:59:59.999”的全部数据,between的闭区间在这种场景容易漏数据。

3.2 订单统计:分组聚合还是 QueryWrapper 更顺手

需求:按日期统计最近 7 天的订单数和订单金额。如果用 LambdaQueryWrapper,聚合列没有原生方法可用,硬凑很难受。这种场景我通常直接切 QueryWrapper:

QueryWrapper<Order> wrapper = new QueryWrapper<>(); wrapper.select("order_date", "count(id) as cnt", "sum(amount) as total_amount") .ge("order_date", LocalDate.now().minusDays(6)) .lt("order_date", LocalDate.now().plusDays(1)) .groupBy("order_date") .orderByAsc("order_date"); List<Map<String, Object>> rows = orderMapper.selectMaps(wrapper);

返回的结构类似[{order_date=2025-06-01, cnt=12, total_amount=1234.00}, ...]。selectMaps会把每行变成一个 Map,很适合报表导出或前端图表展示。

为什么这里不用 Lambda?因为聚合函数没法用方法引用表达,字符串列名反而干净。Wrapper 不是只有 Lambda 一种,实际项目里 LambdaQueryWrapper 和 QueryWrapper 共存是常态。更复杂的多表 join + 聚合,我建议直接写在 XML 里,不要硬用 Wrapper 凑,否则 SQL 的可读性会牺牲掉。

3.3 批量状态更新:更新前先上个保险

批量更新状态用的不是查询 wrapper,而是 LambdaUpdateWrapper:

LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(req.getDeptId() != null, User::getDeptId, req.getDeptId()) .set(User::getStatus, 0) .set(User::getUpdateTime, LocalDateTime.now()); userMapper.update(null, updateWrapper);

注意update第一个参数传 null,这样只会按 updateWrapper 的 set 字段更新。如果第一个参数传入实体,实体里非 null 字段也会参与 SET,容易误更新。

更重要的是:更新之前必须确认 where 条件存在。假如req.getDeptId()为空,eq 的 condition 不满足,updateWrapper 只有 SET 没有 WHERE,userMapper.update(null, updateWrapper)会触发全表更新。这不是危言耸听,我在真实项目里见过不止一次。

最简单的保护:

if (updateWrapper.getSqlSegment().isEmpty()) { throw new IllegalStateException("批量更新必须携带 where 条件"); }

更稳妥的是在 MyBatis-Plus 插件层加一道拦截:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

BlockAttackInnerInterceptor会直接拦截没有 where 条件的 update/delete SQL,相当于给全表写操作上了保险。加了这个插件后,空条件全表更新会直接抛异常,而不是默默执行。

3.4 唯一性校验:ne 排除自己和逻辑删除的配合

新增用户时校验用户名是否已存在,修改用户时校验用户名是否被别的人占用,这是很常见的需求:

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, username) .ne(userId != null, User::getId, userId); Long count = userMapper.selectCount(wrapper); if (count != null && count > 0) { // 用户名已存在 }

ne表示不等于,加上它就能排除当前这条记录。如果项目配置了逻辑删除,MP 会在 SQL 自动追加deleted = 0,不需要手动写。此时逻辑删除过的历史用户名不会占用新用户校验,这个行为通常符合直觉。

注意selectCount方法返回值在不同版本有差异:老版本是 Integer,新版是 Long,使用时以 IDE 提示为准。判断存在性时不要查 list 再判断 size,selectCount更省资源。

4. Wrapper 用久了才会踩到的深坑

4.1 条件全空引发的全表更新事故

这个坑我在 3.3 提了一嘴,这里再展开说。最容易发生的场景不是“没写条件”,而是“写了但条件判断全部为 false”。

LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(req.getDeptId() != null, User::getDeptId, req.getDeptId()) .set(User::getStatus, 0); userMapper.update(null, updateWrapper);

前端少传一个 deptId,eq 就不拼了,updateWrapper 只剩下 SET,没有 WHERE,全表 status 被清零。这个事故如果发生在线上,基本就是“删库跑路”级别的压力。

所以我在团队里定了一条规矩:凡是使用 update 或 delete 的 Wrapper,调用前必须检查getSqlSegment()是否为空;同时全局启用BlockAttackInnerInterceptor。两者叠加,才能把风险压到最低。

不要迷信“这是内部系统不会传错”。内部系统跟外部系统一样会传错,只是没人会在意而已。

4.2 lambda 找不到缓存:解析失败的常见原因

LambdaQueryWrapper 用多了,偶尔会遇到can not find lambda cache for this property之类的报错。原因是 MP 解析 lambda 的本质是从 SerializedLambda 里反射出方法名,再换算字段名,这一步依赖 Class 元数据。

我遇到的几种触发情况:

  • 项目开了热部署,应用运行中 class 被替换,旧的 lambda 缓存与实际类对不上;
  • 实体类写在私有内部类中,或字段定义在泛型父类上;
  • 同一个 JVM 里多个 ClassLoader 加载了同一份实体类。

遇到这种问题,我的处理顺序是:先重启应用,排除热部署缓存问题;如果还报错,再检查实体是不是写在非静态内部类里,能改成顶层类就改;实在改不了,这个查询就改用 QueryWrapper 的字符串字段名。没必要跟框架硬磕。

4.3 last() 是把双刃剑:分页插件冲突和注入风险

wrapper.last("limit 10")看起来简单,实际是个坑。

第一个问题:如果你加了分页插件,又调用 selectPage,MP 会先帮你生成 limit 分页,这时last("limit 10")又拼一个 limit 上去,SQL 变成LIMIT ?,? LIMIT 10,直接报错。我见过同事在同一个查询里既用 Page 又用 last("limit 1"),排查了半天。

第二个问题:last是把字符串原样追加到 SQL 末尾。如果加的是用户输入,比如wrapper.last("order by " + orderBy),基本等于把 SQL 注入的钥匙交给别人。

我的建议是:

  • 分页一律用 Page,不要用last("limit");
  • 必须用 last 时,只允许写固定字符串,如last("FOR UPDATE");
  • 任何变量都别往 last 里塞。

可能有人问:那查一条数据用selectList(wrapper.last("limit 1"))行不行?可以,但非 MySQL 数据库的 limit 语法不一样,跨库就挂了。简单判断是否存在,直接用 selectCount 并不慢。

4.4 in 集合过大与 between 边界

wrapper.in(User::getId, idList)是很常见的写法,但 idList 上千条时,SQL 会非常长,MySQL 可能因为解析成本高而变慢;在 Oracle 里超过 1000 条直接报错。如果你没法保证入参数量,建议分批处理:

List<List<Long>> batchIds = Lists.partition(idList, 500); for (List<Long> ids : batchIds) { // 分段处理 }

注意分批后如果需要整体结果,不要循环去查库再手动合,否则会有 N+1 问题;可以分批只查 id,最后整体查一次,或者考虑用临时表去 join,这个按业务取舍。

between的边界问题也很典型。datetime 字段用between(start, end)是闭区间,如果 end 传的是2025-06-01,查询只会包含2025-06-01 00:00:00这一瞬间的数据,当天其余数据全部漏掉。更稳的写法是用 ge + lt:

wrapper.ge(User::getCreateTime, startDate.atStartOfDay()) .lt(User::getCreateTime, endDate.plusDays(1).atStartOfDay());

这个写法我基本已经刻进肌肉记忆了。

4.5 逻辑删除字段带来的隐形坑

实体开启逻辑删除后,MP 会在普通查询自动追加deleted = 0,这本身是好事。但有些人查“已删除的数据”时,会手动加条件:

wrapper.eq(User::getDeleted, 1);

结果生成 SQL 变成WHERE deleted = 1 AND deleted = 0,什么都查不到。逻辑删除的数据默认是查不出来的,真想查只能写自定义 SQL 或通过配置绕过拦截,不要天真地在 Wrapper 里手动拼 deleted。

另一个坑是唯一索引。比如用户表有username唯一索引,逻辑删除后老用户记录统一 deleted=1,但还是占着唯一索引。用户重新注册同名账号时,insert 会直接撞唯一键报错。常规解法是把 deleted 一起放进唯一索引,或者改成基于deleted=0的过滤索引,这个要看数据库能力。

5. 让 Wrapper 查询跑得快:性能相关的实战经验

5.1 先拿到真实 SQL,再用 EXPLAIN 直观看索引

Wrapper 写起来很爽,但真正执行的 SQL 是 MP 生成的,很多人不知道它长什么样。排查性能问题时,首先要让 SQL 打印出来。配置里加上:

mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

打开日志后,把生成的 SQL 拷到数据库工具里执行 EXPLAIN:

EXPLAIN SELECT username, status FROM user WHERE status = 1 ORDER BY create_time DESC;

看执行计划里的type列。如果出现ALL,说明没走索引,数据量一大必然慢;如果 type 是ref或range,且 key 列有实际索引名,基本问题不大。加了逻辑删除后,SQL 会自动多一个deleted = 0条件,如果这个字段没索引,也可能导致全表扫描,所以逻辑删除字段建议加个普通索引。

5.2 只 select 需要的字段,别把大字段拖下水

默认selectList会查询实体全部字段。实体里如果有备注、正文、JSON 这类大字段,列表接口会被拖慢很多。我之前优化过一张 30 万行的业务表,原列表查询因为 select 了全部字段,平均 800ms;改成只 select id、名称、状态之后,掉到了 80ms 左右。

代码也简单:

wrapper.select(User::getId, User::getUsername, User::getStatus);

如果你担心返回实体时其他字段为 null,可以定义专门的 DTO 或查询结果类,或者用 Map 接收。不要图省事一把梭 select 全部。

5.3 不要在索引字段上套函数

有人为了按天统计,直接写:

wrapper.apply("DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-06-01'");

create_time 上即使有索引也废了,因为 B+ 树是按原始值排序的,套了函数之后无法用于范围匹配。正确姿势是范围查询:

wrapper.ge(User::getCreateTime, LocalDate.parse("2025-06-01").atStartOfDay()) .lt(User::getCreateTime, LocalDate.parse("2025-06-02").atStartOfDay());

这种写法既能保证按天查询的结果完整,又能让索引正常工作。数据库优化的很多问题,最后都落在“不要让索引列参与表达式运算”这一条上。

5.4 批量操作合并,减少数据库往返

Service 继承了 IService 时,批量插入直接调saveBatch(list),不要 for 循环 insert。底层虽然是 batch,但 MySQL 的 JDBC 驱动不一定默认开启批处理优化,连接串上可以带上rewriteBatchedStatements=true,对大批量 insert 有明显提升。

批量更新同理,能用一条 updateWrapper 更新一批数据,就别在 for 里一个 id 一个 id 地 updateById。每一条都是一次网络往返,数据多起来性能差距是数量级的。如果非要逐条处理业务逻辑,至少考虑手动提交事务或使用批量 executor,不要放任默认逐条提交。

6. 写在最后:我对 Wrapper 的几条使用习惯

文章写到这里,日常最常用的东西基本都过了一遍。最后分享几句我个人的使用习惯。

第一,能 Lambda 就 Lambda,但要清楚什么时候切 QueryWrapper。业务 CRUD、动态条件、简单更新,LambdaQueryWrapper 是首选;一旦涉及聚合、分组、别名字段,QueryWrapper 的字符串列名反而更直接;多表 join 和复杂报表,直接回 XML,别硬凑。

第二,更新和删除必须做双保险。代码里检查getSqlSegment(),插件层挂BlockAttackInnerInterceptor,两者都上,才能说我对全表更新这种事放心了。

第三,Wrapper 写完之后,养成看 SQL 的习惯。打开 SQL 日志跑一遍接口,确认 where 条件、括号、limit 都没问题,再提交代码。很多低级错误,看一眼日志就全明白了。

第四,别把 Wrapper 玩出花来。一个查询里又是 apply、又是 last、又是嵌套 and,可读性会急剧下降。当条件复杂到连你自己都数不清括号时,停下来,把这段逻辑拆开或换 XML 写 SQL,长期维护成本更小。

这些年我从 XML 动态 SQL 写到 Wrapper,再到现在分场景选型,最大的体会就是:工具的好坏不在工具本身,而在你对它的边界是否清楚。Lambda 写法帮我挡住了一堆字段名写错的问题,却也差点让我在分组统计里绕远路。如果你也被某个 Wrapper 的骚操作坑过,应该能懂为什么我要单独把这些事拿出来写。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 8:36:04

量化私募C++工程师岗全解析:从知识体系到求职实操

最近朋友圈里陆续有人转一条量化私募的招聘JD&#xff0c;点进去看完我挺有感触&#xff1a;24/25/26届本硕博都收&#xff0c;校招社招春招秋招一起开&#xff0c;点名要数学、物理、统计、计算机、软件这些理工科专业&#xff0c;岗位里排第一的就是量化软件开发工程师&#…

作者头像 李华
网站建设 2026/9/30 8:35:25

Java旅游信息管理系统论文与源码:课程设计毕业设计实战指南

简介&#xff1a;这份资源是面向计算机专业大学生及Java Web初学者的一份完整毕业论文与项目文档&#xff0c;主题为基于Java的旅游信息管理系统&#xff0c;适合用作课程设计、毕业设计参考或Web开发入门练手。压缩包内仅含1个docx文件&#xff0c;约696KB&#xff0c;内容为完…

作者头像 李华
网站建设 2026/9/30 8:35:25

网络与信息安全巡检实战:从清单到自动化脚本的落地指南

简介&#xff1a;这份文档面向中小学、幼儿园、职校及其他教育单位的信息安全负责人与网络管理员&#xff0c;围绕教育系统网络与信息安全巡检工作展开&#xff0c;帮助读者理清巡检流程、检查要点与整改方向。内容涵盖巡检计划安排、重要设备日志备份、数据备份方式、应用服务…

作者头像 李华
网站建设 2026/9/30 8:35:25

城市深度游指南:从游客到在地生活,用四张清单通关北京

在后海边上喝酒的那年&#xff0c;一个北京本地的朋友指着我身后一条没人的小胡同问&#xff1a;“你走进去过吗&#xff1f;”那条胡同我路过不下几十回&#xff0c;可被问住的瞬间&#xff0c;我真的一句话都答不上来。他笑着说&#xff1a;“那你这北京&#xff0c;算白待了…

作者头像 李华
网站建设 2026/9/30 8:35:25

学生公寓组网方案设计:三层交换架构与IP子网划分实战

简介&#xff1a;这份资源是面向计算机网络课程学习者与课程设计撰写者的学生公寓组网方案设计报告&#xff0c;以山东轻工业学院现有网络配置为背景&#xff0c;解决校园公寓网络从需求分析到方案落地的完整设计问题&#xff0c;适合作为课程设计参考或组网方案模板。压缩包内…

作者头像 李华
网站建设 2026/9/30 8:34:50

风储深度调峰模型:MATLAB+Yalmip建模与求解实践

这两年做新能源并网相关项目&#xff0c;我最大的感受是&#xff1a;系统“看不见”的刚性约束远比想象中多。刚开始接触风储深度调峰模型时&#xff0c;我以为只是在MATLAB里把功率平衡公式写对、跑个优化就完事&#xff0c;结果真正搭起来才发现&#xff0c;火电深度调峰的分…

作者头像 李华