如何实现百万 QPS 下服务本地缓存的同步?

320 阅读23分钟

在电商系统中,商详、购物车等相关业务对查询接口性能要求很高(比如在 20ms 以内)并且访问量 QPS 在几万甚至数十万以上,直接从数据库中查询数据不能满足性能要求,且对数据库压力过大。如果请求二级缓存(如 Redis 等)会有网络开销,同一请求内依赖缓存的种类越多(不同 Key),性能损失可能越大,在极端性能要求的场景下依然不满足条件。因此便会采用将数据缓存到应用本地,以本地缓存数据对外提供查询接口的方案来满足性能。这样虽然满足了性能要求但是也迎来了 数据一致性 的挑战,在这种场景下提供本地缓存查询的服务因为 QPS 很高,为了保证系统的可用性会将流量分摊(分片的思想),服务会采用多实例部署,实例数量一般在几十到几百台不等,“该如何保证这么多服务内的缓存与数据库中数据一致” 便是需要考虑的问题。本篇我们介绍一种在实际业务生产中使用的数据同步机制,遵循的是分布式理论的 AP 定理,为大家在以后在类似的场景下设计时提供参考,同时也能提高大家对 “分布式系统的协调”“数据的最终一致性” 的理解。

1 概览

在进入正文展开技术细节之前,我想先在整体上给大家讲解下缓存服务刷新本地缓存的实现,先对系统间的刷新流程有一个大概的理解。本质上它其实是 一种“无锁”的数据同步机制,采用的是单生产者多消费者的设计(SPMC),由两个应用互相协调来完成:

  • data-sync 应用 负责缓存变更事件的调度
  • cache-provider应用 刷新本地缓存,并对外提供查询服务

下图便是系统间的执行流程:

数据同步流程.png

  1. 首先,当数据发生变更时,data-sync 应用会接收到缓存变更的消息,这时我们便 将消息作为一次缓存变更的事件,记录到数据库的事件表中,默认为 待处理 状态;
  2. 接下来是发送事件,它由分布式定时任务驱动,它会从数据库中将 待处理 的事件查询出来,对事件进行处理,先标记事件状态为 处理中,处理事件它会从数据库查询变更数据并写入二级缓存中,事件处理完成后变更状态为 已发送,并在该类型事件的 版本队列 中记录本次数据变更的版本和需要更新该版本的所有 cache-provider 实例的 IP;
  3. 事件“已发送”后,cache-provider 便需要对事件进行“消费”,对事件的消费采用的是 “拉” 模式,每个 cache-provider 都会开启一个 “本地定时任务” 消费事件,在任务中它会先比对本地缓存和二级缓存中的数据版本,如果发现数据版本落后,那么会尝试获取令牌,获取令牌成功后,更新本地数据,将本机 IP 在待更新的 IP 集合中移除,并更新本地记录的数据版本;
  4. 事件被消费后,会驱动“完成事件”的分布式定时任务,根据事件的 IP 执行情况判断事件的完成状态,如果事件已被所有 cache-provider 消费,会将事件标记为 已完成

事件状态流转的状态机如下:

数据同步流程1.png

2 data-sync “发送事件任务”:处理事件,将数据写入二级缓存

发送事件时 每次只处理一类事件,如下图所示:

数据同步流程2.png

2.1 为什么处理的是 data_type_a 而不是 data_type_b?

考虑一种情况,如果我们每次都是取最新事件,并且这时不断地有事件发生:

数据同步流程3.png

我们会发现 最早进来的事件被“耽搁”了,那这种情况该如何解决?采用 FIFO 先入先出 的算法:

数据同步流程4.png

这样我们就能避免这种情况,也能保证 最早变更的数据最早被同步

2.2 为什么会有“处理中”这种中间状态?

考虑一种场景,如果没有“处理中”这种中间状态:

数据同步流程5.png

先发起的任务的服务没有处理完该任务时,此时又发起了同样的任务,那么这时两台服务器就会都拉取到同一任务,就会 发生同一任务被处理了两遍的情况

添加上“处理中”的状态后,那么再次被发起的任务会处理新的事件,而不会去处理已经被其他服务器正在处理的事件:

数据同步流程6.png

