# 前言
今年接触了一个策略类手游相关的项目,后端本身计划是使用skynet进行开发的,后来结合项目的时间紧急程度和客户端开发组讨论后决定使用PHP进行快速开发,后期再使用其他语言框架进行拆分业务;综合考虑最后选用了webman作为主要开发框架。
整体项目分为配置服务、http-api服务、websocket服务三大部分,其中配置管理主要是兼容客户端生成的配置数据进行导入导出转换加载,底层使用MySQL进行储存,多服务间使用Redis进行一级缓存,服务进程间使用了基于APCu的共享缓存,后期我将该共享缓存组件化也贡献给了社区。
[https://www.workerman.net/plugin/133](https://www.workerman.net/plugin/133)
# Redis
在游戏开发界实际上使用Redis的情况还是比较多的,我们使用Redis主要还是为了将一些数据缓存共享给各个服务器实例:
~~~
┌─────┐ ┌─────┐
| A | ────────────> service <──────────── | B |
└─────┘ └─────┘
/ | \ / | \
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
| a | | b | | c | ───────> instance <─────── | a | | b | | c |
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘
| | | | | |
1|2 1|2 1|2 ────────> process <──────── 1|2 1|2 1|2
3|4 3|4 3|4 3|4 3|4 3|4
~~~
如图所示,我们A/B为区服,每个区服下可能存在abc不同的服务器实例,他们需要共享相同的区服配置;每个区服各自管理自己的数据库数据区域/数据库实例;每个区服下的服务器实例对于数据库数据的要求是强需求,且为变动较为频繁的数据内容,与web的微服务有区别,所以我们没有使用类似Nacos或者其他配置中心进行处理,从而用更适配当前场景的Redis作为缓存服务。
同时Redis也可以作为用户登录鉴权相关中的一环,也可以为运营相关功能提供一些辅助,比如使用Redis-Stream作为消息队列,处理一些事件通知等。
# 共享内存
在游戏开发中,许多业务都是在内存中进行的计算处理,而我们上述的模式是多进程模式,进程间通讯是一个比较频繁出现的点;一开始解决这个问题是粗暴的将一些固定业务固定在对应的进程上执行,尽可能避免进程间的通讯问题,后来随着业务逐步的扩大,单纯限制业务是没办法完全实现的,这时候有考虑过使用webman的channel;但实际上channel基于socket涉及系统内核态用户态的拷贝等问题,同时受网络影响受限,在一些业务的计算处理上会带来比较高的延迟,包括Redis也同样是这样的问题,我们需要实现数据的零拷贝。
后续我们的目标锁定在了共享内存上,因为共享内存可以轻易的在进程间进行通讯交换,而且不存在深拷贝和网络等问题,效率、性能非常的高,整体微秒级别的响应满足我们的需求;于是我基于PHP的拓展APCu封装了适合我们业务场景的插件包进行使用。
# webman-shared-cache
我们的基础应用实现了定时器来从MySQL数据库读取配置信息,定时器的处理器也在读取数据刷入Redis的同时触发共享内存的更新事件,上层业务通过更新事件的回调出发会将Redis的数据刷入共享内存中,以便当前区服实例的各个进程能够使用。
我们使用缓存的场景很多都是MAP数据,所以我在实现插件的时候特别实现了类似Redis-Hash相关的功能:
* HSet/HGet/HDel/HKeys/HExists
由于我们需要一些自增自减的运算,所以也实现了以下功能点:
* HIncr/HDecr,支持浮点运算
由于APCu的特性所以储存的数据也是支持储存对象数据的;
# webman-shared-cache为何使用锁?
之前我有和社区的同学们聊过,他们不是很理解为什么我在实现插件的时候自己使用了锁,这是因为APCu本身的自行实现了对它自身函数的原子性操作,但我们使用它的时候是在多进程的环境下,每一个进程内存在多次APCu的操作,为了业务的原子性,我们希望这多次的操作要在一个原子性内完成,所以需要一个锁来进行隔离,以免在多进程的环境下被其他进程的操作污染,整体是类似MySQl的事务的:
~~~php
protected static function _HIncr(string $key, string|int $hashKey, int|float $hashValue = 1): bool|int|float
{
$func = __FUNCTION__;
$result = false;
$params = func_get_args();
self::_Atomic($key, function () use (
$key, $hashKey, $hashValue, $func, $params, &$result
) {
$hash = self::_Get($key, []);
if (is_numeric($v = ($hash[$hashKey] ?? 0))) {
$hash[$hashKey] = $result = $v + $hashValue;
self::_Set($key, $hash);
}
return [
'timestamp' => microtime(true),
'method' => $func,
'params' => $params,
'result' => null
];
}, true);
return $result;
}
~~~
比如上述代码,就是一个Hash key的自增操作,我们需要在读取Hash后在写入,读取和写入应为一体的;
原子性执行函数Atomic的实现如下:
~~~php
/**
* 原子操作
* - 无法对锁本身进行原子性操作
* - 只保证handler是否被原子性触发,对其逻辑是否抛出异常不负责
* - handler尽可能避免超长阻塞
* - lockKey会被自动设置特殊前缀#lock#,可以通过Cache::LockInfo进行查询
*
* @param string $lockKey
* @param Closure $handler
* @param bool $blocking
* @return bool
*/
protected static function _Atomic(string $lockKey, Closure $handler, bool $blocking = false): bool
{
$func = __FUNCTION__;
$result = false;
if ($blocking) {
$startTime = time();
while ($blocking) {
// 阻塞保险
if (time() >= $startTime + self::$fuse) {return false;}
// 创建锁
apcu_entry($lock = self::GetLockKey($lockKey), function () use (
$lockKey, $handler, $func, &$result, &$blocking
) {
$res = call_user_func($handler);
$result = true;
$blocking = false;
return [
'timestamp' => microtime(true),
'method' => $func,
'params' => [$lockKey, '\Closure'],
'result' => $res
];
});
}
} else {
// 创建锁
apcu_entry($lock = self::GetLockKey($lockKey), function () use (
$lockKey, $handler, $func, &$result
) {
$res = call_user_func($handler);
$result = true;
return [
'timestamp' => microtime(true),
'method' => $func,
'params' => [$lockKey, '\Closure'],
'result' => $res
];
});
}
if ($result) {
apcu_delete($lock);
}
return $result;
}
~~~
当使用阻塞模式的时候,我们会在当前进程内使用一个while循环来进行阻塞抢占,为了不将当前进程阻塞死,我们还加入了一个保险,由`self::$fuse`提供;
## 注意
这里在实践过程中需要注意的是,Atomic在传入回调函数时切勿再使用匿名函数作为参数值或者是通过use传入一个匿名函数,如:
~~~
$fuc = function() {
// do something
}
Cache::Atomic('test', function () use ($fuc) {
// do anything
})
~~~
APCu底层会对函数参数值或引用参数进行序列化储存,但匿名函数不可以被序列化,所以会抛出一个异常;但你可以通过当前对象的属性值或者静态属性来保存一个匿名函数,然后在Atomic的回调内调用使用。
# 0.4.x版本
由于目前我使用Webman基于SQLite和共享内存在自行实现一个具备RAFT的轻调度服务插件和服务注册与发现插件,所以特此为其完善增加了Channel特性;
Channel可以辅助实现类似Redis-List、Redis-stream、Redis-Pub/Sub的功能。
## Channel
Channel是个特殊的数据格式,他的格式是固定如下的:
~~~
[
'--default--' => [
'futureId' => null,
'value' => []
],
workerId_1 => [
'futureId' => 1,
'value' => []
],
workerId_2 => [
'futureId' => 1,
'value' => []
],
......
]
~~~
它在共享内存中的键默认以**#Channel#**开头。
* **\--default--**是默认储存空间,**workerId\_1/workerId\_2**等是子通道储存空间,命名是由用户代码传入的,这里**建议使用workerman自带的workerId**即可。
* 默认储存空间和子通道储存空间是互斥的,也就是说当存在子通道储存空间时,是不存在--default--的,反之亦然;子通道储存空间是当当前通道存在监听器时生成的,而在监听器产生前,消息会暂存在--default--空间,当监听器创建时,--default--的数据value会被同步到子通道储存空间内,**加入value的队头**。
* 每一个子通道储存空间的value都是拷贝的,存在相同的数据,各自监听器监听各自的子通道储存空间;消息的发布支持向所有子通道发布,也可以指定子通道进行发布。
* 监听器的底层使用了workerman的定时器,区别与workerman的timer,在event驱动下定时器的间隔是0,也就是一个future,而其他的事件驱动是0.001s为间隔。
## 实现一个List
由于监听器创建消费是基于workerId的,我们可以通过不同进程创建相同的workerId的监听器来对同一个子通道进行监听:
1. A进程使用list作为workerId:
~~~
Cache::ChCreateListener('test', 'list', function(string $channelKey, string|int $workerId, mixed $message) {
// TODO 你的业务逻辑
});
~~~
2. B进程也同样创建list的workerId监听器:
~~~
Cache::ChCreateListener('test', 'list', function(string $channelKey, string|int $workerId, mixed $message) {
// TODO 你的业务逻辑
});
~~~
3. 此时Channel test的数据如下:
~~~
[
'list' => [
'futureId' => 1,
'value' => []
],
......
]
~~~
**注意:共享内存中储存的futureId为最后一个监听器创建的futureId;当当前进程需要对监听器进行移除时,请勿使用该数据,对应进程内可以通过`Cache::ChCreateListener()`的返回值获取到当前进程创建的futureId用于移除监听器,不使用共享内存中储存的futureId即可**
4. 这时任意进程通过`Cache::ChPublish('test', '这是一个测试消息', true);`发送消息,或者指定workerId`Cache::ChPublish('test', '这是一个测试消息', true, 'list');`。
## 实现一个Pub/Sub
1. A进程使用workerman的workerId作为workerId:
~~~
Cache::ChCreateListener('test', $worker->id, function(string $channelKey, string|int $workerId, mixed $message) {
// TODO 你的业务逻辑
});
~~~
2. B进程使用workerman的workerId作为workerId:
~~~
Cache::ChCreateListener('test', $worker->id, function(string $channelKey, string|int $workerId, mixed $message) {
// TODO 你的业务逻辑
});
~~~
3. 此时Channel test的数据可能如下:
~~~
[
1 => [
'futureId' => 1,
'value' => []
],
2 => [
'futureId' => 1,
'value' => []
]
]
~~~
4. 这时,任意进程通过`Cache::ChPublish('test', '这是一个测试消息', false);`发送消息即可。
**注:发送消息第三个参数使用false时,如发送时还未创建监听器,消息则不会储存至Channel,即监听后才可存在消息**
## 实现类似Redis-stream
与Pub/Sub相同,只不过发布消息使用`Cache::ChPublish('test', '这是一个测试消息', true);`, 当发布消息指定workerId时,可以实现类似Redis-Stream Group的功能。
**注:这里更复杂的功能可能需要对workerId进行变通,不能简单使用workerman自带的workerId,只需要自行规划好即可**
- 设计模式系列
- 工厂方法模式
- 序言
- Windows程序注册为服务的工具WinSW
- 基础
- 安装
- 开发规范
- 目录结构
- 配置
- 快速入门
- 架构
- 请求流程
- 架构总览
- URL访问
- 容器和依赖注入
- 中间件
- 事件
- 代码层结构
- 四个层次
- 路由
- 控制器
- 请求
- 响应
- 数据库
- MySQL实时同步数据到ES解决方案
- 阿里云DTS数据MySQL同步至Elasticsearch实战
- PHP中的MySQL连接池
- PHP异步非阻塞MySQL客户端连接池
- 模型
- 视图
- 注解
- @SpringBootApplication(exclude={DataSourceAutoConfiguration.calss})
- @EnableFeignClients(basePackages = "com.wotu.feign")
- @EnableAspectJAutoProxy
- @EnableDiscoveryClient
- 错误和日志
- 异常处理
- 日志处理
- 调试
- 验证
- 验证器
- 验证规则
- 扩展库
- 附录
- Spring框架知识体系详解
- Maven
- Maven和Composer
- 构建Maven项目
- 实操课程
- 01.初识SpringBoot
- 第1章 Java Web发展史与学习Java的方法
- 第2章 环境与常见问题踩坑
- 第3章 springboot的路由与控制器
- 02.Java编程思想深度理论知识
- 第1章 Java编程思想总体
- 第2章 英雄联盟的小案例理解Java中最为抽象的概念
- 第3章 彻底理解IOC、DI与DIP
- 03.Spring与SpringBoot理论篇
- 第1章 Spring与SpringBoot导学
- 第2章 Spring IOC的核心机制:实例化与注入
- 第3章 SpringBoot基本配置原理
- 04.SprinBoot的条件注解与配置
- 第1章 conditonal 条件注解
- 第2章 SpringBoot自动装配解析
- 05.Java异常深度剖析
- 第1章 Java异常分类剖析与自定义异常
- 第2章 自动配置Url前缀
- 06.参数校验机制与LomBok工具集的使用
- 第1章 LomBok工具集的使用
- 第2章 参数校验机制以及自定义校验
- 07.项目分层设计与JPA技术
- 第1章 项目分层原则与层与层的松耦合原则
- 第2章 数据库设计、实体关系与查询方案探讨
- 第3章 JPA的关联关系与规则查询
- 08.ORM的概念与思维
- 第1章 ORM的概念与思维
- 第2章 Banner等相关业务
- 第3章 再谈数据库设计技巧与VO层对象的技巧
- 09.JPA的多种查询规则
- 第1章 DozerBeanMapper的使用
- 第2章 详解SKU的规格设计
- 第3章 通用泛型Converter
- 10.令牌与权限
- 第1章 通用泛型类与java泛型的思考
- 常见问题
- 微服务
- demo
- PHP中Self、Static和parent的区别
- Swoole-Cli
- 为什么要使用现代化PHP框架?
- 公众号
- 一键部署微信公众号Markdown编辑器(支持适配和主题设计)
- Autodesigner 2.0发布
- Luya 一个现代化PHP开发框架
- PHPZip - 创建、读取和管理 ZIP 文件的简单库
- 吊打Golang的PHP界天花板webman压测对比
- 简洁而强大的 YAML 解析库
- 推荐一个革命性的PHP测试框架:Kahlan
- ServBay下一代Web开发环境
- 基于Websocket和Canvas实现多人协作实时共享白板
- Apipost预执行脚本如何调用外部PHP语言
- 认证和授权的安全令牌 Bearer Token
- Laradock PHP 的 Docker 完整本地开发环境
- 高效接口防抖策略,确保数据安全,避免重复提交的终极解决方案!
- TIOBE 6月榜单:PHP稳步前行,编程语言生态的微妙变化
- Aho-Corasick字符串匹配算法的实现
- Redis键空间通知 Keyspace Notification 事件订阅
- ServBay如何启用并运行Webman项目
- 使用mpdf实现导出pdf文件功能
- Medoo 轻量级PHP数据库框架
- 在PHP中编写和运行单元测试
- 9 PHP运行时基准性能测试
- QR码生成器在PHP中的源代码
- 使用Gogs极易搭建的自助Git服务
- Gitea
- webman如何记录SQL到日志?
- Sentry PHP: 实时监测并处理PHP应用程序中的错误
- Swoole v6 Alpha 版本已发布
- Proxypin
- Rust实现的Redis内存数据库发布
- PHP 8.4.0 Alpha 1 测试版本发布
- 121
- Golang + Vue 开发的开源轻量 Linux 服务器运维管理面板
- 内网穿透 FRP VS Tailscale
- 新一代开源代码托管平台Gitea
- 微服务系列
- Nacos云原生配置中心介绍与使用
- 轻量级的开源高性能事件库libevent
- 国密算法
- 国密算法(商用密码)
- GmSSL 支持国密SM2/SM3/SM4/SM9/SSL 密码工具箱
- GmSSL PHP 使用
- 数据库
- SQLite数据库的Web管理工具
- 阿里巴巴MySQL数据库强制规范
- PHP
- PHP安全测试秘密武器 PHPGGC
- 使用declare(strict_types=1)来获得更健壮的PHP代码
- PHP中的魔术常量
- OSS 直传阿里腾讯示例
- PHP源码编译安装APCu扩展实现数据缓存
- BI性能DuckDB数据管理系统
- 为什么别人可以是架构师!而我却不是?
- 密码还在用 MD5 加盐?不如试试 password_hash
- Elasticsearch 在电商领域的应用与实践
- Cron 定时任务入门
- 如何动态设置定时任务!而不是写死在Linux Crontab
- Elasticsearch的四种查询方式,你知道多少?
- Meilisearch vs Elasticsearch
- OpenSearch vs Elasticsearch
- Emlog 轻量级开源博客及建站系统
- 现代化PHP原生协程引擎 PRipple
- 使用Zephir编写C扩展将PHP源代码编译加密
- 如何将PHP源代码编译加密,同时保证代码能正常的运行
- 为什么选择Zephir给PHP编写动态扩展库?
- 使用 PHP + XlsWriter实现百万级数据导入导出
- Rust编写PHP扩展
- 阿里云盘开放平台对接进行文件同步
- 如何构建自己的PHP静态可执行文件
- IM后端架构
- RESTful设计方法和规范
- PHP编译器BPC 7.3 发布,成功编译ThinkPHP8
- 高性能的配置管理扩展 Yaconf
- PHP实现雪花算法库 Snowflake
- PHP官方现代化核心加密库Sodium
- pie
- 现代化、精简、非阻塞PHP标准库PSL
- PHP泛型和集合
- 手把手教你正确使用 Composer包管理
- JWT双令牌认证实现无感Token自动续期
- 最先进PHP大模型深度学习库TransformersPHP
- PHP如何启用 FFI 扩展
- PHP超集语言PXP
- 低延迟双向实时事件通信 Socket.IO
- PHP OOP中的继承和多态
- 强大的现代PHP高级调试工具Kint
- PHP基金会
- 基于webman+vue3高质量中后台框架SaiAdmin
- 开源免费的定时任务管理系统:Gocron
- 简单强大OCR工具EasyOCR在PHP中使用
- PHP代码抽象语法树工具PHP AST Viewer
- MySQL数据库管理工具PHPMyAdmin
- Rust编写的一款高性能多人代码编辑器Zed
- 超高性能PHP框架Workerman v5.0.0-beta.8 发布
- 高并发系列
- 入门介绍及安装
- Lua脚本开发 Hello World
- 执行流程与阶段详解
- Nginx Lua API 接口开发
- Lua模块开发
- OpenResty 高性能的正式原因
- 记一次查找 lua-resty-mysql 库 insert_id 的 bug
- 包管理工具OPM和LuaRocks使用
- 异步非阻塞HTTP客户端库 lua-resty-http
- Nginx 内置绑定变量
- Redis协程网络库 lua-resty-redis
- 动态HTML渲染库 lua-testy-template
- 单独的
- StackBlitz在线开发环境
- AI
- 基础概念
- 12312
- 基础镜像的坑
- 利用phpy实现 PHP 编写 Vision Transformer (ViT) 模型
- 语义化版本 2.0.0