场景
假设有一个"用户访问商品"的场景:
用户浏览商品详情时,需要记录用户的访问日志(用户编码+商品编码唯一),同时补全用户名称和商品名称。
这个记录操作不能影响商品详情的正常返回。
注:
博客:
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 包裹,不影响核心业务
分层捕获:
远程调用各自独立捕获,最大程度保留可用数据
唯一索引驱动:
利用数据库约束保证数据唯一性,而不是靠应用层逻辑
适用于所有"非核心的数据记录"场景:访问日志、操作审计、埋点数据、缓存预热等。