两个运营同时改了同一个规则,后保存的把先保存的覆盖了
有次运营跑过来问我:"我昨天配的满减规则怎么没了?活动已经上线了,用户下单也没用券。"
查了一下操作日志,真相很简单:下午 3 点,运营 A 打开了规则编辑页,改了满减门槛从 100 降到 80。改完她没立刻保存,先去开会了。下午 3:15,运营 B 也打开同一个规则,把适用商品从"全场"改成了"指定分类",保存。
下午 4 点,运营 A 开完会回来点了保存——她手里还是下午 3 点的旧版本,里面"适用商品"还是"全场"。她一保存,运营 B 改的"指定分类"被覆盖回去了。
这就是典型的并发编辑覆盖。QLExpress 规则引擎本身只管执行规则,不管谁在什么时候改了规则。多人同时操作的场景下,最后一个保存的人赢——前一个人的修改悄无声息地丢了。
一、为什么这个问题比看起来严重
如果只是规则配错了,运营发现后可以改。但并发覆盖的可怕之处在于——你根本不知道丢了什么。
运营 A 保存后,规则内容是 A 想要的样子,B 的修改完全消失。没有报错,没有提示冲突,没有操作日志记录 B 的修改被覆盖了。B 可能三天后才发现自己配的东西没生效,中间已经发了上万张券。
还有一个隐患:如果 A 和 B 改的是同一个字段(比如 A 把满减门槛从 100 改到 80,B 从 100 改到 60),最后谁后保存谁生效。但 A 会以为门槛是 80,B 会以为门槛是 60,实际线上是 80——因为 A 后保存的。
没有冲突检测的并发编辑,本质上是在碰运气。
二、方案:乐观锁 + 版本号
思路不复杂——数据库里加一个 version 字段。每次读取规则时带上版本号,保存时比对版本号。版本号没变,说明没人改过,允许保存。版本号变了,说明有人先改了,拒绝保存并提示冲突。
ALTER TABLE qlexpress_rule ADD COLUMN version INT DEFAULT 0;
2.1 保存逻辑
@Service
public class QLExpressRuleService {
@Autowired private JdbcTemplate jdbc;
/**
* 保存规则——带版本号校验
*/
public void saveRule(RuleDTO dto) {
int updated = jdbc.update(
"UPDATE qlexpress_rule SET " +
" rule_name = ?, " +
" rule_content = ?, " +
" rule_params = ?, " +
" updated_by = ?, " +
" updated_at = NOW(), " +
" version = version + 1 " + // 乐观锁的关键
"WHERE id = ? AND version = ?", // 版本号比对
dto.getRuleName(),
dto.getRuleContent(),
dto.getRuleParams(),
dto.getUpdatedBy(),
dto.getId(),
dto.getVersion() // 前端传回来的版本号
);
if (updated == 0) {
// 没有行被更新——说明版本号已经变了
Rule current = findById(dto.getId());
throw new ConcurrentModificationException(
"规则已被他人修改,请刷新后重新编辑。" +
"当前版本:" + current.getVersion() +
",你的版本:" + dto.getVersion()
);
}
// 保存成功,刷新缓存
refreshCache(dto.getId());
}
public Rule findById(Long id) {
return jdbc.queryForObject(
"SELECT id, rule_name, rule_content, rule_params, version, updated_by, updated_at " +
"FROM qlexpress_rule WHERE id = ?",
new BeanPropertyRowMapper<>(Rule.class), id
);
}
}
核心就一行 SQL:
UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?
WHERE version = ? 是整个乐观锁的守卫。如果这行在这期间被别人改过(version 已经 +1 了),这个 WHERE 条件不成立,UPDATE 影响 0 行。
2.2 前端交互
前端加载规则时拿到 version,保存时原样带回:
// 打开编辑页
const rule = await fetch(`/api/rule/${ruleId}`).then(r => r.json());
// rule = { id: 1, ruleName: "满减规则", version: 5, ... }
// 保存
const response = await fetch(`/api/rule/${ruleId}`, {
method: 'PUT',
body: JSON.stringify({
ruleName: "满减规则",
ruleContent: editedContent,
version: rule.version // ← 传原始版本号
})
});
if (response.status === 409) {
// 冲突了
const conflict = await response.json();
alert("该规则已被 " + conflict.updatedBy + " 修改,当前版本 " +
conflict.currentVersion + ",请刷新后重新编辑");
}
后端返回 409 Conflict + 冲突详情:
@ExceptionHandler(ConcurrentModificationException.class)
public ResponseEntity<?> handleConflict(ConcurrentModificationException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of(
"error", "VERSION_CONFLICT",
"message", e.getMessage(),
"currentVersion", currentVersion,
"updatedBy", updatedBy
));
}
三、规则差异对比——让运营知道到底冲突在哪
光说"版本冲突"不够,运营不知道 B 到底改了什么。需要把差异展示出来:
/**
* 规则冲突时,返回新旧版本的 diff
*/
@GetMapping("/rule/{id}/diff")
public Map<String, Object> diff(@PathVariable Long id,
@RequestParam int myVersion) {
Rule current = service.findById(id);
RuleEditorLog myEdit = editorLogService.findByRuleIdAndVersion(id, myVersion);
if (myEdit == null) {
return Map.of("message", "你的编辑记录已过期");
}
// 对比两个版本的 rule_content
String myContent = myEdit.getRuleContent();
String currentContent = current.getRuleContent();
return Map.of(
"currentVersion", current.getVersion(),
"myVersion", myVersion,
"updatedBy", current.getUpdatedBy(),
"updatedAt", current.getUpdatedAt(),
"diff", diffService.compare(myContent, currentContent)
);
}
前端收到 diff 后展示:
⚠️ 规则已被【张三】修改,当前版本 V7,你编辑的是 V5
变更内容:
- 满减门槛:100 → 80 ← 你的修改
- 适用商品:全场 → 指定分类 ← 张三的修改
- 优惠金额:20 → 15 ← 张三的修改
请确认是否需要合并后重新提交。
运营能看到 B 改了什么,然后决定是自己手动合并还是放弃修改。这比一个冷冰冰的"版本冲突"有用得多。
四、多表场景:一个活动关联多条规则
实际场景往往是一个活动关联多条规则(满减规则、折扣规则、赠品规则),一条规则又有多个版本快照。总表结构参考:
-- 规则主表
CREATE TABLE qlexpress_rule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
rule_code VARCHAR(64) NOT NULL UNIQUE, -- 规则编码
rule_name VARCHAR(128), -- 规则名称
rule_content TEXT, -- QLExpress 脚本
rule_params JSON, -- 参数定义(JSON)
status VARCHAR(16) DEFAULT 'DRAFT', -- DRAFT/ONLINE/OFFLINE
version INT DEFAULT 0, -- 乐观锁版本号
created_by VARCHAR(64),
updated_by VARCHAR(64),
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW(),
INDEX idx_rule_code (rule_code),
INDEX idx_status (status)
);
-- 编辑历史——每次保存都留一条
CREATE TABLE qlexpress_rule_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
rule_id BIGINT NOT NULL,
version INT NOT NULL,
rule_content TEXT,
rule_params JSON,
changed_by VARCHAR(64),
changed_at DATETIME DEFAULT NOW(),
change_summary VARCHAR(256), -- 操作摘要
INDEX idx_rule_version (rule_id, version)
);
每次保存成功,insert 一条 history:
jdbc.update(
"INSERT INTO qlexpress_rule_history " +
"(rule_id, version, rule_content, rule_params, changed_by, change_summary) " +
"VALUES (?, ?, ?, ?, ?, ?)",
rule.getId(), newVersion, rule.getRuleContent(),
rule.getRuleParams(), rule.getUpdatedBy(), "更新满减门槛"
);
这样任何时候都能回滚到任意历史版本,也能追踪每个版本是谁改的。
五、规则发布:上线才算数
编辑和上线是两步,不是一步。很多系统里"保存"就直接生效了,并发冲突的风险被放大。加一层"草稿 → 发布":
// 状态机
DRAFT → PENDING_REVIEW → ONLINE → OFFLINE
public void publishRule(Long id, int expectedVersion) {
int updated = jdbc.update(
"UPDATE qlexpress_rule SET status = 'ONLINE', version = version + 1 " +
"WHERE id = ? AND version = ? AND status IN ('DRAFT', 'PENDING_REVIEW')",
id, expectedVersion
);
if (updated == 0) {
throw new ConcurrentModificationException("发布失败,规则已被修改或已上线");
}
// 发布成功,推送到 QLExpress 引擎
qlExpressEngine.refreshRule(id);
}
草稿随便改,冲突只发生在草稿阶段。一旦发布上线,只有"下线"操作能改状态,不允许直接覆盖线上规则。
六、冲突率监控
悲观锁是强制排队,乐观锁是允许冲突再解决。乐观锁做得好不好,关键看冲突率:
@Component
public class OptimisticLockMonitor {
private final AtomicLong saveCount = new AtomicLong(0);
private final AtomicLong conflictCount = new AtomicLong(0);
public void recordSave() { saveCount.incrementAndGet(); }
public void recordConflict() { conflictCount.incrementAndGet(); }
@Scheduled(fixedRate = 60_000)
public void report() {
long saves = saveCount.getAndSet(0);
long conflicts = conflictCount.getAndSet(0);
if (saves > 0) {
double rate = (double) conflicts / saves;
log.info("规则编辑冲突率: {}/{} = {:.2f}%", conflicts, saves, rate * 100);
if (rate > 0.1) { // 冲突率超过 10%
log.warn("规则编辑冲突率偏高({:.1f}%), 建议评估是否引入编辑锁", rate * 100);
}
}
}
}
冲突率 < 5%:乐观锁完美适配。
冲突率 5%-10%:可以接受,考虑增加"编辑中"状态提示(页面顶部横幅提示"该规则正在被 XX 编辑")。
冲突率 > 10%:说明多人同时编辑同一个规则太频繁,考虑引入悲观锁(编辑前先"锁定"规则,编辑完释放)、或者拆分规则粒度(一个大规则拆成多个小规则,降低冲突概率)。
七、注意事项
注意一:WebSocket 实时提示。 乐观锁的体验瓶颈是——A 编辑了好几条规则,点了保存才知道冲突。可以加 WebSocket 推送:当 B 保存成功后,给当前正在编辑同一规则的用户推一条"规则已被修改"的通知,A 不用等到点保存才知道。
注意二:version 字段要用包装类型。 MyBatis 如果映射 int version,默认值是 0,但 null 和 0 是两回事。用 Integer version 并且设置 @Column(insertable = false) 让数据库自管理版本号。
注意三:规则内容太大时 diff 的性能。 如果 rule_content 是几千行的 QLExpress 脚本,逐行 diff 会慢。可以只在持久层存内容 hash,冲突时先比较 hash 再决定是否做全文 diff。
注意四:别忘了 QLExpress 引擎本身的规则缓存。 乐观锁保证了数据库里的版本一致,但如果 QLExpress 引擎内部还有一层缓存,保存成功后必须通知引擎刷新。不然数据库里是 V7,引擎执行的还是 V6。
乐观锁不是什么新技术,但在规则引擎这类多人协同编辑的场景里,是最简单有效的保护手段。一个 WHERE version = ? 就能避免"我改的东西去哪了"这类问题——成本极低,收益极大。
你们的规则引擎现在有版本控制吗?运营改规则出过覆盖事故吗?评论区说说。
标题:两个运营同时改了同一个规则,后保存的把先保存的覆盖了
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/24/1782014324118.html
公众号:服务端技术精选
评论