添加 “处理中” 这种中间状态也会引入新的问题:如果在任务执行期间,服务发生了宕机或者操作服务发布,就可能会导致事件状态始终停留在 “处理中” 无法恢复。为了避免这种情况,一是需要在逻辑中添加上 try-catch 代码块保证逻辑执行异常时能回滚到 “待处理” 状态;二是添加分布式定时任务,将处于 “处理中” 且超过若干时间仍为执行完成的任务置为 “待处理” 状态,这样便能保证状态的有效。

2.3 如果发生大批量相同类型事件变更怎么办?

短时间内某类型数据变更多次,那么我们会一个个处理这些事件吗?

数据同步流程7.png

实际上我们会将这些事件汇总在一个 “批次” 中处理,并取 最大的 ID 值作为本次事件处理的“批次版本号”,记录在 batch_id 列中,提高事件处理的效率:

数据同步流程8.png

我们再考虑一种场景,如果在 data_type_a 事件中穿插了一个 data_type_b 的其他类型事件,那这种情况该怎么处理?

数据同步流程9.png

首先我们得先保证“先入先出”,优先处理 data_type_a 类型的事件,那么我们可以先查询此时 “待处理”状态且ID值最小的事件,拿到图中的第一条数据,随后再根据根据事件类型 data_type_a 和“待处理”状态拿到 ID最大(MAX) 的事件,这样就能确定了本次事件的处理范围,根据这个范围处理事件,最后将最大的 ID 值作为批次,这便是 处理事件时完整的FIFO算法实现

数据同步流程10.png


讨论完这些问题,我们现在再回到发送事件的主流程中。我们仍然以 data_type_a 数据变更的事件为例,事件已经在上面的步骤中被修改为“处理中”了,接下来的逻辑便是在数据库中加载数据,压缩 后写入二级缓存:

数据同步流程11.png

为什么是压缩后写入?因为压缩后数据占用空间小,后续由 cache-provider 消费时拉取效率高

在数据写入二级缓存后,我们会变更事件状态为“已发送”,已发送状态的含义是 “发生变更的数据已写入二级缓存”,这里需要注意,我们是在 成功将数据写入二级缓存后 才变更事件状态为已发送,这样便 保证了“已发送”状态的事件,该类型事件的数据一定会被写入 二级缓存 中

数据同步流程14.png

注意变更事件状态为已发送后,又向 REDIS 写入了 版本队列信息需要执行该事件的所有 cache-provider 实例的 IP。这两个键值对的含义我们稍后解释,我们先关注一个问题:

2.4 如果变更事件为已发送后,在 REDIS 中写入版本队列和IP信息失败怎么办?

在这里既变更了数据库,又变更了 REDIS,在没有分布式事务的前提下,就会涉及数据一致性问题,那我们该如何解决呢?

因为我们并不保证数据的强一致性,而是保证最终一致性,所以需要添加 数据一致性的检查机制:在每次发送事件前,将各个类型 已发送 事件的 最大批次版本 从数据库中读取出来,以数据库里的数据为准,如果发现 REDIS 中缓存的值相比于数据库中的批次值较小,那么证明数据不一致,将数据库中的版本值补偿进去就好了。

注意,我们写入 REDIS 的是两个键值对(版本队列和待执行的实例IP),需要保证该步骤的原子性,否则在数据一致性检查时会更加复杂。实现多键值对操作的原子性采用的是 Lua 脚本 的方案。


接下来,我们解释下这两个值的作用:

  • 版本队列信息:每种类型的事件都对应一个版本队列(channel),记录的是该事件类型的批次版本,最新的批次版本会采用“头插法”入队,当队列达到最大时,最老的批次会被从尾节点移除。各个队列记录的批次如图所示,cache-provider 在刷新完对应类型的事件后,会把对应事件类型的批次版本记录到本地,同时它也会拿本地批次版本和队列中的头节点对比,如果版本落后才会去更新该类型数据

数据同步流程15.png

  • 需要执行该事件的所有 cache-provider 实例的 IP:这个键值对记录的是某类型事件、某批次下需要同步该批次刷新的所有 cache-provider 的IP值,以event_data_type_a_2: [ip1, ip2, ..., ip100] 为例,它表示 data_type_a 类型事件在批次2下,需要刷新这个批次数据的所有 cache-provider 实例的IP,每有一个 cache-provider 数据刷新完成后,会将自己的 IP 移除,直到所有的 IP 被移除完,我们通过最终的“移除结果” event_data_type_a_2:[] 来判断事件的完成状态。

