在这篇文章中,我们将讨论我们如何以及为何开发
Interaction System(以下简称SV)是一个分布式、容错、保证传递的消息系统。 SV 被设计为具有高可扩展性的高负载服务,既可作为在线服务(由 1C 提供),也可作为可部署在您自己的服务器设施上的批量生产产品。
SV使用分布式存储
制定问题
为了清楚地说明我们创建交互系统的原因,我将向您介绍一下 1C 中业务应用程序开发的工作原理。
首先,为那些还不知道我们做什么的人介绍一下我们:) 我们正在制作 1C:企业技术平台。 该平台包括业务应用程序开发工具以及允许业务应用程序在跨平台环境中运行的运行时。
客户端-服务器开发范例
1C上创建的业务应用:企业三级运营
在应用程序代码中,过程和函数的标头必须明确指示代码将在何处执行 - 使用 &AtClient / &AtServer 指令(英语版本中为 &AtClient / &AtServer)。 1C 开发人员现在会纠正我,说指令实际上是
您可以从客户端代码调用服务器代码,但不能从服务器代码调用客户端代码。 这是我们出于多种原因而做出的根本限制。 特别是,因为服务器代码必须以这样的方式编写:无论从客户端还是从服务器调用它,它都以相同的方式执行。 在从另一个服务器代码调用服务器代码的情况下,不存在这样的客户端。 而且因为在服务器代码执行期间,调用它的客户端可以关闭、退出应用程序,并且服务器将不再有任何人可以调用。
处理按钮单击的代码:从客户端调用服务器过程将起作用,从服务器调用客户端过程将不起作用
这意味着,如果我们想从服务器向客户端应用程序发送一些消息,例如,“长时间运行”报告的生成已完成并且可以查看该报告,我们没有这样的方法。 您必须使用技巧,例如,定期从客户端代码轮询服务器。 但这种方法会给系统带来不必要的调用,并且通常看起来不太优雅。
而且还有一个需求,比如当有电话打过来的时候
生产本身
创建消息传递机制。 快速、可靠、有保证的交付,并且能够灵活搜索消息。 基于该机制,实现在 1C 应用程序内运行的信使(消息、视频通话)。
将系统设计为可水平扩展。 必须通过增加节点数量来满足不断增加的负载。
履行
我们决定不将SV的服务器部分直接集成到1C:Enterprise平台中,而是将其实现为一个单独的产品,其API可以从1C应用解决方案的代码中调用。 这样做的原因有很多,其中最主要的一个是我希望能够在不同的 1C 应用程序之间(例如,贸易管理和会计之间)交换消息。 不同的1C应用程序可以运行在不同版本的1C:Enterprise平台上,位于不同的服务器上等。 在这种情况下,将 SV 作为位于 1C 安装“侧面”的独立产品实施是最佳解决方案。
因此,我们决定将 SV 作为一个单独的产品。 我们建议小公司使用我们在云中安装的 CB 服务器 (wss://1cdialog.com),以避免与本地安装和配置服务器相关的管理费用。 大客户可能会发现建议在其设施中安装自己的 CB 服务器。 我们在云 SaaS 产品中使用了类似的方法
应用
为了分配负载和容错能力,我们将部署不是一个 Java 应用程序,而是多个 Java 应用程序,并在它们前面放置一个负载均衡器。 如果您需要在节点之间传输消息,请在 Hazelcast 中使用发布/订阅。
客户端和服务器之间的通信是通过 websocket 进行的。 它非常适合实时系统。
分布式缓存
我们在 Redis、Hazelcast 和 Ehcache 之间进行选择。 现在是 2015 年。 Redis刚刚发布了新的集群(太新了,吓人),有Sentinel,限制很多。 Ehcache不知道如何组装成集群(这个功能稍后出现)。 我们决定使用 Hazelcast 3.4 进行尝试。
Hazelcast 开箱即可组装成集群。 在单节点模式下,它不是很有用,只能用作缓存 - 它不知道如何将数据转储到磁盘,如果丢失唯一的节点,就会丢失数据。 我们部署了多个 Hazelcast,在它们之间备份关键数据。 我们不备份缓存——我们不介意。
对我们来说,Hazelcast 是:
- 用户会话的存储。 每次去数据库查询一个session需要很长时间,所以我们把所有的session都放在Hazelcast中。
- 缓存。 如果您正在查找用户配置文件,请检查缓存。 写入一条新消息 - 将其放入缓存中。
- 应用程序实例之间通信的主题。 该节点生成一个事件并将其放置在 Hazelcast 主题中。 订阅该主题的其他应用程序节点接收并处理该事件。
- 集群锁。 例如,我们使用唯一键创建一个讨论(1C 数据库中的单个讨论):
conversationKeyChecker.check("БЕНЗОКОЛОНКА");
doInClusterLock("БЕНЗОКОЛОНКА", () -> {
conversationKeyChecker.check("БЕНЗОКОЛОНКА");
createChannel("БЕНЗОКОЛОНКА");
});
我们检查了一下,没有频道。 我们拿走了锁,再次检查它,然后创建它。 如果您在获取锁定后不检查锁定,那么另一个线程有可能当时也进行了检查,并且现在将尝试创建相同的讨论 - 但它已经存在。 您无法使用同步或常规 java Lock 进行锁定。 通过数据库——速度慢,对数据库来说很可惜;通过Hazelcast——这就是你所需要的。
选择数据库管理系统
我们在使用 PostgreSQL 以及与该 DBMS 的开发人员合作方面拥有丰富且成功的经验。
PostgreSQL 集群并不容易 - 有
如果您需要扩展关系数据库,这意味着
我们分片的第一个版本假设能够以不同的比例将应用程序的每个表分布在不同的服务器上。 服务器 A 上有很多消息 - 请将此表的一部分移至服务器 B。这个决定只是为了过早优化,因此我们决定将自己限制为多租户方法。
例如,您可以在网站上阅读有关多租户的信息
SV有应用程序和订阅者的概念。 应用程序是业务应用程序(例如 ERP 或会计)及其用户和业务数据的特定安装。 订阅者是代表其应用程序在 SV 服务器中注册的组织或个人。 订阅者可以注册多个应用程序,并且这些应用程序可以相互交换消息。 订户成为我们系统中的租户。 来自多个订阅者的消息可以位于一个物理数据库中; 如果我们看到订阅者已经开始产生大量流量,我们会将其移动到单独的物理数据库(甚至单独的数据库服务器)。
我们有一个主数据库,其中存储了路由表,其中包含有关所有订户数据库位置的信息。
为了防止主数据库成为瓶颈,我们将路由表(和其他经常需要的数据)保存在缓存中。
如果订阅者的数据库开始变慢,我们会将其切成内部分区。 在我们使用的其他项目中
由于丢失用户消息很糟糕,因此我们使用副本来维护数据库。 同步和异步副本的组合允许您在主数据库丢失时确保自己。 仅当主数据库及其同步副本同时发生故障时才会发生消息丢失。
如果同步副本丢失,异步副本将变为同步副本。
如果主数据库丢失,同步副本将成为主数据库,异步副本将成为同步副本。
Elasticsearch 用于搜索
除其他外,由于 SV 也是一个信使,因此它需要快速、方便和灵活的搜索,同时考虑形态学并使用不精确的匹配。 我们决定不再重新发明轮子,而是使用基于该库创建的免费搜索引擎 Elasticsearch
在github上我们发现
“文本”这个词的词根也将被保留。 这种方法允许您在单词的开头、中间和结尾处进行搜索。
整体情况
重复文章开头的图片,但有解释:
- Balancer暴露在互联网上; 我们有 nginx,它可以是任何。
- Java 应用程序实例通过 Hazelcast 相互通信。
- 为了使用网络套接字,我们使用
网状 . - Java 应用程序是用 Java 8 编写的,由捆绑包组成
操作系统 。 这些计划包括迁移到 Java 10 和过渡到模块。
开发与测试
在开发和测试 SV 的过程中,我们发现了我们使用的产品的许多有趣的功能。
负载测试和内存泄漏
每个SV版本的发布都涉及负载测试。 当满足以下条件时,即成功:
- 测试了几天,没有出现服务故障
- 关键操作的响应时间没有超过舒适的阈值
- 与之前版本相比性能下降不超过10%
我们用数据填充测试数据库 - 为此,我们从生产服务器接收有关最活跃订阅者的信息,将其数字乘以 5(消息数、讨论数、用户数)并以此方式进行测试。
我们以三种配置对交互系统进行负载测试:
- 压力测试
- 仅连接
- 订阅者注册
在压力测试期间,我们启动了数百个线程,它们不停地加载系统:编写消息、创建讨论、接收消息列表。 我们模拟普通用户的操作(获取我的未读消息列表、写信给某人)和软件解决方案(传输不同配置的包、处理警报)。
例如,压力测试的一部分如下所示:
- 用户登录
- 请求您未读的讨论
- 50% 的人可能会阅读消息
- 50% 可能发短信
- 下一个用户:
- 有 20% 的机会创建新讨论
- 随机选择其中的任何讨论
- 进去
- 请求消息、用户配置文件
- 创建 XNUMX 条消息,发送给此讨论中的随机用户
- 留下讨论
- 重复20次
- 注销,返回到脚本的开头
- 聊天机器人进入系统(模拟来自应用程序代码的消息传递)
- 有50%的几率创建新的数据交换通道(特别讨论)
- 50% 的可能性向任何现有渠道写入消息
“仅连接”场景的出现是有原因的。 有一种情况:用户已连接系统,但尚未参与其中。 每个用户在早上 09:00 打开计算机,建立与服务器的连接并保持沉默。 这些家伙很危险,他们有很多 - 他们唯一的软件包是 PING/PONG,但他们保持与服务器的连接(他们无法保持连接 - 如果有新消息怎么办)。 测试重现了半小时内大量此类用户尝试登录系统的情况。 它类似于压力测试,但它的重点正是在第一个输入上 - 这样就不会出现故障(一个人不使用该系统,它已经脱落 - 很难想到更糟糕的事情)。
订户注册脚本从第一次启动时开始。 我们进行了压力测试,并确信系统在通信过程中不会变慢。 但用户来了之后,由于超时,注册开始失败。 注册时我们使用
我们用作负载生成器
这就是我们决定开始的地方。
几乎在开始认真测试后,我们立即发现 JMeter 开始泄漏内存。
该插件是一个单独的大故事;拥有 176 颗星,在 github 上有 132 个分支。 作者本人自 2015 年以来一直没有承诺(我们在 2015 年采取了它,然后它没有引起怀疑),几个关于内存泄漏的 github 问题,7 个未关闭的拉取请求。
如果您决定使用此插件进行负载测试,请注意以下讨论:
- 在多线程环境下,使用了常规的LinkedList,结果是
NPE 在运行时。 这可以通过切换到 ConcurrentLinkedDeque 或同步块来解决。 我们为自己选择了第一个选项(https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/43 ). - 内存泄漏;断开连接时,连接信息未删除(
https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/44 ). - 在流模式下(当 websocket 在示例结束时未关闭,但稍后在计划中使用时),响应模式不起作用(
https://github.com/maciejzaleski/JMeter-WebSocketSampler/issues/19 ).
这是 github 上的其中之一。 我们做了什么:
- 已经采取
前叉 Elyran Kogan (@elyrank) – 它解决了问题 1 和 3 - 解决问题2
- 将码头从 9.2.14 更新到 9.3.12
- 在 ThreadLocal 中包装了 SimpleDateFormat; SimpleDateFormat不是线程安全的,导致运行时出现NPE
- 修复了另一个内存泄漏(断开连接时连接被错误关闭)
但它却在流动!
记忆开始耗尽不是一天,而是两天。 绝对没有时间了,所以我们决定启动更少的线程,但在四个代理上。 这应该足够至少一周了。
两天过去了...
现在 Hazelcast 内存不足。 日志显示,经过几天的测试,Hazelcast 开始抱怨内存不足,一段时间后集群崩溃了,节点继续一一死亡。 我们将 JVisualVM 连接到 hazelcast,看到了“锯子”——它定期调用 GC,但无法清除内存。
事实证明,在hazelcast 3.4中,当删除map/multiMap(map.destroy())时,内存并未完全释放:
该错误现已在 3.5 中修复,但这在当时是一个问题。 我们创建了具有动态名称的新 multiMap,并根据我们的逻辑删除了它们。 代码看起来像这样:
public void join(Authentication auth, String sub) {
MultiMap<UUID, Authentication> sessions = instance.getMultiMap(sub);
sessions.put(auth.getUserId(), auth);
}
public void leave(Authentication auth, String sub) {
MultiMap<UUID, Authentication> sessions = instance.getMultiMap(sub);
sessions.remove(auth.getUserId(), auth);
if (sessions.size() == 0) {
sessions.destroy();
}
}
沃兹奥夫:
service.join(auth1, "НОВЫЕ_СООБЩЕНИЯ_В_ОБСУЖДЕНИИ_UUID1");
service.join(auth2, "НОВЫЕ_СООБЩЕНИЯ_В_ОБСУЖДЕНИИ_UUID1");
multiMap 是为每个订阅创建的,并在不需要时删除。 我们决定开始地图,键将是订阅的名称,值将是会话标识符(如果需要,您可以从中获取用户标识符)。
public void join(Authentication auth, String sub) {
addValueToMap(sub, auth.getSessionId());
}
public void leave(Authentication auth, String sub) {
removeValueFromMap(sub, auth.getSessionId());
}
图表有所改善。
关于负载测试我们还了解了什么?
关于我们使用 Hazelcast 的经验
Hazelcast 对我们来说是一个新产品,我们从 3.4.1 版本开始使用它,现在我们的生产服务器运行版本 3.9.2(在撰写本文时,Hazelcast 的最新版本是 3.10)。
ID生成
我们从整数标识符开始。 假设我们需要另一个 Long 来创建一个新实体。 数据库中的序列不合适,表涉及分片 - 原来DB1中有一条消息ID=1,DB1中有一条消息ID=2,你不能把这个ID放在Elasticsearch中,也不能放在Hazelcast中,但最糟糕的是,如果您想将两个数据库中的数据合并为一个(例如,决定一个数据库足以满足这些订阅者的需求)。 你可以向 Hazelcast 添加几个 AtomicLong 并将计数器保留在那里,那么获取新 ID 的性能就是incrementAndGet 加上向 Hazelcast 请求的时间。 但 Hazelcast 有更优化的东西 - FlakeIdGenerator。 当联系每个客户时,他们会得到一个 ID 范围,例如,第一个 - 从 1 到 10,第二个 - 从 000 到 10,等等。 现在,客户端可以自行发出新的标识符,直到向其发出的范围结束。 它工作得很快,但是当您重新启动应用程序(和 Hazelcast 客户端)时,一个新的序列开始 - 因此会跳过等等。 另外,开发人员并不真正理解为什么ID是整数,但又如此不一致。 我们权衡了一切并改用 UUID。
顺便说一句,对于那些想要像 Twitter 一样的人,有这样一个 Snowcast 库 - 这是在 Hazelcast 之上的 Snowflake 实现。 您可以在这里查看:
但我们还没有抽出时间来做这件事。
TransactionalMap.replace
另一个惊喜:TransactionalMap.replace 不起作用。 这是一个测试:
@Test
public void replaceInMap_putsAndGetsInsideTransaction() {
hazelcastInstance.executeTransaction(context -> {
HazelcastTransactionContextHolder.setContext(context);
try {
context.getMap("map").put("key", "oldValue");
context.getMap("map").replace("key", "oldValue", "newValue");
String value = (String) context.getMap("map").get("key");
assertEquals("newValue", value);
return null;
} finally {
HazelcastTransactionContextHolder.clearContext();
}
});
}
Expected : newValue
Actual : oldValue
我必须使用 getForUpdate 编写自己的替换:
protected <K,V> boolean replaceInMap(String mapName, K key, V oldValue, V newValue) {
TransactionalTaskContext context = HazelcastTransactionContextHolder.getContext();
if (context != null) {
log.trace("[CACHE] Replacing value in a transactional map");
TransactionalMap<K, V> map = context.getMap(mapName);
V value = map.getForUpdate(key);
if (oldValue.equals(value)) {
map.put(key, newValue);
return true;
}
return false;
}
log.trace("[CACHE] Replacing value in a not transactional map");
IMap<K, V> map = hazelcastInstance.getMap(mapName);
return map.replace(key, oldValue, newValue);
}
不仅测试常规数据结构,还测试它们的事务版本。 碰巧 IMap 可以工作,但 TransactionalMap 不再存在。
无需停机即可插入新 JAR
首先,我们决定在 Hazelcast 中记录我们类的对象。 例如,我们有一个Application类,我们要保存并读取它。 节省:
IMap<UUID, Application> map = hazelcastInstance.getMap("application");
map.set(id, application);
我们读到:
IMap<UUID, Application> map = hazelcastInstance.getMap("application");
return map.get(id);
一切正常。 然后我们决定在 Hazelcast 中建立一个索引来搜索:
map.addIndex("subscriberId", false);
当编写新实体时,他们开始收到 ClassNotFoundException。 Hazelcast 尝试添加到索引中,但对我们的类一无所知,并且希望向其提供包含此类的 JAR。 我们就是这样做的,一切正常,但是出现了一个新问题:如何在不完全停止集群的情况下更新 JAR? Hazelcast 在逐节点更新期间不会获取新的 JAR。 此时我们决定无需索引搜索即可生存。 毕竟,如果您使用 Hazelcast 作为键值存储,那么一切都会正常吗? 并不真地。 IMap 和 TransactionalMap 的行为再次不同。 如果 IMap 不关心,TransactionalMap 就会抛出错误。
地图。 我们写入 5000 个对象并读取它们。 一切都在预料之中。
@Test
void get5000() {
IMap<UUID, Application> map = hazelcastInstance.getMap("application");
UUID subscriberId = UUID.randomUUID();
for (int i = 0; i < 5000; i++) {
UUID id = UUID.randomUUID();
String title = RandomStringUtils.random(5);
Application application = new Application(id, title, subscriberId);
map.set(id, application);
Application retrieved = map.get(id);
assertEquals(id, retrieved.getId());
}
}
但它在事务中不起作用,我们得到一个 ClassNotFoundException:
@Test
void get_transaction() {
IMap<UUID, Application> map = hazelcastInstance.getMap("application_t");
UUID subscriberId = UUID.randomUUID();
UUID id = UUID.randomUUID();
Application application = new Application(id, "qwer", subscriberId);
map.set(id, application);
Application retrievedOutside = map.get(id);
assertEquals(id, retrievedOutside.getId());
hazelcastInstance.executeTransaction(context -> {
HazelcastTransactionContextHolder.setContext(context);
try {
TransactionalMap<UUID, Application> transactionalMap = context.getMap("application_t");
Application retrievedInside = transactionalMap.get(id);
assertEquals(id, retrievedInside.getId());
return null;
} finally {
HazelcastTransactionContextHolder.clearContext();
}
});
}
3.8中出现了User Class Deployment机制。 您可以指定一个主节点并更新其上的 JAR 文件。
现在我们完全改变了我们的方法:我们自己将其序列化为 JSON 并将其保存在 Hazelcast 中。 Hazelcast 不需要知道我们类的结构,我们可以在不停机的情况下进行更新。 域对象的版本控制由应用程序控制。 不同版本的应用程序可以同时运行,并且可能出现一种情况:新应用程序写入具有新字段的对象,但旧应用程序还不知道这些字段。 同时,新应用程序读取旧应用程序编写的没有新字段的对象。 我们在应用程序中处理此类情况,但为了简单起见,我们不更改或删除字段,我们仅通过添加新字段来扩展类。
我们如何确保高性能
四次访问 Hazelcast - 好,两次访问数据库 - 差
访问缓存获取数据总是比访问数据库更好,但您也不想存储未使用的记录。 我们将缓存内容的决定留到开发的最后阶段。 当新功能编码时,我们打开 PostgreSQL 中所有查询的日志记录(log_min_duration_statement 为 0)并运行负载测试 20 分钟。使用收集的日志,pgFouine 和 pgBadger 等实用程序可以构建分析报告。 在报告中,我们主要寻找缓慢且频繁的查询。 对于慢速查询,我们构建一个执行计划(EXPLAIN)并评估是否可以加速这样的查询。 对相同输入数据的频繁请求非常适合缓存。 我们尝试保持查询“扁平”,每个查询一个表。
开发
SV作为在线服务于2017年春季投入运营,作为独立产品,SV于2017年XNUMX月发布(当时处于测试版状态)。
运行一年多来,CB在线服务运行未出现严重问题。 我们通过以下方式监控在线服务
SV 服务器发行版以本机包的形式提供:RPM、DEB、MSI。 另外,对于 Windows,我们提供单个 EXE 形式的安装程序,用于在一台计算机上安装服务器、Hazelcast 和 Elasticsearch。 我们最初将此版本的安装称为“演示”版本,但现在很明显这是最流行的部署选项。
来源: habr.com