游戏配置二级缓存一致性问题解决方案

时间:2024-02-22 17:44:26

游戏服务器进程在启动的时候,一般会把所有策划配置数据加载到内存里,将主键以及对应的记录存放在一个HashMap容器里,这称为一级缓存。部分功能可能还需要缓存其他数据,这些称为二级缓存。举个例子,对于如下的玩家升级表记录

程序缓存level与ConfigPlayerLevel的对应关系,同时也缓存level与needExp的对应关系。

public class ConfigPlayerLevelStorage implements Reloadable {

	private Map<Integer, ConfigPlayerLevel> levels = new HashMap<>();

	private Map<Integer, Long> level2Exp = new HashMap<>();

	@Override
	public void reload() {
		String sql = "SELECT * FROM ConfigPlayerLevel";
		try {
			List<ConfigPlayerLevel> datas = DbUtils.queryMany(DbUtils.DB_DATA, sql, ConfigPlayerLevel.class);
			levels = datas.stream().collect(Collectors.toMap(ConfigPlayerLevel::getLevel, Function.identity()));
			level2Exp = new HashMap<>();
			levels.forEach((x,y)->{
				level2Exp.put(x, y.getNeedExp());
			});
		} catch (Exception e) {
			LoggerUtils.error("", e);
		}
	}

}

其中的reload()方法,代表程序在运行期间进行配置数据重载(这是运营项目是非常常见的)。这段代码初看起来没什么问题,但在程序运行期间遇到线程并发问题就会出现二级缓存状态不一致问题。假设热更新配置的线程在执行”level2Exp = new HashMap<>();“这行代码,level2Exp的数据已经被清空了,但同时业务线程访问level2Exp这个缓存的时候,就会出现level2Exp是完整数据,而level2Exp是初始数据的情况了。

这个问题的解决思路有两种(通过加锁来分离读写的方式不现实,即麻烦又低效)

1,先保证所有缓存的数据已经完整再切换引用,代码如下

@Override
	public void reload() {
		String sql = "SELECT * FROM ConfigPlayerLevel";
		try {
			List<ConfigPlayerLevel> datas = DbUtils.queryMany(DbUtils.DB_DATA, sql, ConfigPlayerLevel.class);
			Map<Integer, ConfigPlayerLevel> _levels = datas.stream().collect(Collectors.toMap(ConfigPlayerLevel::getLevel, Function.identity()));
			Map<Integer, Long> _level2Exp = new HashMap<>();
			_levels.forEach((x,y)->{
				_level2Exp.put(x, y.getNeedExp());
			});

			this.levels = _levels;       // 临时执行行数1
			this.level2Exp = _level2Exp; // 临时执行行数2

		} catch (Exception e) {
			LoggerUtils.error("", e);
		}
	}

如此修改,即使出现线程并发问题,也能保证一级二级缓存的数据是完整的。

但细心的朋友也发现了,假设热更新线程指定到临时执行代码1的时候,业务线程就开始访问level2Exp的数据,这个时候仍然存在状态不一致的问题。(业务线程访问了热更后的一级缓存和热更前的二级缓存,这种情况虽然不甚完美,但在业务上是容许的)

2,直接替换配置容器。说得高大上一点,就是clojure在处理并发所采用的方式,分离标识与状态(程序在获取一个标识的当前状态,无论将来对这个标识怎样修改,获取的那个状态将不再改变。)

当热更新的时候,我们先重新初始化一个新的配置容器,等所有缓存的数据填充完毕之后,再把原先旧的配置容器整个替换掉。参考代码如下:

public class ConfigDataPool {

	private static ConfigDataPool instance = new ConfigDataPool();
	public static ConfigDataPool getInstance() {
		return instance;
	}

	private ConcurrentMap<Class<?>, Reloadable> datas = new ConcurrentHashMap<>();
	/**
	 * 单表重载
	 * @param configTableName 配置表名称
	 */
	public boolean reload(String configTableName) {
		for (Map.Entry<Class<?>, Reloadable> entry : datas.entrySet()) {
			Class<?> c = entry.getKey();
			if (c.getSimpleName().toLowerCase().indexOf(configTableName.toLowerCase()) >= 0) {
				try {
                    // 初始化新的数据容器
					Reloadable storage = (Reloadable) c.newInstance();
                    // 读取新数据,并缓存
					storage.reload();
                    // 替换旧容器
					datas.put(c, storage);
					return true;
				} catch (Exception e) {
					LoggerUtils.error(c.getName() + "配置数据重载异常", e);
				} 
				break;
			}
		}
		return false;
	}

}

这种方式能完美解决所有缓存的状态一致性问题,可以说无懈可击。