数据同步流程16.png

以下为发送事件的部分代码逻辑:

public interface DataEventProcessService {

    /**
     * 发送事件
     */
    void sendEvent();
}

@Slf4j
@Service
public class DataEventProcessServiceImpl implements DataEventProcessService {

    @Resource
    private DataEventMapper dataEventMapper;
    // 访问 Redis
    @Resource
    private CacheRoutingService cacheRoutingService;
    
    @Override
    public void sendEvent() {
        // 1. 校验数据一致性
        checkConsistency();
        // 2. 发送事件逻辑
        doSendEvent();
    }
    
    /**
     * 校验数据库和二级缓存的数据一致性
     */
    private void checkConsistency() {
        List<DataEvent> dataEventList = dataEventMapper.selectSentGroupByEventType();
        for (DataEvent dataEvent : dataEventList) {
            // 将没有写入二级缓存版本队列的批次采用头插法添加进去
            offerChannelFirst(dataEvent);
        }
    }

    /**
     * 发送事件
     */
    private void doSendEvent() {
        long start = System.currentTimeMillis();

        // 获取到待处理事件的 ID 范围,将这些事件作为一个批次发送
        DataEvent minDataEvent = dataEventMapper.selectOldestWaitSend();
        DataEvent maxDataEvent = null;
        try {
            if (minDataEvent != null) {
                maxDataEvent = dataEventMapper.selectMaxEvent(minDataEvent.getEventType(), DataEventStatusEnum.WAIT_PROCESS.getValue());
                Long minId = minDataEvent.getId(), maxId = maxDataEvent.getId();
                String eventType = minDataEvent.getEventType();
                log.info("发送事件 {}---{} 开始", eventType, maxId);

                // 更新该批次事件状态为 处理中,不记录批次
                updateSpecificTypeEventStatus(DataEventStatusEnum.PROCESSING, eventType, minId, maxId, null);

                // 查询数据库中数据的逻辑(使用策略模式),查询完成后压缩并写入二级缓存
                byte[] cacheBytes = dataQueryRoutingService.queryByEventType(eventType);
                if (cacheBytes == null) {
                    log.warn("发送事件 {}---{} 过程中,查询数据为空", eventType, maxId);
                    throw new RuntimeException("数据查询为空");
                }
                log.info("发送事件 {} 数据查询成功 length: {}", eventType + "---" + maxId, cacheBytes.length);
                // 写入二级缓存,数据 Key 为类型,value 为字节数组
                cacheRoutingService.set(eventType, cacheBytes);

                // 更新事件状态为 已发送,并记录批次
                updateSpecificTypeEventStatus(DataEventStatusEnum.SENT, eventType, minId, maxId, maxId);
                log.info("发送事件 {}---{} 数据写入二级缓存成功", eventType, maxId);

                // 更新二级缓存,将本次的批次写入版本队列,如果这步骤执行失败了,去第一步的校验数据一致性里去解决即可
                offerChannelFirst(maxDataEvent);

                // 记录事件发送耗时
                long end = System.currentTimeMillis();
                recordEventTimeTake(maxDataEvent.getEventType(), end - start);
                log.info("发送事件 {}---{} 结束", eventType, maxId);
            }
        } catch (Exception e) {
            log.warn("发送事件 {} 失败,尝试回滚处理中事件状态", minDataEvent.getEventType(), e);
            // 回滚状态为 处理中 的事件为 待处理,避免这批事件不能被发送
            rollbackToWaitProcess(minDataEvent.getEventType(), minDataEvent.getId(), maxDataEvent != null ? maxDataEvent.getId() : null);
        }
    }

