设计模式与数据同步:观察者模式监听变更、命令模式执行同步、策略模式选择同步策略

数据同步,说白了就是让多个数据副本保持一致。我在做分布式系统时,这问题几乎天天遇到。你想想看,用户改了密码,缓存没更新,结果用旧密码登录成功——这锅谁来背?

今天咱们聊一个组合打法:观察者模式负责感知变化,命令模式负责执行同步动作,策略模式负责选择怎么同步。三个模式配合,基本能覆盖大部分数据同步场景。

为什么需要三个模式?

一个模式不够吗?我刚开始也这么想。后来发现,数据同步这件事本身就有三个维度:

  • 谁通知我变了?——观察者模式解决
  • 同步动作怎么做?——命令模式封装
  • 用什么策略同步?——策略模式选择

举个例子。你在电商后台修改了商品价格。首先,系统得知道价格变了(观察者)。然后,它要把这个变更同步到搜索引擎、缓存、推荐系统等多个地方(命令)。最后,是实时同步还是批量同步?是全量替换还是增量更新?这就是策略的事了。

核心思路:观察者负责「发现变化」,命令负责「封装操作」,策略负责「决定怎么做」。三者各司其职,互不干扰。

观察者模式:监听数据变更

观察者模式在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次,数据库瞬间不报警了。

三者如何协作?

现在把三个模式串起来。整体流程是这样的:

  1. 数据源发生变化,通知所有观察者
  2. 观察者收到通知,创建命令列表(每个命令对应一个同步动作)
  3. 观察者调用策略对象,传入命令列表和事件数据
  4. 策略决定如何执行这些命令(实时/延迟/批量)

用代码表示就是:

// 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 结构图:三种模式的协作关系

观察者模式 + 命令模式 + 策略模式 协作图 数据源 (Subject) 观察者 (Observer) 通知变更 命令列表 CacheSyncCommand AuditLogCommand SearchIndexCommand 同步策略 RealTimeStrategy DelayedStrategy BatchStrategy 创建命令 选择策略 策略执行命令 → 完成数据同步

实际项目中的坑

这套组合拳看着完美,但落地时有不少细节要注意。我列几个常见的:

问题 原因 解决方案
循环通知 A更新触发B同步,B同步又触发A更新 加版本号或变更来源标记,避免重复触发
命令执行失败 某个同步命令抛异常,后续命令不执行 每个命令加try-catch,失败后记录重试队列
策略切换不及时 运行时切换策略,但已有任务还在用旧策略 用双缓冲策略,新任务用新策略,旧任务继续跑完
观察者内存泄漏 注册了观察者但忘记移除 用弱引用,或者在对象销毁时自动解注册

我曾经踩过一个坑:在C++项目里,观察者回调中直接delete了数据源对象,结果其他观察者还在用这个对象,直接野指针崩溃。后来我规定:观察者回调里绝对不能操作观察者列表本身。要操作,就发一个事件到队列里异步处理。

什么时候用这套方案?

不是所有同步场景都需要三个模式全上。我个人的判断标准是:

  • 只有一个同步目标(比如只更新缓存):观察者模式就够了,不需要命令和策略
  • 多个同步目标,但策略固定(比如总是实时同步):观察者+命令模式即可
  • 多个同步目标,且策略可变(比如根据负载切换同步方式):三个模式全上

说白了,设计模式是工具,不是目的。用多少,看场景复杂度。我见过有人为了用设计模式而用,一个简单的setter都套上观察者模式,结果代码绕了三层,维护起来想骂人。

嗯,今天就聊到这儿。这套组合拳在微服务数据同步、缓存一致性、配置中心变更推送等场景下非常实用。你可以在自己的项目里试试,从最简单的观察者开始,慢慢加上命令和策略,感受一下它们各自解决什么问题。


公众号:蓝海资料掘金营,微信deep3321