区域名称同步存储实现笔记

代码位置:

一、需求背景

tb_emp 表冗余了 region_name:

字段 性质 来源
region_id 关联 id 员工新增/编辑时由前端提交
region_name 冗余字段 由后端按 region_id 查 tb_region 补全

由此带来一个一致性问题:

在区域管理里把「昌平区」改名为「昌平新区」后,tb_region.region_name 变了,

但 tb_emp.region_name 仍然是「昌平区」------数据库不会自动同步。

所以需要在「修改区域」时,顺带把员工表的冗余字段一起改掉。

二、实现思路:同步存储

方案:在更新区域表的同时,同步更新员工表中的区域名称。

sql 复制代码
-- 第一步:改区域
update tb_region set region_name = '昌平新区' where id = 3;

-- 第二步:改冗余数据
update tb_emp set region_name = '昌平新区' where region_id = 3;
说明
优点 单表查询,列表页效率最高,不需要 JOIN
缺点 区域修改时有额外开销;两步操作若不在同一事务,可能数据不一致

与另一种方案对比

方案 做法 优点 缺点
同步存储(本次采用) 冗余 region_name,改区域时同步 查询最快,单表搞定 写操作有额外开销,有一致性风险
关联查询 不存名称,查询时 JOIN tb_region 无一致性问题 每次查询都要 JOIN

三、最终实现(三处改动)

3.1 EmpMapper 接口:注解式 SQL

java 复制代码
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

/**
 * 根据区域id修改区域名称(区域改名后同步员工表中的冗余字段)
 * 
 * @param regionName 区域名称
 * @param regionId 区域主键
 * @return 结果
 */
@Update("update tb_emp set region_name = #{regionName} where region_id = #{regionId}")
public int updateByRegionId(@Param("regionName") String regionName, @Param("regionId") Long regionId);

EmpMapper.xml 中不再 写对应的 <update id="updateByRegionId">,否则启动报错(见第七节)。

3.2 RegionServiceImpl:注入 EmpMapper

java 复制代码
@Autowired
private RegionMapper regionMapper;

@Autowired
private EmpMapper empMapper;

3.3 RegionServiceImpl.updateRegion:同一事务内同步

java 复制代码
@Transactional(rollbackFor = Exception.class)
@Override
public int updateRegion(Region region)
{
    // 先更新区域信息
    region.setUpdateTime(DateUtils.getNowDate());
    int result = regionMapper.updateRegion(region);

    // 同步更新员工表区域名称
    empMapper.updateByRegionId(region.getRegionName(), region.getId());
    return result;
}

四、逐行解读

java 复制代码
region.setUpdateTime(DateUtils.getNowDate());

更新时间由服务端 生成,不依赖前端传值(客户端时间不可信)。对应 tb_region 的时间列。

java 复制代码
int result = regionMapper.updateRegion(region);

更新区域主表。result 是受影响行数,最终返回给 Controller 判断成功与否。

java 复制代码
empMapper.updateByRegionId(region.getRegionName(), region.getId());

拿刚刚提交的新名称去更新该区域下所有员工。注意两点:

  1. 参数顺序与 SQL 一致:set region_name = #{regionName} 在前,where region_id = #{regionId} 在后;
  2. 是一条 SQL 批量更新,不是"查出员工列表再逐个 update",走 region_id 索引,开销可控。
java 复制代码
return result;

返回主表的更新行数,前端据此判断成功。

五、@Transactional(rollbackFor = Exception.class) 为什么这样写

两步写操作必须同生共死:区域改了、员工没改,就产生脏数据。

Spring 默认的回滚规则

异常类型 裸 @Transactional @Transactional(rollbackFor = Exception.class)
RuntimeException(NPE、SQL 异常) 回滚 回滚
Error(OOM 等) 回滚 回滚
受检异常 (IOException 等) 提交事务 回滚

也就是说,方法里如果抛了一个受检异常,裸 @Transactional 会把已经执行一半的操作提交掉,事务白加。

rollbackFor = Exception.class 明确表示"任何异常都回滚",也是《阿里巴巴 Java 开发手册》的强制要求。