    /**
     * 该方法用于向版本队列中添加执行批次
     * 添加批次的逻辑是被添加批次需要大于版本队列的头节点,如果小于头节点,则证明该批次已经落后,不需要再添加
     */
    private void offerChannelFirst(DataEvent dataEvent) {
        // 版本队列和事件IP执行情况 KEY
        String channelKey = String.format(CHANNEL_PREFIX, dataEvent.getEventType());
        String eventIPKey = String.format(EVENT_IPS_PREFIX, dataEvent.getEventType(), dataEvent.getId());

        int count = 0;
        boolean consistent;
        do {
            count++;
            String oldChannelBatchIds = cacheRoutingService.get(channelKey);
            ArrayDeque<Long> oldChannelBatchIdDeque = JSONObject.parseObject(oldChannelBatchIds, new TypeReference<ArrayDeque<Long>>() {});
            // 版本队列还未被写入,初始化版本队列
            if (oldChannelBatchIdDeque == null) {
                oldChannelBatchIdDeque = new ArrayDeque<>();
                cacheRoutingService.set(channelKey, JSONObject.toJSONString(oldChannelBatchIdDeque));

                oldChannelBatchIds = cacheRoutingService.get(channelKey);
                oldChannelBatchIdDeque = JSONObject.parseObject(oldChannelBatchIds, new TypeReference<ArrayDeque<Long>>() {});
            }

            // 如果数据库中已发送事件的最大 ID 值比头节点大,那么证明之前双写写入二级缓存失败,为保证数据一致性
            // 将数据库 ID 值追加到版本队列头节点,并在二级缓存中记录事件被执行的 IP 情况
            Long firstBatchId = oldChannelBatchIdDeque.peekFirst();
            if (firstBatchId == null || dataEvent.getId() > firstBatchId) {
                oldChannelBatchIdDeque.offerFirst(dataEvent.getId());
                // 校正版本队列大小
                while (oldChannelBatchIdDeque.size() > CHANEL_MAX_BATCH_ID_SIZE) {
                    oldChannelBatchIdDeque.pollLast();
                }
                String newChannelBatchIds = JSONObject.toJSONString(oldChannelBatchIdDeque);

                // 获取所有当前活跃的 cache-provider 节点的 IP
                Set<String> allIps = getCurrentLivingIPSet();

                // 将版本队列和事件IP执行情况通过LUA脚本同时写入二级缓存
                consistent = cacheRoutingService.casSetKeyAndSetKey(Arrays.asList(channelKey, eventIPKey),
                        Arrays.asList(oldChannelBatchIds, newChannelBatchIds), JSONObject.toJSONString(allIps));
                if (consistent) {
                    log.info("发送事件 {}---{} 已完成,批次和事件IP执行情况写入成功", dataEvent.getEventType(), dataEvent.getId());
                }
            }
            // 如果数据库中最大 ID 值小于等于头节点,那么证明数据一致,无需操作
            else {
                break;
            }
        } while (!consistent && count < casRetryCount);
    }
}

3 cache-provider 同步二级缓存数据到本地

接下来我们继续看下事件消费的逻辑,以下是 cache-provider 消费事件的简图:

数据同步流程17.png

每台实例分别都启动一个 本地定时任务,指定消费事件的间隔为 60s + randomTime,每次执行事件消费任务时,只处理一类事件。任务启动时更新本机状态为 执行中,避免本次任务未及时处理完,下次任务拉起时发生单机多任务的并行。任务启动时,它首先会将本地缓存的数据版本和 REDIS 中的版本作对比,如果版本落后则尝试获取令牌,获取令牌成功,更新本地缓存(Caffeine),并将 IP 在待更新的 IP 集合中移除,最后更新本地的数据版本,完成事件消费。

3.1 为什么要添加 randomTime?

因为 cache-provider 服务的实例数量比较多,添加 randomTime 时间将任务执行的分散开,避免实例大范围同时获取令牌。

3.2 令牌桶是怎么实现的?

大家可能会有疑问,REDIS 中也没有提供出来令牌桶相关的 API?那这个令牌桶是怎么实现的呢?我们实际上采用一段 LUA 脚本来实现,简单给大家介绍下它的逻辑:定义 最大的令牌数量和令牌恢复速率,令牌桶 通过计算本次操作时间和上次操作的时间的差值乘以令牌恢复速率获得当前令牌数量,但是令牌数量不能超过最大令牌数量,如果令牌数量满足本次操作要获取的令牌数量,那么获取令牌成功,否则获取令牌失败。令牌恢复速率和最大令牌数量这两个值可以进行配置,它的瓶颈在网络带宽上,对于部分比较大的数据,可能不支持多个实例同时拉,如果带宽满足,可以把这两个阈值调得很大。Lua 脚本如下:

