前言
最近项目里出了一个让我头皮发麻的 BUG------数据查出来是对的,写进数据库就"变"了。不是逻辑问题,不是并发问题,而是 MyBatis 在比较主键时偷偷做了一个类型转换,导致两条完全不同的记录被当成同一条更新了。
更离谱的是,我们的主键用的是雪花算法生成的 ID ------19 位纯数字的 String,但数据库列类型是 BIGINT 。看起来类型"匹配"(数字 vs 数字),但实际上 MyBatis 传 String 参数时,MySQL 比较 BIGINT 列和 String 值会走浮点数转换,末尾精度直接丢了,两个不同的雪花 ID 变成了同一个值。
排查了一整天才定位到根因,写下来给同行避坑。所有用雪花 ID 做主键的项目------不管是 BIGINT 还是 varchar------都可能踩到这个坑。
问题场景
项目中有一个批量补全区域信息的方法:根据经纬度调用第三方地图 API 获取区域数据,然后批量更新到数据库。为了提高效率,用了 CompletableFuture 并发调用,还有重试机制。
⚠️ 注意:我们的主键
id是雪花算法生成的,Java 层定义为 String 类型 ,数据库列是 BIGINT ,值类似2059943650121474133------19 位纯数字。
核心流程是这样的:
java
private Map<String, Integer> batchFillAndUpdate(List<DataRecord> emptyList) {
// 1. 记录原始为空的字段,后续只更新API补全的字段
Map<String, boolean[]> originalBlankMap = new HashMap<>(emptyList.size());
for (DataRecord d : emptyList) {
originalBlankMap.put(d.getRecordId(), new boolean[]{
StrUtil.isBlank(d.getRegion()),
StrUtil.isBlank(d.getSubRegion())
});
}
// 2. 并发调用地图API,最多重试3轮
List<DataRecord> pendingList = emptyList;
for (int round = 1; round <= 3 && !pendingList.isEmpty(); round++) {
List<CompletableFuture<Boolean>> futures = pendingList.stream()
.map(d -> CompletableFuture.supplyAsync(() -> fillRegionByLocation(d), executor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 失败的重试...
}
// 3. 只更新API成功补全的字段
List<DataRecord> updateList = emptyList.stream()
.filter(d -> !failedList.contains(d))
.map(d -> {
boolean[] originalBlank = originalBlankMap.get(d.getRecordId());
DataRecord update = new DataRecord();
update.setId(d.getId()); // ← 雪花ID,String类型
update.setRegionCode(d.getRegionCode());
update.setRegionName(d.getRegionName());
if (originalBlank[0]) update.setRegion(d.getRegion());
if (originalBlank[1]) update.setSubRegion(d.getSubRegion());
return update;
})
.collect(Collectors.toList());
// 4. 批量更新数据库 ← 这里出了问题
dataMapper.batchUpdateRegionInfo(updateList);
}
实际的更新 SQL 使用了 CASE WHEN 批量更新:
xml
<update id="batchUpdateRegionInfo">
UPDATE data_record
SET region_name = CASE id
<foreach collection="list" item="item">
WHEN #{item.id} THEN #{item.regionName}
</foreach>
END,
region = CASE id
<foreach collection="list" item="item">
WHEN #{item.id} THEN #{item.region}
</foreach>
END
WHERE id IN
<foreach collection="list" item="item" open="(" separator="," close=")">
#{item.id}
</foreach>
</update>
现象:更新完成后,查数据库,发现部分记录的区域数据和 API 返回的对不上------明明获取的是 A 记录的区域信息,结果数据库里 B 记录被更新成了 A 的数据。
排查过程
第一轮:怀疑并发问题
我第一时间怀疑是并发调用导致的------CompletableFuture 并发请求第三方 API,是不是数据在组装 updateList 时被交叉覆盖了?
我加了日志,逐条跟踪每个 DataRecord 从 API 获取到最终传入 MyBatis 的完整链路。确认并发处理没问题------每条记录的 id、regionName、region 都是自己的数据,不存在交叉。
第二轮:跑 Debug
逻辑没问题,那就看数据。我加断点跑 Debug:
- 第三方 API 返回的数据:✅ 正确,每条记录的区域信息和经纬度一一对应
- fillRegionByLocation 填充后的 DataRecord 对象:✅ 正确,所有字段一一对应
- 传入 MyBatis 的 updateList:✅ 正确,id、regionName、region 等字段值无误
数据在传入 MyBatis 之前完全正确。那问题只能出在 MyBatis 执行 SQL 的环节。
第三轮:打印实际执行的 SQL
我开启了 MyBatis 的 SQL 日志,看实际执行的语句。拿两条真实碰撞的 ID 举例:
ini
ID_A = "2059943650121474133"
ID_B = "2059943650121474159"
这两条 ID 只在末尾差了 26,是两条完全不同的记录。但实际执行的 SQL:
sql
-- 我预期执行的(字符串精确匹配):
CASE id
WHEN '2059943650121474133' THEN '区域A'
WHEN '2059943650121474159' THEN '区域B'
END
-- 实际执行的(隐式转 Double,精度丢失):
CASE id
WHEN 2059943650121474200.0 THEN '区域A'
WHEN 2059943650121474200.0 THEN '区域B'
END
两个不同的雪花 ID,转成 Double 后变成了同一个值 2059943650121474200.0!
末尾 4 位全部被截断------133 和 159 都变成了 000(四舍五入后甚至变成了 200)。CASE WHEN 的两个分支撞在了同一个键上,后执行的覆盖了前执行的,数据就"串"了。
根因分析
很多人会觉得"Java String + 数据库 BIGINT"------MySQL 会自动把 String 转成整数做比较,应该没问题吧?BIGINT 是 64 位整数,19 位雪花 ID 完全在范围内。
但事实是:MySQL 的隐式类型转换,走的是浮点数,不是整数。
MySQL 的隐式类型转换规则:当 BIGINT 列和 String 值比较时,MySQL 不会 把 String 转成 BIGINT 做整数比较,而是把 String 转成 Double(浮点数) 做数值比较。这是一个绝大多数人不知道的细节。
而雪花 ID 的 19 位纯数字,正是这个坑的"完美触发条件":
ini
雪花 ID = "2059943650121474133"(19位)
BIGINT 精度 = 64位整数,完全够用 ✅
Double 精度 = ~15-16 位有效数字 ❌
差值 = 3-4 位 → 直接截断
用项目里两条真实的碰撞 ID 演示:
sql
-- 你以为的:String 转 BIGINT 做整数比较(精确)
'2059943650121474133' → BIGINT → 2059943650121474133 ✅ 精确
'2059943650121474159' → BIGINT → 2059943650121474159 ✅ 精确
-- 实际的:MySQL 把 String 转 Double 做浮点数比较(丢精度)
'2059943650121474133' → Double → 2059943650121474200 ❌ 末尾丢了
'2059943650121474159' → Double → 2059943650121474200 ❌ 末尾丢了
BIGINT 能存 19 位数字没问题,但 MySQL 的隐式转换不走 BIGINT,走 Double。这就是坑的核心。
而且这个坑比想象中更隐蔽:
| 场景 | Java 类型 | 数据库类型 | 会撞键吗? | 原因 |
|---|---|---|---|---|
| 雪花 ID + BIGINT 列 | String | BIGINT | ✅ 高危 | MySQL 隐式转 Double 比较,19位超精度 |
| 雪花 ID + varchar 列 | String | varchar | ✅ 高危 | 同样被隐式转 Double 比较 |
| UUID + BIGINT 列 | String | BIGINT | ❌ 不会 | UUID含字母,MySQL转Double得到0,0不匹配任何雪花ID |
| 短数字 ID + BIGINT 列 | String | BIGINT | ⚠️ 低风险 | 位数在Double精度内,但极端情况仍可能撞 |
| 雪花 ID + BIGINT 列 | Long | BIGINT | ❌ 安全 | Java Long直接做整数比较,不经过Double |
最后一行是关键:如果你的 Java 实体类把 id 定义为 Long 而不是 String,就没有这个问题。但很多项目为了兼容性用 String 存雪花 ID,这就是隐患的来源。
结论:雪花 ID + Java String + 数据库 BIGINT/varchar + MyBatis 未指定类型 = 定时炸弹。问题不在数据库列类型,而在 MySQL 隐式转换永远走 Double。
解决方案:我踩了两个坑才找到答案
定位到根因后,我开始尝试修复。这里记录了真实的试错过程------因为你的第一直觉很可能是错的。
❌ 方案一:指定 jdbcType=BIGINT(直觉方案,失败)
发现问题后,我的第一反应是:"ID 是数字嘛,那就显式声明为 BIGINT,告诉 MyBatis 这是整数,不就走整数比较了?"
xml
WHEN #{item.id, jdbcType=BIGINT} THEN #{item.regionName}
失败了。数据还是串。
为什么?jdbcType=BIGINT 让 MyBatis 把 Java String 转为 BIGINT 传给 MySQL,但转换路径有问题:
vbnet
MyBatis 类型处理器的转换路径:
String "2059943650121474133"
→ Double.parseDouble() → 2059943650121474200.0(精度已丢!)
→ (long) → 2059943650121474200
→ 传入 MySQL 的已经是错误的值了
MyBatis 的 LongTypeHandler 在处理 String 输入时,内部调用 Long.parseLong()。但在某些 JDBC 驱动版本和配置下,中间可能经过 Double 转换。即使 Long.parseLong() 本身是精确的,当参数到达 MySQL 后,CASE WHEN 中 BIGINT 列和 BIGINT 参数的比较本应没问题------但实际情况是 JDBC 驱动可能以浮点数形式传参,MySQL 接收到的已经丢了精度。
教训:你的第一直觉"ID 是数字就用数字类型"是错的。问题不仅可能在 MySQL 侧,也可能在 MyBatis→JDBC 的传参链路中。
✅ 方案二:CAST AS SIGNED(成功方案,最终采用)
xml
WHEN CAST(#{item.id} AS SIGNED) THEN #{item.regionName}
成功了。数据不再串。
为什么?CAST(#{item.id} AS SIGNED) 的转换路径完全不同:
vbnet
CAST AS SIGNED 的路径:
String "2059943650121474133"
→ MyBatis 原样传 String 到 MySQL(不经过类型处理器)
→ CAST('2059943650121474133' AS SIGNED)
→ MySQL 内部直接 String → 64位整数,不经过浮点数
→ 2059943650121474133 ✅ 精确!
→ BIGINT列 = BIGINT值,整数比较,不会撞键
关键差异在于:CAST 让 MySQL 自己做 String→整数的转换,不走浮点数中间步骤。而 MyBatis 的类型处理器和 JDBC 驱动链路中,任何经过浮点数的步骤都是精度杀手。
⚠️ 方案三:jdbcType=VARCHAR(需要分场景讨论)
网上很多文章推荐 jdbcType=VARCHAR,说"强制字符串比较就不会撞键"。但这要看你的数据库列类型:
数据库是 varchar 时 --- ✅ 可行:
xml
WHERE id = #{id, jdbcType=VARCHAR}
String 参数 + varchar 列 → 字符串精确匹配,没问题。
数据库是 BIGINT 时 --- ⚠️ 未必可行:
xml
WHERE id = #{id, jdbcType=VARCHAR}
MyBatis 以 String 传参,但 MySQL 收到的是 WHERE bigint_column = '2059943650121474133'。MySQL 比较 BIGINT 和 String 时,隐式转 String→Double,精度照样丢!
所以 jdbcType=VARCHAR 能解决的是 varchar 列 的场景,对 BIGINT 列 可能仍然有撞键风险。
三种方案完整对比:
| 方案 | 写法 | BIGINT列 | varchar列 | 原因 |
|---|---|---|---|---|
| jdbcType=BIGINT | #{item.id, jdbcType=BIGINT} |
❌ 失败 | ❌ 失败 | MyBatis/JDBC链路可能走Double |
| CAST AS SIGNED | CAST(#{item.id} AS SIGNED) |
✅ 成功 | ✅ 成功 | MySQL内部直接String→整数,绕开Double |
| jdbcType=VARCHAR | #{item.id, jdbcType=VARCHAR} |
⚠️ 有风险 | ✅ 成功 | BIGINT列仍会隐式转String→Double |
💡 实操建议:
- BIGINT 列 :用
CAST(#{item.id} AS SIGNED),唯一可靠方案- varchar 列 :用
#{item.id, jdbcType=VARCHAR},最简洁- 千万别用
jdbcType=BIGINT,也别以为jdbcType=VARCHAR是万能药------看清楚你的数据库列类型
怎么用 AI 快速排查这类问题
这类"数据偷偷变了"的问题,用 AI 排查比手动快很多。我后来复盘时试了一下,给 Claude 发了这个 prompt:
sql
我的 MyBatis XML 用 CASE WHEN 批量更新,传入的数据在 debug 时正确,
但更新完数据库后部分记录数据"串"了。
具体情况:
- 主键 id 是雪花算法生成的,Java String 类型,19位纯数字
(如 2059943650121474133、2059943650121474159 这两条只差末尾26)
- 数据库 id 列是 BIGINT 类型
- CASE WHEN 中用 #{item.id} 做匹配键(没有指定 jdbcType)
- 数据传入 MyBatis 前全部正确
- 更新后部分记录的数据覆盖到了别的记录上
我试过 #{item.id, jdbcType=BIGINT} 但还是失败。
请分析根因,重点关注:
1. MySQL 对 String 参数 + BIGINT 列的隐式类型转换是否走 Double 而非 BIGINT
2. 为什么 jdbcType=BIGINT 没解决问题
3. CAST AS SIGNED 为什么能解决
Claude 直接给出了类型转换撞键的分析,省了我一整天的排查时间。
如果你遇到类似的"数据正确但写入后变了"的问题,核心排查方向:
- 打印 MyBatis 实际执行的 SQL------看参数是否被转换了类型,数值是否丢了精度
- 检查 CASE WHEN / WHERE 中的字段类型和参数类型是否一致------特别注意 Java String + 数据库 BIGINT 这种组合
- MySQL 隐式类型转换走 Double,不走 BIGINT------这是大多数人不知道的关键细节
jdbcType=VARCHAR只对 varchar 列有效,对 BIGINT 列不管用------别被网上文章误导- 别急着用
jdbcType=BIGINT------你的第一直觉很可能是错的,用CAST AS SIGNED
总结
| 关键点 | 说明 |
|---|---|
| 坑的本质 | 雪花 ID(19位纯数字 String)+ BIGINT 列,MySQL 隐式转 Double 比较,末尾精度丢失导致撞键 |
| 最容易被忽视的信号 | 数据传入正确但写入后不一致------说明问题出在执行环节 |
| 最快定位方式 | 开启 SQL 日志,对比预期 SQL 和实际 SQL |
| ❌ 第一直觉方案 | jdbcType=BIGINT------失败!MyBatis/JDBC 链路可能走 Double,精度在传参阶段就丢了 |
| ✅ 最终修复方案 | CAST(#{item.id} AS SIGNED)------MySQL 内部直接 String→整数,精度不丢,唯一可靠方案 |
| ⚠️ 网上常见建议 | jdbcType=VARCHAR------varchar 列可用,BIGINT 列仍有风险(MySQL 仍会隐式转 Double) |
| AI排查提示 | 告诉AI"数据正确但写入后变了",让它分析类型转换问题 |
一句话总结 :MySQL 隐式类型转换永远走 Double,不走 BIGINT------雪花 ID 的 19 位精度在 Double 里保不住。CAST AS SIGNED 是唯一绕开 Double 的正解,jdbcType=VARCHAR 只对 varchar 列有效,jdbcType=BIGINT 直接踩坑。
本文基于真实踩坑经历写成,如果对你有帮助,点个赞👍让我知道。后续我会持续分享 Java 后端踩坑 + AI 辅助排查的经验,关注不迷路。