场景假设有一个用户访问商品的场景用户浏览商品详情时需要记录用户的访问日志用户编码商品编码唯一同时补全用户名称和商品名称。这个记录操作不能影响商品详情的正常返回。注博客https://blog.csdn.net/badao_liumang_qizhi一、建表 SQLCREATE TABLE t_user_product_visit_log ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, user_code varchar(50) NOT NULL COMMENT 用户编码, user_name varchar(100) DEFAULT NULL COMMENT 用户名称, product_code varchar(50) NOT NULL COMMENT 商品编码, product_name varchar(200) DEFAULT NULL COMMENT 商品名称, visit_count int(11) NOT NULL DEFAULT 1 COMMENT 访问次数, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 首次访问时间, update_time datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 最近访问时间, PRIMARY KEY (id), UNIQUE KEY uk_user_product (user_code, product_code) ) ENGINEInnoDB DEFAULT charsetutf8mb4 COMMENT用户商品访问记录表;关键设计uk_user_product 联合唯一索引保证同一用户商品只有一条记录ON UPDATE CURRENT_TIMESTAMP每次更新自动刷新时间visit_count演示更新时可以做累加操作二、实体类package com.example.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.util.Date; TableName(t_user_product_visit_log) Data public class UserProductVisitLogEntity { TableId(type IdType.AUTO) private Long id; private String userCode; private String userName; private String productCode; private String productName; private Integer visitCount; private Date createTime; private Date updateTime; }知识点TableName 指定表名TableId(type IdType.AUTO) 声明自增主键字段名驼峰自动映射为下划线列名userCode → user_code三、Mapper 接口package com.example.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.entity.UserProductVisitLogEntity; import org.apache.ibatis.annotations.Param; public interface UserProductVisitLogMapper extends BaseMapperUserProductVisitLogEntity { /** * 插入或更新基于唯一索引 uk_user_product * 存在则更新名称和访问次数1不存在则插入 */ void insertOrUpdate(Param(entity) UserProductVisitLogEntity entity); }四、Mapper XML ?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserProductVisitLogMapper insert idinsertOrUpdate parameterTypecom.example.entity.UserProductVisitLogEntity INSERT INTO t_user_product_visit_log (user_code, user_name, product_code, product_name, visit_count, create_time, update_time) VALUES (#{entity.userCode}, #{entity.userName}, #{entity.productCode}, #{entity.productName}, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE user_name #{entity.userName}, product_name #{entity.productName}, visit_count visit_count 1, update_time NOW() /insert /mapper SQL 执行逻辑MySQL 尝试执行 INSERT发现 uk_user_product 唯一索引冲突该用户商品已有记录转而执行 ON DUPLICATE KEY UPDATE 部分更新名称字段访问次数 1刷新更新时间注意 visit_count visit_count 1 是引用表中已有的值做累加不是用传入参数。五、远程调用服务模拟 Feignpackage com.example.feign; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; // 用户服务 FeignClient(name userService, url ${feign.user-service.url}) public interface UserFeign { GetMapping(/users/{userCode}/name) String getUserName(PathVariable(userCode) String userCode); } // 商品服务 FeignClient(name productService, url ${feign.product-service.url}) public interface ProductFeign { GetMapping(/products/{productCode}/name) String getProductName(PathVariable(productCode) String productCode); }六、Service 实现核心package com.example.service.impl; import com.example.entity.UserProductVisitLogEntity; import com.example.feign.ProductFeign; import com.example.feign.UserFeign; import com.example.mapper.UserProductVisitLogMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; Slf4j Service public class ProductServiceImpl { private final UserProductVisitLogMapper visitLogMapper; private final UserFeign userFeign; private final ProductFeign productFeign; public ProductServiceImpl(UserProductVisitLogMapper visitLogMapper, UserFeign userFeign, ProductFeign productFeign) { this.visitLogMapper visitLogMapper; this.userFeign userFeign; this.productFeign productFeign; } /** * 查询商品详情主流程 */ public ProductDetailVO getProductDetail(String userCode, String productCode) { // 辅助操作记录访问日志异常不影响主流程 saveVisitLog(userCode, productCode); // 主流程查询商品详情并返回 // ... 正常的商品查询逻辑 return queryProductFromDB(productCode); } /** * 保存访问记录 * 设计原则 * 1. 整体 try-catch 兜底任何异常都不向上抛出 * 2. 每个远程调用单独 try-catch某个失败不影响其他字段 * 3. 只记日志不影响用户体验 */ private void saveVisitLog(String userCode, String productCode) { try { UserProductVisitLogEntity entity new UserProductVisitLogEntity(); entity.setUserCode(userCode); entity.setProductCode(productCode); // 远程调用1查询用户名称 try { String userName userFeign.getUserName(userCode); entity.setUserName(userName); } catch (Exception e) { log.warn(查询用户名称失败, userCode{}, error{}, userCode, e.getMessage()); // 不抛出userName 为 null 也能入库 } // 远程调用2查询商品名称 try { String productName productFeign.getProductName(productCode); entity.setProductName(productName); } catch (Exception e) { log.warn(查询商品名称失败, productCode{}, error{}, productCode, e.getMessage()); // 不抛出productName 为 null 也能入库 } // 执行入库INSERT ON DUPLICATE KEY UPDATE visitLogMapper.insertOrUpdate(entity); } catch (Exception e) { // 最外层兜底确保任何未预期的异常都不会传播到主流程 log.error(保存访问记录失败不影响主流程, userCode{}, productCode{}, error{}, userCode, productCode, e.getMessage()); } } }七、异常隔离的三层防护结构saveVisitLog()├── try { ← 第一层兜底所有异常│ ├── try { userFeign... } ← 第二层远程调用1 失败不影响后续│ ├── try { productFeign... } ← 第二层远程调用2 失败不影响后续│ └── visitLogMapper.insertOrUpdate() ← 入库操作││ } catch (Exception e) {│ log.error(...) ← 只记日志不 throw│ }八、执行效果演示第一次访问INSERT用户 U001 访问商品 P001→ INSERT (user_codeU001, product_codeP001, user_name张三, product_name冰箱, visit_count 1)→ 插入成功第二次访问UPDATE用户 U001 再次访问商品 P001→ INSERT 触发唯一索引冲突→ 执行 ON DUPLICATE KEY UPDATE→ visit_count 1 1 2, update_time NOW()远程调用失败时用户 U001 访问商品 P002但用户服务挂了→ userFeign.getUserName() 抛出异常→ 内层 catch 捕获log.warnuserName null→ 继续查询商品名称正常→ INSERT (user_codeU001, product_codeP002, user_nameNULL, product_name洗衣机, visit_count 1)→ 入库成功名称为空但不影响→ 主流程正常返回商品详情入库本身失败时数据库连接超时→ visitLogMapper.insertOrUpdate() 抛出异常→ 外层 catch 捕获log.error→ 主流程正常返回商品详情用户无感知九、总结这个模式的核心思想Upsert 原子操作用 INSERT ON DUPLICATE KEY UPDATE 替代先查后写避免并发问题异常隔离辅助操作用 try-catch 包裹不影响核心业务分层捕获远程调用各自独立捕获最大程度保留可用数据唯一索引驱动利用数据库约束保证数据唯一性而不是靠应用层逻辑适用于所有非核心的数据记录场景访问日志、操作审计、埋点数据、缓存预热等。
Spring Boot + MyBatis-Plus 实现异常隔离的 Upsert 数据落库(含远程调用数据补全)
场景假设有一个用户访问商品的场景用户浏览商品详情时需要记录用户的访问日志用户编码商品编码唯一同时补全用户名称和商品名称。这个记录操作不能影响商品详情的正常返回。注博客https://blog.csdn.net/badao_liumang_qizhi一、建表 SQLCREATE TABLE t_user_product_visit_log ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, user_code varchar(50) NOT NULL COMMENT 用户编码, user_name varchar(100) DEFAULT NULL COMMENT 用户名称, product_code varchar(50) NOT NULL COMMENT 商品编码, product_name varchar(200) DEFAULT NULL COMMENT 商品名称, visit_count int(11) NOT NULL DEFAULT 1 COMMENT 访问次数, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 首次访问时间, update_time datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 最近访问时间, PRIMARY KEY (id), UNIQUE KEY uk_user_product (user_code, product_code) ) ENGINEInnoDB DEFAULT charsetutf8mb4 COMMENT用户商品访问记录表;关键设计uk_user_product 联合唯一索引保证同一用户商品只有一条记录ON UPDATE CURRENT_TIMESTAMP每次更新自动刷新时间visit_count演示更新时可以做累加操作二、实体类package com.example.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.util.Date; TableName(t_user_product_visit_log) Data public class UserProductVisitLogEntity { TableId(type IdType.AUTO) private Long id; private String userCode; private String userName; private String productCode; private String productName; private Integer visitCount; private Date createTime; private Date updateTime; }知识点TableName 指定表名TableId(type IdType.AUTO) 声明自增主键字段名驼峰自动映射为下划线列名userCode → user_code三、Mapper 接口package com.example.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.entity.UserProductVisitLogEntity; import org.apache.ibatis.annotations.Param; public interface UserProductVisitLogMapper extends BaseMapperUserProductVisitLogEntity { /** * 插入或更新基于唯一索引 uk_user_product * 存在则更新名称和访问次数1不存在则插入 */ void insertOrUpdate(Param(entity) UserProductVisitLogEntity entity); }四、Mapper XML ?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserProductVisitLogMapper insert idinsertOrUpdate parameterTypecom.example.entity.UserProductVisitLogEntity INSERT INTO t_user_product_visit_log (user_code, user_name, product_code, product_name, visit_count, create_time, update_time) VALUES (#{entity.userCode}, #{entity.userName}, #{entity.productCode}, #{entity.productName}, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE user_name #{entity.userName}, product_name #{entity.productName}, visit_count visit_count 1, update_time NOW() /insert /mapper SQL 执行逻辑MySQL 尝试执行 INSERT发现 uk_user_product 唯一索引冲突该用户商品已有记录转而执行 ON DUPLICATE KEY UPDATE 部分更新名称字段访问次数 1刷新更新时间注意 visit_count visit_count 1 是引用表中已有的值做累加不是用传入参数。五、远程调用服务模拟 Feignpackage com.example.feign; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; // 用户服务 FeignClient(name userService, url ${feign.user-service.url}) public interface UserFeign { GetMapping(/users/{userCode}/name) String getUserName(PathVariable(userCode) String userCode); } // 商品服务 FeignClient(name productService, url ${feign.product-service.url}) public interface ProductFeign { GetMapping(/products/{productCode}/name) String getProductName(PathVariable(productCode) String productCode); }六、Service 实现核心package com.example.service.impl; import com.example.entity.UserProductVisitLogEntity; import com.example.feign.ProductFeign; import com.example.feign.UserFeign; import com.example.mapper.UserProductVisitLogMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; Slf4j Service public class ProductServiceImpl { private final UserProductVisitLogMapper visitLogMapper; private final UserFeign userFeign; private final ProductFeign productFeign; public ProductServiceImpl(UserProductVisitLogMapper visitLogMapper, UserFeign userFeign, ProductFeign productFeign) { this.visitLogMapper visitLogMapper; this.userFeign userFeign; this.productFeign productFeign; } /** * 查询商品详情主流程 */ public ProductDetailVO getProductDetail(String userCode, String productCode) { // 辅助操作记录访问日志异常不影响主流程 saveVisitLog(userCode, productCode); // 主流程查询商品详情并返回 // ... 正常的商品查询逻辑 return queryProductFromDB(productCode); } /** * 保存访问记录 * 设计原则 * 1. 整体 try-catch 兜底任何异常都不向上抛出 * 2. 每个远程调用单独 try-catch某个失败不影响其他字段 * 3. 只记日志不影响用户体验 */ private void saveVisitLog(String userCode, String productCode) { try { UserProductVisitLogEntity entity new UserProductVisitLogEntity(); entity.setUserCode(userCode); entity.setProductCode(productCode); // 远程调用1查询用户名称 try { String userName userFeign.getUserName(userCode); entity.setUserName(userName); } catch (Exception e) { log.warn(查询用户名称失败, userCode{}, error{}, userCode, e.getMessage()); // 不抛出userName 为 null 也能入库 } // 远程调用2查询商品名称 try { String productName productFeign.getProductName(productCode); entity.setProductName(productName); } catch (Exception e) { log.warn(查询商品名称失败, productCode{}, error{}, productCode, e.getMessage()); // 不抛出productName 为 null 也能入库 } // 执行入库INSERT ON DUPLICATE KEY UPDATE visitLogMapper.insertOrUpdate(entity); } catch (Exception e) { // 最外层兜底确保任何未预期的异常都不会传播到主流程 log.error(保存访问记录失败不影响主流程, userCode{}, productCode{}, error{}, userCode, productCode, e.getMessage()); } } }七、异常隔离的三层防护结构saveVisitLog()├── try { ← 第一层兜底所有异常│ ├── try { userFeign... } ← 第二层远程调用1 失败不影响后续│ ├── try { productFeign... } ← 第二层远程调用2 失败不影响后续│ └── visitLogMapper.insertOrUpdate() ← 入库操作││ } catch (Exception e) {│ log.error(...) ← 只记日志不 throw│ }八、执行效果演示第一次访问INSERT用户 U001 访问商品 P001→ INSERT (user_codeU001, product_codeP001, user_name张三, product_name冰箱, visit_count 1)→ 插入成功第二次访问UPDATE用户 U001 再次访问商品 P001→ INSERT 触发唯一索引冲突→ 执行 ON DUPLICATE KEY UPDATE→ visit_count 1 1 2, update_time NOW()远程调用失败时用户 U001 访问商品 P002但用户服务挂了→ userFeign.getUserName() 抛出异常→ 内层 catch 捕获log.warnuserName null→ 继续查询商品名称正常→ INSERT (user_codeU001, product_codeP002, user_nameNULL, product_name洗衣机, visit_count 1)→ 入库成功名称为空但不影响→ 主流程正常返回商品详情入库本身失败时数据库连接超时→ visitLogMapper.insertOrUpdate() 抛出异常→ 外层 catch 捕获log.error→ 主流程正常返回商品详情用户无感知九、总结这个模式的核心思想Upsert 原子操作用 INSERT ON DUPLICATE KEY UPDATE 替代先查后写避免并发问题异常隔离辅助操作用 try-catch 包裹不影响核心业务分层捕获远程调用各自独立捕获最大程度保留可用数据唯一索引驱动利用数据库约束保证数据唯一性而不是靠应用层逻辑适用于所有非核心的数据记录场景访问日志、操作审计、埋点数据、缓存预热等。