-- 定义令牌桶相关的 KEY 前缀
local key = KEYS[1]
-- 表示令牌恢复的速率,比如现在实例执行定时任务的间隔为 60s,每次需要 2 个令牌,那么可以指定 rate 为 1/30(0.03),表示 30s 恢复一个令牌
local rate = tonumber(ARGV[1])
-- 定义令牌桶最大允许令牌数量
local capacity = tonumber(ARGV[2])
-- 本次获取令牌的时间
local now = tonumber(ARGV[3])
-- 本次请求需要的令牌数量
local requested = tonumber(ARGV[4])

-- 剩余的令牌数量
local last_tokens = tonumber(redis.call("GET", key .. ":tokens") or capacity)
-- 上次操作的时间戳
local last_timestamp = tonumber(redis.call("GET", key .. ":timestamp") or now)

-- 两次操作间的时间间隔
local delta = math.max(0, now - last_timestamp)
-- 在剩余令牌数量上累加上时间间隔内恢复的令牌数量,并保证令牌桶数量为整数
local new_tokens = math.min(capacity, math.floor(last_tokens + (delta * rate)))

if new_tokens >= requested then
    redis.call("SET", key .. ":tokens", new_tokens - requested)
    redis.call("SET", key .. ":timestamp", now)
    return 1
else
    return 0
end

额外考虑一种特殊情况,如果某台 cache-provider 实例“运气不好”,它每次消费事件获取令牌时都拿不到,导致它的数据版本非常落后,为了让这台服务器能够快速的跟上数据消费的版本,我们在获取令牌时提供了一种保护机制:针对落后超过2个及以上版本的实例在获取令牌时需要消耗的令牌数更少,比如说正常获取令牌需要 2 个令牌,现在这个比较落后的实例只需要 1 个令牌就行,让它更快的能拿到令牌刷新数据。

3.3 应用重新启动/部署时需要令牌桶来协调吗?

不需要,因为我们在部署或重启时并不会同时操作大部分实例,而是分批次的进行部署,我们只需要保证实例将所有数据加载进来正常提供服务就好,并且它们在读取数据时会 先记录各个类型版本队列的头节点作为本地的数据版本,再读取对应类型的数据,这样的先后顺序能够避免在数据读取过程中 “读取的数据是老的,但版本是新的” 的问题。

3.4 “事件消费异常”是怎么处理的?

事件消费异常是指 某类事件因为某种原因一直不能被消费成功,可能会出现影响其他事件消费的情况,比如说 cache-provider 按照图中 顺序 不断地检查数据版本并消费版本队列中的事件,但消费 data_type_a 时一直不能被消费成功,导致 cache-provider 节点被卡在这个事件上,后续的其他事件得不到消费:

数据同步流程18.png

我们是这样解决的:将所有要刷新的数据类型的 绝对顺序 定义好,并在 cache-provider 的本地内存中记录 CONSUMER_BEGIN_INDEX 作为每次执行任务时获取类型事件的 起始索引,它会在 每次执行完缓存同步时 执行如下操作将该索引值变更:

CONSUMER_BEGIN_INDEX = ++CONSUMER_BEGIN_INDEX % EVENT_SIZE;

数据同步流程19.png

这样便使得在每次遍历事件时,起始索引不同,并且 % 操作能保证环形遍历,这样能在一定程度上保证 事件消费的公平,避免因某类事件的异常而影响其他事件的消费。

3.5 全量刷新本地缓存时如何优雅地让“旧的本地缓存失效”?

数据同步流程20.png

cache-provider 实例准备进行本地缓存刷新时,从二级缓存获取到数据后,该如何将旧本地缓存优雅地替换掉?

  • 能直接让它失效吗?
cacheA.invalidateAll();
cacheA.putAll(newCacheA);

显然不能,因为在缓存失效(invalidateAll)的这个间隙时间内,此时进来的请求会失效,所以先失效再添加的形式不可取。

  • 那能直接向本地缓存中添加所有缓存,执行缓存替换的逻辑可以吗?
cacheA.putAll(newCacheA);

显然也不能,因为如果新的数据中有部分数据是被删除的,那么相当于没有这部分数据的 Key 值,无法在 cacheA 中完成缓存的替换,所以也不可取,那么该如何保证服务可用且优雅的完成缓存更新呢?

数据同步流程21.png

