Spring Boot + MyBatis-Plus 实现异常隔离的 Upsert 数据落库(含远程调用数据补全)

场景

假设有一个"用户访问商品"的场景:

用户浏览商品详情时,需要记录用户的访问日志(用户编码+商品编码唯一),同时补全用户名称和商品名称。

这个记录操作不能影响商品详情的正常返回。

注:

博客:
https://blog.csdn.net/badao_liumang_qizhi

一、建表 SQL

复制代码
CREATE 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`)
) ENGINE=InnoDB DEFAULT charset=utf8mb4 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 BaseMapper<UserProductVisitLogEntity> {

    /**
     * 插入或更新(基于唯一索引 uk_user_product)
     * 存在则更新名称和访问次数+1,不存在则插入
     */
    void insertOrUpdate(@Param("entity") UserProductVisitLogEntity entity);
}

四、Mapper XML

复制代码
​
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserProductVisitLogMapper">

    <insert id="insertOrUpdate" parameterType="com.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 是引用表中已有的值做累加,不是用传入参数。

五、远程调用服务(模拟 Feign)

复制代码
package 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_code='U001', product_code='P001', 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.warn,userName = null

→ 继续查询商品名称(正常)

→ INSERT (user_code='U001', product_code='P002', user_name=NULL, product_name='洗衣机', visit_count= 1)

→ 入库成功(名称为空但不影响)

→ 主流程正常返回商品详情

入库本身失败时:

数据库连接超时

→ visitLogMapper.insertOrUpdate() 抛出异常

→ 外层 catch 捕获,log.error

→ 主流程正常返回商品详情(用户无感知)

九、总结

这个模式的核心思想:

Upsert 原子操作:

用 INSERT ON DUPLICATE KEY UPDATE 替代"先查后写",避免并发问题

异常隔离:

辅助操作用 try-catch 包裹,不影响核心业务

分层捕获:

远程调用各自独立捕获,最大程度保留可用数据

唯一索引驱动:

利用数据库约束保证数据唯一性,而不是靠应用层逻辑

适用于所有"非核心的数据记录"场景:访问日志、操作审计、埋点数据、缓存预热等。

相关推荐
IT_陈寒4 小时前
React状态更新后视图不刷新?我差点以为是灵异事件
前端·人工智能·后端
不懂的浪漫4 小时前
01|从 Spring Boot 项目理解 RAG:ingest、query、rerank、trace 到 eval
java·人工智能·spring boot·后端·ai·rag
无风听海4 小时前
ASP.NET Core Results<T1, T2>深度解析
后端·asp.net
__log5 小时前
NestJS vs Spring Boot:从架构哲学到实战选择的技术全景解析
spring boot·后端·架构·typescript
拽着尾巴的鱼儿5 小时前
国密算法 Spring Boot 实战:SM2/SM3/SM4 完整集成指南
spring boot·后端·算法
一条泥憨鱼5 小时前
Stream流-从进阶到起飞
java·ide·后端·stream
Devin~Y5 小时前
大厂Java面试实战:Spring Boot微服务、Redis缓存、Kafka消息队列与Spring AI RAG
java·spring boot·redis·kafka·mybatis·spring mvc·hikaricp
小江的记录本5 小时前
【Java基础】Java 8-21新特性 :JDK17:密封类、模式匹配、Record类(附《思维导图》+《面试高频考点清单》)
java·数据结构·后端·python·mysql·面试·职场和发展
Mahir085 小时前
Spring 核心原理:IoC/DI 与 Bean 生命周期全景解析
java·后端·spring·面试·bean生命周期·控制反转ioc·依赖注入di