注意

@Transactional 基于 Spring AOP 代理生效,以下情况会失效:

  • 同类内部方法直接调用(没走代理);
  • 方法不是 public;
  • 异常被自己 try-catch 吃掉,没有抛出去;
  • 数据库引擎不支持事务(MyISAM)。

六、为什么老师的代码看起来更简洁

老师的版本只有四行有效代码:

java 复制代码
region.setUpdateTime(DateUtils.getNowDate());
int result = regionMapper.updateRegion(region);
empMapper.updateByRegionId(region.getRegionName(), region.getId());
return result;

等价但"更啰嗦"的工程化版本会额外做三件事:

额外处理 目的 代价
先查旧区域 selectRegionById 判断名称是否真的变化 多一次查询
nameChanged 判断 名称没变就跳过同步 代码多 5 行
StringUtils.isNotEmpty 判空 防止 regionName 为 null 把员工名称刷成 NULL 代码多 2 行

老师选择"无条件同步",理由:

  1. 教学优先:本节要讲的是"改主表要记得改冗余表"这个套路,代码越短越容易记住;
  2. 性能代价极小 :where region_id = ? 走索引,多执行一次 SQL 几乎无感;
  3. 默认输入正常:前端表单里区域名称是必填项,实际很难提交 null。

简洁版的风险

风险 触发条件 后果
无事务(最初版本) 第二步失败 区域改了,员工没改
不判空 前端传 regionName: null 员工 region_name 被刷成 NULL
无条件同步 名称未变 多执行一次无用 SQL(可忽略)

老师最终版已经补上了最关键的一环------事务,剩下的是性能微调,教学阶段可以不做。

取舍建议

  • 跟教程阶段:用老师版本(事务 + 无条件同步)即可;
  • 正式项目:再加上判空与变化判断;
  • 不要为了"看起来专业"而堆砌防御代码,过早优化也是负担。

七、注解 SQL 与 XML 的选择

老师的 updateByRegionId 用了 @Update 注解,把 SQL 写在 Mapper 接口上。

注解(@Update/@Select...) XML(<update>...)
优点 SQL 与接口在一起,不用跨文件 支持 <if>/<foreach>、resultMap、长 SQL
缺点 不支持动态标签(除非包 <script>);长 SQL 难读 接口与 SQL 分两处维护
适合 单条简单 SQL、无动态条件 动态条件查询、多表映射、复杂 CRUD

本项目 EmpMapper 现在是混合模式:

复制代码
EmpMapper.java
├── @Update updateByRegionId   ← 注解:一行 SQL,无动态条件
└── selectEmpList / insertEmp / updateEmp / deleteEmpByIds 等
                                ← XML:动态 SQL + resultMap

混合使用是常见做法,各取所长,不算不规范。

必须遵守的两条规则

1. 同一个 id 不能在注解和 XML 里重复出现

否则启动报错:

复制代码
IllegalArgumentException: Mapped Statements collection already contains value
for com.dkd.manage.mapper.EmpMapper.updateByRegionId

所以改成注解后,务必删除 XML 里的 <update id="updateByRegionId">。

2. 多参数必须用 @Param 命名

java 复制代码
public int updateByRegionId(@Param("regionName") String regionName, @Param("regionId") Long regionId);

不写 @Param 时,MyBatis 只能用 arg0/param1 这类位置名,#{regionName} 无法解析。

3. 需要动态条件时收手

如果这段 SQL 要加条件,注解里需要包 <script>:

java 复制代码
@Update({"<script>",
         "update tb_emp set region_name = #{regionName}",
         "  <where>region_id = #{regionId}</where>",
         "</script>"})

一旦出现 <script>,说明该搬回 XML 了。

八、验证步骤

  1. 重新编译并重启(Mapper 接口和 XML 都有改动);
  2. 人员管理里确认某员工归属「昌平区」;
  3. 区域管理把「昌平区」改成「昌平新区」;
  4. 回人员管理刷新 → 该员工归属区域变成「昌平新区」。

直接查库确认:

