代码位置:
dkd-manage/.../service/impl/RegionServiceImpl.javadkd-manage/.../mapper/EmpMapper.java
涉及表:tb_region、tb_emp
关联笔记:数据库冗余字段设计笔记.md、Emp冗余字段补全笔记.md
一、需求背景
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());
拿刚刚提交的新名称去更新该区域下所有员工。注意两点:
- 参数顺序与 SQL 一致:
set region_name = #{regionName}在前,where region_id = #{regionId}在后; - 是一条 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 行 |
老师选择"无条件同步",理由:
- 教学优先:本节要讲的是"改主表要记得改冗余表"这个套路,代码越短越容易记住;
- 性能代价极小 :
where region_id = ?走索引,多执行一次 SQL 几乎无感; - 默认输入正常:前端表单里区域名称是必填项,实际很难提交 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 了。
八、验证步骤
- 重新编译并重启(Mapper 接口和 XML 都有改动);
- 人员管理里确认某员工归属「昌平区」;
- 区域管理把「昌平区」改成「昌平新区」;
- 回人员管理刷新 → 该员工归属区域变成「昌平新区」。
直接查库确认:
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;
}
十一、速查
同步存储三步走
- 主表更新(
updateRegion) - 冗余表同步(
updateByRegionId) - 两步放进同一事务(
@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 时会不会把冗余字段刷空? - 改完是否重启并验证过两边数据一致?