针对这种情况,我们设计了一种“Slave缓存”的方案,如图中所示,处理请求一直由主缓存 cacheA 提供服务,添加 cacheASlave 缓存先将新数据刷新到本地,刷完完成后执行 Swap 交换操作,那么这样我们就能无损的完成新旧数据的变更,这时我们再将旧缓存失效,注意这里使用了 volatile 关键字 保证 Swap 操作的可见性

// Slave 缓存添加新数据
cacheASlave.putAll(newCacheA);
// 执行主备缓存的 Swap 操作
Cache<String, String> cacheTemp = cacheA;
cacheA = cacheASlave;
cacheASlave = cacheTemp;
// 旧缓存数据失效
cacheASlave.invalidateAll();

3.6 事件消费最后将本机IP在待更新IP集合中移除是怎么做的?

数据同步流程22.png

在事件消费完成后,我们会将消费成功的 cache-provider 的IP移除,但是这个 value 值的更新涉及多个实例同时更新,那么该如何保证数据一致性,避免脏写呢?我们采用的是 CAS + 自旋重试,这也是很多开源框架的源代码中广泛采用的机制,我们定义了一段 LUA 脚本:

local currentVal1 = redis.call('GET', KEYS[1]);
if currentVal1 == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2]);
    return 1
else
    return 0
end

如果当前值和读取时的值相同,那么证明这期间没有其他实例更新这个值,那么执行本实例的更新,否则无法进行更新操作,并且我们配置了自旋重试次数来尽力保证数据完成更新。

3.7 如果某类型事件小批次版本事件未消费完又来了该类型的大批次版本事件怎么办?

如下图所示,某类型事件当前只有批次较小的事件待消费,但是这个批次的事件还没被消费完,又来了更大的批次事件待消费,那么我们需要再继续消费完小批次,然后再消费大批次吗?实际上是不需要的,同类型事件只需要消费版本队列头节点最大的批次,这样就能满足数据是最新的。当只有 IP1 消费完批次 1 的事件时,这时又更新了批次 5,那么所有的 cache-provider 节点只需要都去消费批次 5 即可,不需要再去管批次 1:

数据同步流程23.png

以下为刷新本地缓存的逻辑:

@Service
public class CacheRefreshScheduler {

    /**
     * 控制是否刷新的开关,如果关闭则不刷新缓存
     */
    private boolean refreshSwitch;

    /**
     * 每次执行任务时,获取事件的起始索引
     */
    private Integer consumerBeginIndex = 0;
    
    /**
     * 本机执行状态:是否正在执行拉取任务
     */
    private final AtomicBoolean isExecuting = new AtomicBoolean(false);

    @Resource
    private CacheManager cacheManager;
    