sql 复制代码
-- 区域表
SELECT id, region_name FROM tb_region WHERE id = 3;

-- 员工表冗余字段
SELECT id, user_name, region_id, region_name FROM tb_emp WHERE region_id = 3;

两条结果里的名称应该一致。

九、常见坑

现象 原因 处理
启动报 already contains value for ...updateByRegionId 注解和 XML 重复定义 删除 XML 中的 <update>
启动报 BindingException: Invalid bound statement target/classes 里是旧 XML 重新构建 / clean 后重启
参数绑定失败,regionName 取不到值 多参数未加 @Param 给每个参数加 @Param("名称")
区域改了、员工没改 没加事务,或异常被 catch 掉 加 @Transactional(rollbackFor = Exception.class),不要吞异常
员工 region_name 变 NULL regionName 为空仍无条件同步 同步前判空

十、延伸:角色名称尚未同步

tb_emp 中还有两个冗余字段没有同步逻辑:

字段 源自 现状
role_name tb_role.role_name 角色改名后不会同步
role_code tb_role.role_code 同上

按同样的套路补在 RoleServiceImpl.updateRole 里,一句注解 SQL 即可:

java 复制代码
@Update("update tb_emp set role_code = #{roleCode}, role_name = #{roleName} where role_id = #{roleId}")
int updateByRoleId(@Param("roleId") Long roleId,
                   @Param("roleCode") String roleCode,
                   @Param("roleName") String roleName);
java 复制代码
@Transactional(rollbackFor = Exception.class)
@Override
public int updateRole(Role role)
{
    role.setUpdateTime(DateUtils.getNowDate());
    int result = roleMapper.updateRole(role);
    // 同步更新员工表中的角色名称、角色编码
    empMapper.updateByRoleId(role.getRoleId(), role.getRoleCode(), role.getRoleName());
    return result;
}

十一、速查

同步存储三步走

  1. 主表更新(updateRegion)
  2. 冗余表同步(updateByRegionId)
  3. 两步放进同一事务(@Transactional(rollbackFor = Exception.class))

关键代码

代码 作用
@Update("update tb_emp set region_name = #{regionName} where region_id = #{regionId}") 注解式批量更新冗余字段
@Param("regionName") / @Param("regionId") 多参数必须命名,否则 #{} 解析失败
@Transactional(rollbackFor = Exception.class) 任何异常都回滚,保证两步同生共死
DateUtils.getNowDate() 更新时间取服务端时间,不信任前端

检查清单

  • 改主表的 Service 里有没有同步冗余表?
  • 两步是否在同一事务内?
  • 事务注解是否写了 rollbackFor = Exception.class?
  • 注解 SQL 与 XML 是否有重复 id?
  • 多参数是否都加了 @Param?
  • regionName 为 null 时会不会把冗余字段刷空?
  • 改完是否重启并验证过两边数据一致?
相关推荐
Rain的Java大神之路1 小时前
LeetCode 21 合并两个有序链表:一道 Easy 题,凭什么刷掉一半候选人?
java·后端·算法
小白的码BUG之路1 小时前
K8S -- 集群搭建环境基础
java·服务器·kubernetes
w_t_y_y1 小时前
JWT(5)JWT+spring security
java·后端·spring
wenyq71 小时前
LeetCode 152. Maximum Product Subarray
java·算法·leetcode
余额瞒着我当琳1 小时前
C++异常,异常的概念,异常抛出与捕获,RAII 和智能指针的设计思路,C++标准库一些指针的使用
java·开发语言·c++·算法
東隅已逝,桑榆非晚1 小时前
map和set的使用
数据结构·c++·笔记·学习
花间相见2 小时前
【macOS开发环境:Java配置】—— Mac上的Java版本管理-SDKMAN安装、多版本切换与项目实战
java·架构·个人开发
骑着蜗牛撵大象3272 小时前
大模型 SDK 编译就崩?用 CMakeLists 把 httplib、JSON 与 OpenSSL 装配成可复用的 ChatServer
android·java·c++·http·json·cmake·大模型sdk