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