我目前所在的部門主要是負(fù)責(zé)公司的數(shù)據(jù)相關(guān)的內(nèi)容,可以理解為數(shù)據(jù)統(tǒng)計,做的工作其實也比較復(fù)雜,除了做一些數(shù)據(jù)統(tǒng)計分析業(yè)務(wù)之外,需要做一些基礎(chǔ)服務(wù)的開發(fā);我部門因為內(nèi)部開發(fā)語言并不統(tǒng)一,在這種情況下,項目被動的分成了A\B\C\D等子項目,并沒有將項目合并到一個項目中開發(fā),在這種過程中,被動的接受了SOA這樣的結(jié)構(gòu)。
A項目是一個任務(wù)的調(diào)度分配服務(wù),可以理解為一個大型的腳本/定時執(zhí)行器,有點類似與現(xiàn)在比較流行的serverless函數(shù)服務(wù),向A項目中添加一個任務(wù)函數(shù)或者執(zhí)行腳本,他就會在合適的時候被觸發(fā);由于硬件服務(wù)器并不止有一臺,數(shù)據(jù)庫也并不只有一臺,結(jié)合現(xiàn)在容器化思路,這樣的配置需要很多,如果僅僅是寫在配置文件中,并不能方便運維的統(tǒng)一快速方便的管理,所以我們計劃做一個配置中心;因為我們的A\B\C\D等子項目也并不只有一個實例,他們各自是可以橫向拓展的主體;在這樣的前提下,我們決定引入Nacos/consul等包含了配置管理的服務(wù)注冊/發(fā)現(xiàn)服務(wù)(Nacos/consul都是優(yōu)秀的服務(wù)注冊/發(fā)現(xiàn)服務(wù),選用Nacos是一些額外因素,他們各自有優(yōu)缺點)。
PHP在SOA中扮演了Web業(yè)務(wù)服務(wù)的一個角色,主要是進(jìn)行一些業(yè)務(wù)接口的輸出,但是我們的服務(wù)由于需要高承載量,原本計劃是使用自研的Reactor模型的NIO框架,但是考慮到減少心智負(fù)擔(dān),所以選用了文檔及社群更完善Webman作為開發(fā)框架。
最初我是使用了 Tinywan/nacos 的插件進(jìn)行的業(yè)務(wù)開發(fā),但是我們在使用過程中發(fā)現(xiàn),他的配置監(jiān)聽項是通過Timer創(chuàng)建一個nacos->config->get請求實現(xiàn)的,在Timer間隔期內(nèi)可能變更了配置,也就是極限狀況下存在{Timer interval}的同步延遲,這樣并不符合我司的具體情況,我們要求的服務(wù)變更可能需要更迅速,因為在一些業(yè)務(wù)點我們不能有過多時長的錯誤及業(yè)務(wù)不通暢,但如果僅僅是將{timer interval}的值縮小至ms,那么又會存在對Nacos服務(wù)的過多請求;另外由于我們的業(yè)務(wù)已經(jīng)寫了有一段時間了,累積了大量的config()調(diào)用方式,這時候我們需要考慮怎么樣非侵入的改變這一習(xí)慣或者著一些代碼,于是,我基于 Tinywan/nacos 的思路封裝了適合我們的Nacos客戶端插件 Workbunny/webman-nacos;
配置監(jiān)聽部分我們需要完成以下三個要求
我們在配置中使用yaml文件作為了環(huán)境配置替代了原有的.env文件,并且將yaml文件保存在nacos對應(yīng)的namespace;相當(dāng)于業(yè)務(wù)使用config函數(shù)的時候,config函數(shù)會找到config目錄下對應(yīng)的php文件,PHP文件中又使用yaml函數(shù)去調(diào)用對應(yīng)的yaml文件引入對應(yīng)的值,調(diào)用鏈可以理解為如下:
config() -> /config/X.php -> yaml() -> /x.yaml
這個過程完全可以簡化成config()直接找到config目錄的對應(yīng)php文件,將多個php文件保存至nacos對應(yīng)的namespace下。
基于上述的過程,我最早使用了Timer + Guzzle異步請求 + nacos長輪詢監(jiān)聽 保證 時效性,因為存在多個yaml文件,所以需要對多個yaml文件進(jìn)行監(jiān)聽,如果單純一個配置開一個進(jìn)程有點太奢侈,所以我使用了一個進(jìn)程 + Guzzle異步請求;Nacos監(jiān)聽的長輪詢機制你可以理解為如果有消息,就馬上返回對應(yīng)的配置id,如果沒消息,就一直阻塞到timeout并且返回一個空字符串;考慮到請求會阻塞,為了不影響該進(jìn)程內(nèi)Timer的下一個執(zhí)行周期,我將Timer的間隔時長和長輪詢阻塞時長畫上了等號。
public function onWorkerStart(Worker $worker)
{
$worker->count = 1;
if($this->configListeners){
// 拉取配置項文件
foreach ($this->configListeners as $listener){
list($dataId, $group, $tenant, $configPath) = $listener;
if(!file_exists($configPath)){
$this->_get($dataId, $group, $tenant, $configPath);
}
}
// 創(chuàng)建定時監(jiān)聽
Timer::add($this->longPullingInterval, function (){
$promises = [];
foreach ($this->configListeners as $listener){
list($dataId, $group, $tenant, $configPath) = $listener;
# 初始化文件
if(file_exists($configPath)){
$promises[] = $this->client->config->listenerAsync(
$dataId,
$group,
md5(file_get_contents($configPath)),
$tenant,
$this->longPullingInterval * 1000
)->then(function (ResponseInterface $response) use($dataId, $group, $tenant, $configPath){
if($response->getStatusCode() === 200){
if($response->getBody()->getContents() !== ''){
# 文件通過nacos get并覆蓋寫入本地文件
$this->_get($dataId, $group, $tenant, $configPath);
}
}
},function (GuzzleException $exception){
Log::channel('error')->error($exception->getMessage(), $exception->getTrace());
});
}
}
if($promises){
Utils::settle($promises)->wait();
}
});
}
}
第一版完成后我發(fā)現(xiàn)了一些問題:
為了解決第一個問題,我在_get方法內(nèi)加入了對workers的reload
protected function _get(string $dataId, string $group, string $tenant, string $path)
{
$res = $this->client->config->get($dataId, $group, $tenant);
if(file_put_contents($path, $res, LOCK_EX)){
reload($path);
}
}
function reload(string $file)
{
Worker::log($file . ' update and reload. ');
if(extension_loaded('posix') and extension_loaded('pcntl')){
posix_kill(posix_getppid(), SIGUSR1);
}else{
Worker::reloadAllWorkers();
}
}
第二個問題我使用了Workerman/http-client的異步http客戶端,在使用的過程中還有個 小插曲 ,由于http-client使用了workerman的event-loop,我的項目是在workerman的on回調(diào)生命周期內(nèi),所以可以利用event-loop達(dá)到無阻塞的請求;
public function onWorkerStart(Worker $worker)
{
$worker->count = 1;
if($this->configListeners){
// 拉取配置項文件
foreach ($this->configListeners as $listener){
list($dataId, $group, $tenant, $configPath) = $listener;
if(!file_exists($configPath)){
$this->_get($dataId, $group, $tenant, $configPath);
}
$this->timers[$dataId] = Timer::add($this->longPullingInterval,
function () use($dataId, $group, $tenant, $configPath){
$this->client->config->listenerAsyncUseEventLoop([
'dataId' => $dataId,
'group' => $group,
'contentMD5' => md5(file_get_contents($configPath)),
'tenant' => $tenant
], function (Response $response) use($dataId, $group, $tenant, $configPath){
if($response->getStatusCode() === 200){
if((string)$response->getBody() !== ''){
$this->_get($dataId, $group, $tenant, $configPath);
}
}
}, function (\Exception $exception){
Log::channel('error')->error($exception->getMessage(), $exception->getTrace());
});
});
}
}
}
第三個問題,我基于workerman/timer封裝了一個簡易的能達(dá)到我目的的timer:
<?php
declare(strict_types=1);
namespace Workbunny\WebmanNacos;
use Workerman\Timer as WorkermanTimer;
/**
* 定時器
*
* @desc 對workerman/timer的封裝
* 1.延遲單此執(zhí)行
* 2.立即單次執(zhí)行
* 3.延遲循環(huán)執(zhí)行
* - 延遲與循環(huán)時間不同
* - 延遲與循環(huán)間隔相同
* 4.立即循環(huán)執(zhí)行
* @author chaz6chez
*/
final class Timer {
/** @var array[] 子定時器 */
protected static array $_timers = [];
/**
* 新增定時器
* @param float $delay
* @param float $repeat
* @param callable $callback
* @param ...$args
* @return int|bool
*/
public static function add(float $delay, float $repeat, callable $callback, ... $args)
{
switch (true){
# 立即循環(huán)
case ($delay === 0.0 and $repeat !== 0.0):
$callback(...$args);
return WorkermanTimer::add($repeat, $callback, $args);
# 延遲執(zhí)行一次
case ($delay !== 0.0 and $repeat === 0.0):
return WorkermanTimer::add($delay, $callback, $args, false);
# 延遲循環(huán)執(zhí)行,延遲與重復(fù)相同
case ($delay !== 0.0 and $repeat !== 0.0 and $repeat === $delay):
return WorkermanTimer::add($delay, $callback, $args);
# 延遲循環(huán)執(zhí)行,延遲與重復(fù)不同
case ($delay !== 0.0 and $repeat !== 0.0 and $repeat !== $delay):
return $id = WorkermanTimer::add($delay, function(...$args) use(&$id, $repeat, $callback){
$callback(...$args);
self::$_timers[$id] = WorkermanTimer::add($repeat, $callback, $args);
}, $args, false);
# 立即執(zhí)行
default:
$callback(...$args);
return 0;
}
}
/**
* 移除定時器
* @param int $id
* @return void
*/
public static function del(int $id): void
{
if(
$id !== 0 and
isset(self::$_timers[$id]) and
is_int($timerId = self::$_timers[$id])
){
unset(self::$_timers[$id]);
WorkermanTimer::del($timerId);
}
}
/**
* @return void
*/
public static function delAll(): void
{
self::$_timers = [];
WorkermanTimer::delAll();
}
}
更新于 2022-05-13
在后續(xù)的過程中,我接到很多人的反饋,說nacos的客戶端沒有提供服務(wù)負(fù)載均衡相關(guān)的內(nèi)容,這塊地方我是這樣覺得:
假設(shè)有如下兩個服務(wù)服務(wù):
┌─────┐ ┌─────┐
| 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
假設(shè)我們是A服務(wù)的a實例(簡稱為Aa),需要調(diào)用B服務(wù);從調(diào)用者的角度,我們該如何做負(fù)載均衡?
調(diào)用者Aa分別在1、2、3、4號進(jìn)程中各創(chuàng)建一個nacos-client實例,請保持單例且長連接;
初始化的時候可以基于健康、權(quán)重或者基于metadata的約定等方式對服務(wù)B的實例進(jìn)行選擇連接;
這樣的好處是,不論Aa內(nèi)對于B服務(wù)的請求可以復(fù)用連接,無需重復(fù)創(chuàng)建http連接;
為每一個nacos-client實例創(chuàng)建一個timer,timer負(fù)責(zé)對當(dāng)前實例進(jìn)行健康狀態(tài)檢查;
如果PHP支持線程是最好的,因為基于event-loop的timer如果阻塞了,是會影響當(dāng)前event-loop的;如果是線程去旁置執(zhí)行,則不會影響event-loop,但無傷大雅,只要該使用的連接盡可能的使用長連接,并且做足異常的判斷和處理,實際上相較也沒有太大的差異;
假設(shè)健康狀態(tài)不佳,則將當(dāng)前nacos-client實例中的連接停止,從通過nacos實例列表中挑選一個健康狀態(tài)良好的實例進(jìn)行連接(除了健康狀態(tài),還可以根據(jù)metadata等參數(shù)自定義處理);
因為event-loop實際上在loop中也是順序執(zhí)行,所以不用擔(dān)心在處理連接的時候會有消息正在處理;
可以在metadata提供一些元數(shù)據(jù),交給調(diào)用者自行判斷;也可以直接在提供方進(jìn)行處理后以health的方式交給調(diào)用方直接使用;建議二者選其一。
以上這樣做相較于傳統(tǒng)的輪詢、隨機、加權(quán)輪詢等負(fù)載方案更適合長連接,效率更高,但缺點就是實現(xiàn)方式上較復(fù)雜一些,需要服務(wù)提供方和服務(wù)調(diào)用方實現(xiàn)各自對應(yīng)的處理邏輯;但我個人認(rèn)為,本身在微服務(wù)體系下,整個體系應(yīng)該是一致的,這樣做是一種一勞永逸的做法,并不會出現(xiàn)在一個體系下有多種執(zhí)行方案的情況。
更新于 2022-08-19
為了更好的發(fā)展,我也遷過來,一起維護(hù),不然搞兩套插件不太好!
?????? 為了方便維護(hù)和使用,推薦大家使用最新版的Nacos插件 http://www.wtbis.cn/plugin/50 我也會以后積極參與這個倉庫的貢獻(xiàn)。
不錯的文章 謝謝分享 學(xué)到了
想請教一下大佬,如何通過nacos實現(xiàn)服務(wù)之間的相互調(diào)用呢?可以通過 $client->instance->list 獲取實例列表hosts然后根據(jù)ip端口訪問,但這樣每次請求都要重新去nacos上獲取,還需要單獨實現(xiàn)負(fù)載均衡。如果緩存的話,更新也不及時,感覺不是很好。有什么好的方法實現(xiàn)嗎?有API網(wǎng)關(guān)的話,服務(wù)內(nèi)部之間的調(diào)用也需要走網(wǎng)關(guān)嗎?
一般情況下,服務(wù)和服務(wù)之間大部分情況都是在一個內(nèi)網(wǎng)下,nacos客戶端每個實例都是長連接,這時候每次請求都去nacos上獲取也不會浪費連接數(shù),其實還好;
如果擔(dān)心請求會浪費的話,其實可以和獲取配置信息一樣,起一個定時更新的緩存,一般情況下秒級別的更新實際上夠用了,只要在真正獲取實例并執(zhí)行的地方做好切換、重試機制就好,一般這種情況也只影響一個時間單位內(nèi)的少部分用戶;
至于負(fù)載,每個公司的架構(gòu)實現(xiàn)方式是不同的,所以負(fù)載也是自己實現(xiàn),根據(jù)list的權(quán)重或者自定義的metadata,nacos這部分的靈活性比較高,全部交給用戶自己基于這些內(nèi)容實現(xiàn),其實不難;除了使用方通過list來做負(fù)載外,服務(wù)提供方可以通過注冊和注銷根據(jù)自身限流策略來做熔斷、降級等負(fù)載,其實說白了就是讓不想要的服務(wù)不要出現(xiàn)在list或者從list剔除不想要的服務(wù);
大部分工作其實是要在服務(wù)注冊的時候做好的,比如定義的metadata,再比如是否規(guī)范使用了ephemeral、weight等屬性,這些屬性和自己的負(fù)載策略可以掛勾;另外就是要做好自身的服務(wù)限流和一些實例的更新策略,畢竟一般情況下自身服務(wù)達(dá)到限流的時候,可能需要降級、熔斷等。
webman-nacos-client也在計劃圍繞負(fù)載在下一個版本增加一個比較通用的負(fù)載策略,并且會增加測試用例。
有些人使用了網(wǎng)關(guān)之后,會使用網(wǎng)關(guān)提供的負(fù)載策略,所以有服務(wù)內(nèi)部調(diào)用走網(wǎng)關(guān)的做法,不過我不是很推薦這樣的做法,我認(rèn)為內(nèi)外部需要獨立,內(nèi)部可能會有內(nèi)部的策略,外部會有外部的策略,不應(yīng)耦合在一起。
感謝分享,我也覺得內(nèi)外獨立比較好,網(wǎng)關(guān)也不需要單獨為我的服務(wù)訪問配置白名單了。我這邊目前使用的是定時緩存服務(wù)實例,然后再單獨負(fù)載查找(用的插件是tinywan/load-balancing),沒有走外部的網(wǎng)關(guān),期待webman-nacos-client之后的版本
我推薦的做法也是定時緩存的策略,不過和你的負(fù)載的處理方式可能有些不同;
假設(shè)A服務(wù)需要調(diào)用B服務(wù),我們以A服務(wù)中1號實例舉例;
A-1有4個進(jìn)程,那么就會分別創(chuàng)建4個連接B服務(wù)的client連接對象實例,同時會創(chuàng)建4個負(fù)責(zé)負(fù)載監(jiān)聽的timer分別為各自的client連接進(jìn)行處理以下事務(wù):
注:這個timer如果是通過線程實現(xiàn)的話,效果會更好;目前event-loop的timer中的業(yè)務(wù)邏輯如果阻塞,是會影響當(dāng)前進(jìn)程的其他業(yè)務(wù)的,具體這部分可以了解reactor模型。
更新了一下文章,對負(fù)載部分做了一些解釋
看到了更新,有點疑問,A在一開始準(zhǔn)備長連接的時候,如何判斷需要綁定B的哪個實例呢,除了健康狀態(tài),是否還是需要根據(jù)權(quán)重或者metadata之類的信息做負(fù)載輪詢呢?
其實http連接只要keep-alive,并且自己不釋放掉客戶端實例,就已經(jīng)是長連接了;后續(xù)請求保持當(dāng)前這個連接,但是需要有一個timer定時的對當(dāng)前連接及連接的服務(wù)進(jìn)行檢查,畢竟當(dāng)前連接調(diào)用的服務(wù)實例也有別的服務(wù)正在調(diào)用,很可能因為別人的調(diào)用導(dǎo)致健康狀況不良好,這時候timer的作用就是及時將這個連接中的實例切換。
如果A服務(wù)的實例比B服務(wù)少很多,例如1個實例1個進(jìn)程,那不就全部請求都打在了B的單個實例上了嗎,通過timer檢查健康狀態(tài),如果還需要判斷這些的話,確實就很復(fù)雜了
最大限度的利用連接,不論是timer還是觸發(fā)式的檢查,都是屬于重連/負(fù)載方案;類似的東西,如webman的DB連接也是通過一個timer來做select 1;通俗的來講,這個就是只有一個連接的連接池,畢竟PHP沒有線程,沒辦法通過線程來實現(xiàn)連接池,但作用是一樣的,就是復(fù)用連接;
原因有2:
另外B服務(wù)對應(yīng)提供的服務(wù)不止有A服務(wù)一個服務(wù)進(jìn)行使用,如果A\B\C\D等服務(wù)的調(diào)用方客戶端都是按照上述描述的實現(xiàn)方案,那他們就是一個體系的,那么你只需要實現(xiàn)一個客戶端負(fù)載方案,其他服務(wù)都可以復(fù)用該方案,畢竟不止AB兩個服務(wù),還可能有CDEFG。
為注冊了兩個實例,狀態(tài)都是健康的,但是在獲取實例列表時候hosts為空,點進(jìn)控制臺查看實例為下線狀態(tài)
點擊上線后刷新還是下線的狀態(tài)