    /**
     * 执行刷新任务
     */
    private void refresh() {
        try {
            log.info("缓存刷新任务,开始执行");
            // 获取本机IP
            String localIp = NetUtils.getLocalIp();
            // 1. 维护本机心跳到二级缓存中
            keepHeartBeat(localIp);

            if (!refreshSwitch) {
                log.warn("缓存刷新开关已关闭");
                return;
            }
            // 2. CAS更新当前执行状态:执行中
            if (!isExecuting.compareAndSet(false, true)) {
                log.info("当前正在执行缓存刷新,跳过本次刷新");
                return;
            }
            // 3. 按顺序寻找一个本机数据版本落后于二级缓存的事件
            int eventSize = CacheEventEnum.values().length;
            CacheEventEnum eventToUpdate = null;
            Long remoteVersion = null;
            Long localVersion = null;
            for (int count = 0; count < eventSize; count++) {
                if (log.isInfoEnabled()) {
                    log.info("寻找落后的事件版本,当前指针位置:{}", consumerBeginIndex);
                }
                CacheEventEnum thisEvent = CacheEventEnum.getByLoadOrder(consumerBeginIndex);
                consumerBeginIndex = ++consumerBeginIndex % eventSize;
                // 获取远程版本、本地版本
                remoteVersion = cacheManager.getLatestRemoteVersion(thisEvent.name());
                localVersion = cacheManager.getEventVersion(thisEvent);
                if (remoteVersion > localVersion) {
                    eventToUpdate = thisEvent;
                    if (log.isInfoEnabled()) {
                        log.info("本地缓存版本落后,开始尝试更新。事件:{}, 本地版本:{}, 远程版本:{}", eventToUpdate.name(), localVersion, remoteVersion);
                    }
                    break;
                } else {
                    // 若无需更新,尝试将自己从IP列表中移除
                    cacheManager.markProcessSuccess(thisEvent.name(), remoteVersion);
                }
            }
            if (null == eventToUpdate) {
                if (log.isInfoEnabled()) {
                    log.info("当前本地所有缓存都是最新版本,无需更新");
                }
                return;
            }
            // 4. 尝试获取令牌
            int requestedTokenNum = cacheManager.isCrazy(eventToUpdate.name(), localVersion) ? 1 : 2;
            if (log.isInfoEnabled()) {
                log.info("尝试获取令牌,请求的令牌数量:{}", requestedTokenNum);
            }
            if (!cacheManager.isRequestAllowed(Constants.TOKEN_BUCKET_KEY, requestedTokenNum)) {
                if (log.isInfoEnabled()) {
                    log.info("未获取到令牌,跳过本次刷新");
                }
                return;
            }
            // 5. 刷新缓存,策略模式通过类型获取到具体的 Service
            CacheService cacheService = cacheManager.getByCacheEventEnum(eventToUpdate);
            Assert.notNull(cacheService, String.format("事件:%s 对应的缓存加载类未配置", eventToUpdate));
            cacheService.refreshCache();
            // 6. 将本机IP在事件执行情况的IP列表中移除
            cacheManager.markProcessSuccess(eventToUpdate.name(), remoteVersion);
            // 7. 先更新本地缓存数据,再更新本地数据版本保证 6 一定能将自己的IP移除
            cacheManager.setEventVersion(cacheService.getCacheEventEnum(), remoteVersion);
            if (log.isInfoEnabled()) {
                log.info("缓存刷新任务,执行缓存刷新完成");
            }
        } catch (Exception e) {
            log.error("缓存刷新任务,执行出现异常:", e);
        } finally {
            // 将本机状态更改为未执行
            isExecuting.set(false);
        }
    }
}

4 data-sync “完成事件任务”

现在各个 cache-provider 实例已经将数据加载完成了,接下来便需要我们将事件 由“已发送”状态更改为“已完成”状态,如下图所示:

数据同步流程24.png

分布式定时任务会驱动完成事件的定时任务,查询“已发送”的事件,根据事件类型和批次获取到该事件的IP执行情况,发现其中已经没有IP值,证明事件已经完成,那么更新事件状态为“已完成”。

4.1 如果某台服务器一直不能将自己的 IP 在集合中移除,那么事件状态一直无法变更怎么办?

通过 配置 容错率 来容忍部分 cache-provider 实例没有执行某类型事件,但也能将事件变更为已完成状态。比如配置 5% 的容错率,那么100台中95台完成更新,我们便能将事件更新为已完成,重要的是:事件状态的变更是为了方便观察事件的流转情况,实际并 不影响容错的 5 台服务器继续更新数据,并且我们会对经常执行数据同步失败的 cache-provider 实例进行监控,排查相关问题。

4.2 3.6小节中未被消费完的小批次事件后续怎么处理?

批次 1 的 data_type_a 事件由于有了更大的批次 5 所以不会再被 cache-provider 消费,所以 data_type_a_1 事件 IP 消费情况中的 IP 值不会再变动,那么该事件会始终被卡在“已发送”状态不能流转:

数据同步流程25.png

针对这种情况,增加了 已失效 状态来让事件继续流转,避免始终卡在中间态,完整的状态机流转如下:

数据同步流程26.png

5 data-sync “REDIS1 和 REDIS2 间数据同步任务”

这部分就相对更加容易了,如图所示,我们配置了定时任务将相关缓存数据由主缓存 REDIS1 同步到副缓存 REDIS2 的任务,采用二级缓存双活的容灾机制:

数据同步流程27.png

如果主缓存 REDIS1 发生不可用,可以一键更改配置完成主备切换

总结

以上便是在实际业务生产中实践的分布式服务的数据同步,除了这些核心的流程外,还需要创建事件的运营端列表用于监控事件的状态和各个实例的数据版本。本篇内容也仅仅是为大家提供一些思路,好的技术方案总是在不断的讨论和分析中慢慢演进出来的,而不是一蹴而就,希望能够引起大家的思考,给大家带来一些启发。