这篇文章用一个测试表完整跑通 Doris 的Partial Column Update(部分列更新):
- 不存在就插入
- 存在就只更新指定列
- 值为
NULL时不覆盖原值(保持原值)
1. 先搞懂:Partial Update 的前提条件
1.1 必须是 Unique Key 表,并且是 Merge-on-Write(MoW)
Doris 的Unique Key表天然支持 Upsert:同一个 Key 写入多次,只保留“最新结果”。
但Partial Update(只更新部分列)对INSERT来说,要求 Unique Key 表开启 Merge-on-Write。
注意:如果你的旧表是 Merge-on-Read(MoR),很多情况下不能在线改成 MoW,会报类似 “Can not change UNIQUE KEY to Merge-On-Write mode”。
这种情况通常需要建新表迁移(后面会简单提一下思路)。
2. 创建一个测试表(支持 Partial Update)
我们创建一个简单测试表:用户画像/配置类数据很常见。
CREATETABLEtest_partial_upsert(uidBIGINT,name STRING,ageINT,city STRING,scoreINT,update_timeDATETIME)UNIQUEKEY(uid)DISTRIBUTEDBYHASH(uid)BUCKETS4PROPERTIES("enable_unique_key_merge_on_write"="true");解释一下:
UNIQUE KEY(uid):以uid作为主键(唯一键)enable_unique_key_merge_on_write=true:开启 MoW,后续才能用INSERT做 partial update
3. 开启 Partial Update:必须设置的两个变量
Doris 用INSERT做部分列更新,需要先设置会话变量:
SETenable_unique_key_partial_update=true;官方文档说明:这个变量默认是false,不开就不允许用INSERT做部分列更新。
另外,为了实现“key 不存在就插入”,通常还要关闭 strict(严格)模式:
SETenable_insert_strict=false;原因:严格模式下,导入/写入对不符合规则的数据会过滤或报错;在 partial update + 新 key 场景下,很多同学会碰到“被过滤/不生效”的情况,因此实践里常配合关闭 strict。
小提示:这些
SET是会话级别的,只对当前连接生效。生产环境里建议在应用拿到连接后统一设置(不要每条 SQL 都 SET)。
4. 不存在就插入,存在就更新(Upsert)
4.1 第一次写入:插入一整行
SETenable_unique_key_partial_update=true;SETenable_insert_strict=false;INSERTINTOtest_partial_upsert(uid,name,age,city,score,update_time)VALUES(1,'Alice',30,'Shanghai',80,'2026-01-07 10:00:00');表里现在有:
- uid=1, name=Alice, age=30, city=Shanghai, score=80
4.2 第二次写入:只更新部分列(Partial Update)
比如我只想更新score和update_time,其它列保持不动:
SETenable_unique_key_partial_update=true;SETenable_insert_strict=false;INSERTINTOtest_partial_upsert(uid,score,update_time)VALUES(1,95,'2026-01-07 10:05:00');结果:uid=1 那行会变成
- name 仍然是 Alice(没动)
- age、city 也不变
- score 更新为 95
这就是Partial Update:只更新你写进列清单里的列。
4.3 如果 key 不存在:同样会插入新行
INSERTINTOtest_partial_upsert(uid,score,update_time)VALUES(2,60,'2026-01-07 10:06:00');uid=2 不存在,就会插入一条新行(其它列为 NULL 或默认值,取决于表定义)。
5. 重点来了:如何做到「值为 NULL 就不更新原值」?
5.1 先说结论:Doris 目前不会自动忽略 NULL
在 Partial Update 模式下:
- 只要某列出现在
INSERT的列清单里 - 且对应值是
NULL - Doris 就会把目标列更新成
NULL
也就是说,“NULL 不覆盖原值”不是默认行为。社区也有人提了增强需求,希望提供“忽略 NULL”的选项。
5.2 正确做法:NULL 的列不要出现在 INSERT 列清单里
例如你希望:
- city 传 NULL 时不更新
- score 有值才更新
写法是:当 city 为 NULL,就别把 city 写进 SQL:
-- 只更新 score,不包含 cityINSERTINTOtest_partial_upsert(uid,score,update_time)VALUES(1,100,'2026-01-07 10:10:00');这样city就会保持原值。
错误示例(会把 city 覆盖成 NULL):
INSERTINTOtest_partial_upsert(uid,city,score)VALUES(1,NULL,100);6. 应用层怎么写:推荐两种方式
方式 A(最简单):按场景拆成多条 SQL
比如你有“更新分数”“更新城市”“更新年龄”几类场景,每个场景写固定列的INSERT:
- 需要更新的列写进去
- 不需要的列不写进去
- 永远不会把 NULL 覆盖到别的列
优点:最稳、最容易排查
缺点:SQL 会多一些
方式 B(更通用):MyBatis 动态 SQL(NULL 就不拼列)
这就是你们现在在做的方向。核心思想:
参数为 NULL → 不拼接该列,从而避免覆盖原值。
示例(通用 partial upsert mapper):
<insertid="upsertPartial"parameterType="com.demo.TestPojo">INSERT INTO test_partial_upsert (uid<iftest="name != null">, name</if><iftest="age != null">, age</if><iftest="city != null">, city</if><iftest="score != null">, score</if><iftest="updateTime != null">, update_time</if>) VALUES (#{uid}<iftest="name != null">, #{name}</if><iftest="age != null">, #{age}</if><iftest="city != null">, #{city}</if><iftest="score != null">, #{score}</if><iftest="updateTime != null">, #{updateTime}</if>)</insert>这样:
city=null时就不会写 city 列,自然也不会更新成 NULL。
7. 批量写入
你可能想把多条数据做成:
INSERTINTOtest_partial_upsert(uid,score)VALUES(1,10),(2,20),(3,30);但动态 SQL 有一个天然矛盾:
批量 INSERT 要求每一行的列集合完全一致。
如果 list 里有些对象city=null,有些对象city!=null,那列集合就不一致,不能一条 SQL 全塞进去。
正确做法:按“列集合”分组,再分别批量 INSERT
思路:
- 给每个对象算一个 mask(哪些列非空)
- mask 相同的放一组
- 每组用一条固定列集合的批量 INSERT
1、mask 设计:用 bit 表示“本组要写哪些列”
我们固定 key 列必须写:uid
其它列:name/age/city/score/update_time可能为 null,希望 null 不更新 → 用 mask 控制它是否出现在 SQL 中。
publicfinalclassTestMask{publicstaticfinalintNAME=1<<0;publicstaticfinalintAGE=1<<1;publicstaticfinalintCITY=1<<2;publicstaticfinalintSCORE=1<<3;publicstaticfinalintUPDATE_TIME=1<<4;privateTestMask(){}}计算 mask:
publicstaticintmask(TestPartialUpsertPojop){intm=0;if(p.getName()!=null)m|=TestMask.NAME;if(p.getAge()!=null)m|=TestMask.AGE;if(p.getCity()!=null)m|=TestMask.CITY;if(p.getScore()!=null)m|=TestMask.SCORE;if(p.getUpdateTime()!=null)m|=TestMask.UPDATE_TIME;returnm;}解释:如果
city=null,mask 不包含 CITY,这一批 SQL 就不会出现 city 列 →不会把数据库里的 city 改成 NULL。
2、Java:分组 + 每组再按 500 分批写入
@ServicepublicclassTestPartialUpsertService{privatefinalTestPartialUpsertMappermapper;publicTestPartialUpsertService(TestPartialUpsertMappermapper){this.mapper=mapper;}@TransactionalpublicvoidupsertBatch(List<TestPartialUpsertPojo>list){if(list==null||list.isEmpty())return;// 1) 重要:同一连接里开启 session 变量(不要塞到 insert SQL 里)mapper.enablePartialUpdate();mapper.disableInsertStrict();// 2) 按 mask 分组Map<Integer,List<TestPartialUpsertPojo>>groups=list.stream().collect(Collectors.groupingBy(TestPartialUpsertService::mask));// 3) 每组做批量 INSERT(避免 SQL 太长,500 一批)for(Map.Entry<Integer,List<TestPartialUpsertPojo>>e:groups.entrySet()){intm=e.getKey();List<TestPartialUpsertPojo>g=e.getValue();for(inti=0;i<g.size();i+=500){List<TestPartialUpsertPojo>chunk=g.subList(i,Math.min(i+500,g.size()));mapper.upsertByMask(m,chunk);}}}privatestaticintmask(TestPartialUpsertPojop){intm=0;if(p.getName()!=null)m|=TestMask.NAME;if(p.getAge()!=null)m|=TestMask.AGE;if(p.getCity()!=null)m|=TestMask.CITY;if(p.getScore()!=null)m|=TestMask.SCORE;if(p.getUpdateTime()!=null)m|=TestMask.UPDATE_TIME;returnm;}}3、MyBatis Mapper:三件事
3.1接口
@MapperpublicinterfaceTestPartialUpsertMapper{// session 变量:同一连接生效intenablePartialUpdate();intdisableInsertStrict();// 按 mask + 批量 upsertintupsertByMask(@Param("mask")intmask,@Param("list")List<TestPartialUpsertPojo>list);}3.2 Pojo
publicclassTestPartialUpsertPojo{privateLonguid;privateStringname;privateIntegerage;privateStringcity;privateIntegerscore;privateLocalDateTimeupdateTime;// getter/setter 省略}4、MyBatis XML:根据 mask 决定列集合(固定列集合 + foreach VALUES)
4.1 session SET(单独写,避免多语句 insert)
<updateid="enablePartialUpdate">SET enable_unique_key_partial_update = true</update><updateid="disableInsertStrict">SET enable_insert_strict = false</update>4.2 核心:按 mask 生成“统一列集合”的批量 INSERT
<insertid="upsertByMask">INSERT INTO test_partial_upsert (uid<iftest="(mask & 1) != 0">, name</if><iftest="(mask & 2) != 0">, age</if><iftest="(mask & 4) != 0">, city</if><iftest="(mask & 8) != 0">, score</if><iftest="(mask & 16) != 0">, update_time</if>) VALUES<foreachcollection="list"item="x"separator=",">(#{x.uid}<iftest="(mask & 1) != 0">, #{x.name}</if><iftest="(mask & 2) != 0">, #{x.age}</if><iftest="(mask & 4) != 0">, #{x.city}</if><iftest="(mask & 8) != 0">, #{x.score}</if><iftest="(mask & 16) != 0">, #{x.updateTime}</if>)</foreach></insert>为什么这个 XML 能保证“NULL 不更新原值”?
mask决定列是否出现- 例如 mask 没有 CITY,那么整条 SQL根本不包含
city - Doris partial update 只会更新 SQL 中出现的列
- 所以
city保持原值
5、调用示例(验证效果)
List<TestPartialUpsertPojo>list=newArrayList<>();// uid=1:只更新 scorelist.add(newTestPartialUpsertPojo(1L,null,null,null,100,LocalDateTime.now()));// uid=2:更新 city + agelist.add(newTestPartialUpsertPojo(2L,null,28,"Shanghai",null,LocalDateTime.now()));// uid=3:更新 name(其余 null 不更新)list.add(newTestPartialUpsertPojo(3L,"Alice",null,null,null,null));service.upsertBatch(list);