设计模式与数据同步:观察者模式监听变更、命令模式执行同步、策略模式选择同步策略
数据同步,说白了就是让多个数据副本保持一致。我在做分布式系统时,这问题几乎天天遇到。你想想看,用户改了密码,缓存没更新,结果用旧密码登录成功——这锅谁来背?
今天咱们聊一个组合打法:观察者模式负责感知变化,命令模式负责执行同步动作,策略模式负责选择怎么同步。三个模式配合,基本能覆盖大部分数据同步场景。
为什么需要三个模式?
一个模式不够吗?我刚开始也这么想。后来发现,数据同步这件事本身就有三个维度:
- 谁通知我变了?——观察者模式解决
- 同步动作怎么做?——命令模式封装
- 用什么策略同步?——策略模式选择
举个例子。你在电商后台修改了商品价格。首先,系统得知道价格变了(观察者)。然后,它要把这个变更同步到搜索引擎、缓存、推荐系统等多个地方(命令)。最后,是实时同步还是批量同步?是全量替换还是增量更新?这就是策略的事了。
核心思路:观察者负责「发现变化」,命令负责「封装操作」,策略负责「决定怎么做」。三者各司其职,互不干扰。
观察者模式:监听数据变更
观察者模式在Java里太常见了。Swing的监听器、Spring的事件机制,都是它的变体。我习惯把被观察的对象叫做「数据源」,观察者叫做「同步器」。
先看一个简单的数据源定义:
// Java 示例:可观察的数据源
public class DataSource {
private List<DataChangeListener> listeners = new ArrayList<>();
private String data;
public void addListener(DataChangeListener listener) {
listeners.add(listener);
}
public void setData(String newData) {
if (!Objects.equals(this.data, newData)) {
this.data = newData;
notifyListeners(new DataChangeEvent(newData));
}
}
private void notifyListeners(DataChangeEvent event) {
for (DataChangeListener listener : listeners) {
listener.onDataChanged(event);
}
}
}
这里有个细节:setData里先判断值是否真的变了。我曾经见过一个项目,每次set都触发通知,结果循环更新把自己搞死了。嗯,防抖是必要的。
C++版本也类似,不过要注意线程安全:
// C++ 示例:带互斥锁的数据源
class DataSource {
std::vector<std::shared_ptr<DataChangeListener>> listeners_;
std::string data_;
std::mutex mtx_;
public:
void addListener(std::shared_ptr<DataChangeListener> listener) {
std::lock_guard<std::mutex> lock(mtx_);
listeners_.push_back(listener);
}
void setData(const std::string& newData) {
std::lock_guard<std::mutex> lock(mtx_);
if (data_ != newData) {
data_ = newData;
for (auto& listener : listeners_) {
listener->onDataChanged(DataChangeEvent(newData));
}
}
}
};
避坑指南:我曾经在C++项目里忘了加锁,结果多线程同时setData,监听器列表被并发修改,直接crash。后来我养成了习惯:只要涉及观察者列表,一律加锁。
命令模式:封装同步动作
观察者通知了「数据变了」,接下来要执行同步。但同步动作可能很复杂:更新缓存、写日志、发消息队列……每个动作都不一样。命令模式正好派上用场。
我把每个同步动作封装成一个命令对象:
// Java 示例:同步命令接口
public interface SyncCommand {
void execute(DataChangeEvent event);
}
// 具体命令:更新缓存
public class CacheSyncCommand implements SyncCommand {
private CacheService cacheService;
@Override
public void execute(DataChangeEvent event) {
cacheService.update(event.getKey(), event.getValue());
}
}
// 具体命令:写操作日志
public class AuditLogCommand implements SyncCommand {
private AuditLogger logger;
@Override
public void execute(DataChangeEvent event) {
logger.log("Data changed: " + event.getKey() + " -> " + event.getValue());
}
}
这样做的好处是什么?每个命令只做一件事,可以单独测试。我在项目中经常把命令对象序列化后扔到消息队列里,实现异步同步。这样主流程不会被同步操作拖慢。
C++里可以用函数对象或者std::function来实现:
// C++ 示例:使用std::function作为命令
using SyncCommand = std::function<void(const DataChangeEvent&)>;
class SyncExecutor {
std::vector<SyncCommand> commands_;
public:
void addCommand(SyncCommand cmd) {
commands_.push_back(std::move(cmd));
}
void executeAll(const DataChangeEvent& event) {
for (auto& cmd : commands_) {
cmd(event); // 依次执行所有同步命令
}
}
};
注意:命令执行顺序可能影响结果。比如先更新缓存再写日志,和先写日志再更新缓存,效果不同。我建议把「核心同步」命令放在前面,「辅助记录」命令放在后面。
策略模式:选择同步策略
同步策略有很多种:实时同步、延迟同步、批量同步、增量同步……不同场景用不同策略。策略模式就是把这些算法封装起来,让它们可以互相替换。
看一个策略接口:
// Java 示例:同步策略
public interface SyncStrategy {
void sync(DataChangeEvent event, List<SyncCommand> commands);
}
// 实时同步策略
public class RealTimeSyncStrategy implements SyncStrategy {
@Override
public void sync(DataChangeEvent event, List<SyncCommand> commands) {
for (SyncCommand cmd : commands) {
cmd.execute(event); // 立即执行
}
}
}
// 延迟同步策略(5秒后执行)
public class DelayedSyncStrategy implements SyncStrategy {
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@Override
public void sync(DataChangeEvent event, List<SyncCommand> commands) {
scheduler.schedule(() -> {
for (SyncCommand cmd : commands) {
cmd.execute(event);
}
}, 5, TimeUnit.SECONDS);
}
}
// 批量同步策略(攒够10条再执行)
public class BatchSyncStrategy implements SyncStrategy {
private List<DataChangeEvent> buffer = new ArrayList<>();
private static final int BATCH_SIZE = 10;
@Override
public void sync(DataChangeEvent event, List<SyncCommand> commands) {
buffer.add(event);
if (buffer.size() >= BATCH_SIZE) {
for (SyncCommand cmd : commands) {
for (DataChangeEvent e : buffer) {
cmd.execute(e);
}
}
buffer.clear();
}
}
}
你想想看,如果每次用户点一下按钮都实时同步,高并发下系统扛不住。用延迟策略或者批量策略,能大幅降低压力。我在做秒杀系统时,就用批量策略把同步频率从每秒1000次降到了每秒10次,数据库瞬间不报警了。
三者如何协作?
现在把三个模式串起来。整体流程是这样的:
- 数据源发生变化,通知所有观察者
- 观察者收到通知,创建命令列表(每个命令对应一个同步动作)
- 观察者调用策略对象,传入命令列表和事件数据
- 策略决定如何执行这些命令(实时/延迟/批量)
用代码表示就是:
// Java 示例:三者协作
public class SyncObserver implements DataChangeListener {
private List<SyncCommand> commands;
private SyncStrategy strategy;
public SyncObserver(List<SyncCommand> commands, SyncStrategy strategy) {
this.commands = commands;
this.strategy = strategy;
}
@Override
public void onDataChanged(DataChangeEvent event) {
// 观察者收到通知,委托策略执行命令
strategy.sync(event, commands);
}
}
这样设计,每个部分都可以独立替换。想加一个新的同步动作?加一个命令类就行。想换同步策略?换一个策略对象就行。数据源本身完全不知道这些细节。
SVG 结构图:三种模式的协作关系
实际项目中的坑
这套组合拳看着完美,但落地时有不少细节要注意。我列几个常见的:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 循环通知 | A更新触发B同步,B同步又触发A更新 | 加版本号或变更来源标记,避免重复触发 |
| 命令执行失败 | 某个同步命令抛异常,后续命令不执行 | 每个命令加try-catch,失败后记录重试队列 |
| 策略切换不及时 | 运行时切换策略,但已有任务还在用旧策略 | 用双缓冲策略,新任务用新策略,旧任务继续跑完 |
| 观察者内存泄漏 | 注册了观察者但忘记移除 | 用弱引用,或者在对象销毁时自动解注册 |
我曾经踩过一个坑:在C++项目里,观察者回调中直接delete了数据源对象,结果其他观察者还在用这个对象,直接野指针崩溃。后来我规定:观察者回调里绝对不能操作观察者列表本身。要操作,就发一个事件到队列里异步处理。
什么时候用这套方案?
不是所有同步场景都需要三个模式全上。我个人的判断标准是:
- 只有一个同步目标(比如只更新缓存):观察者模式就够了,不需要命令和策略
- 多个同步目标,但策略固定(比如总是实时同步):观察者+命令模式即可
- 多个同步目标,且策略可变(比如根据负载切换同步方式):三个模式全上
说白了,设计模式是工具,不是目的。用多少,看场景复杂度。我见过有人为了用设计模式而用,一个简单的setter都套上观察者模式,结果代码绕了三层,维护起来想骂人。
嗯,今天就聊到这儿。这套组合拳在微服务数据同步、缓存一致性、配置中心变更推送等场景下非常实用。你可以在自己的项目里试试,从最简单的观察者开始,慢慢加上命令和策略,感受一下它们各自解决什么问题。
公众号:蓝海资料掘金营,微信deep3321