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 包裹,不影响核心业务

分层捕获:

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

唯一索引驱动:

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

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

相关推荐
雪隐8 小时前
AI股票小助手07-TA-Lib 技术指标计算实战
人工智能·后端
掘金者阿豪8 小时前
一本书读懂微积分!
后端
Cosolar8 小时前
深入理解 LangChain Callback 机制:从入门到实战
人工智能·后端·面试
我登哥MVP8 小时前
Spring Boot 从“会用”到“精通”:SpringBoot MVC 请求处理全流程
java·spring boot·后端·spring·mvc·maven·intellij-idea
我登哥MVP9 小时前
Spring Boot 从“会用”到“精通”:ReturnValueHandler原理
java·spring boot·后端·spring·java-ee·maven·intellij-idea
伊布拉西莫9 小时前
Flask 请求生命周期
后端·python·flask
英豪1639 小时前
@Target + @Retention + isAnnotationPresent + getAnnotation
后端
黄同学real9 小时前
HJL WebAPI 项目日志入库实战:从建表到自动清理
后端