11.微服務-分佈式事務最大努力通知型

最大努力通知型


1、Rabbitmq實現延遲隊列
2、使用swoft自帶服務組件
3、將最大努力通知服務放入到swoft

柔性事務解決方案:最大努力通知(定期校對)
在這裏插入圖片描述
實現
業務活動的主動方,在完成業務處理之後,向業務活動的被動方發送消息,被動方需主動響應正確消息,否則根據定時策略,最大努力通知。

業務活動的被動方也可以向業務活動主動方查詢,恢復丟失的業務消息。

約束
被動方的處理結果不影響主動方的處理結果
成本
業務查詢與校對系統的建設成本
適用範圍
對業務最終一致性的時間敏感度低
跨企業的業務活動

方案特點
業務活動的主動方在完成業務處理後,向業務活動被動方發送通知消息(允許消息丟失)
主動方可以設置時間階梯型通知規則,在通知失敗後按規則重複通知,直到通知N次後不主動方提供校對查詢接口給被動方按需校對查詢,用於恢復丟失的業務消息

應用案例
銀行通知、商戶通知等(各大交易業務平臺間的商戶通知:多次通知、查詢校對、對賬文件)

Rabbitmq延遲隊列實現原理:
由於rabbitmq本身並沒有提供延遲隊列的功能,所以需要藉助

1、rabbitmq 可以針對 Queue和Message 設置 x-message-ttl 來控制消息的生存時間,如果超時,消息變爲 dead letter(死信)
2、rabbitmq 的queue 可以配置 x-dead-letter-exchange 和 x-dead-letter-routing(可選)
兩個參數,來控制隊列出現 dead letter 的時候,重新發送消息的目的地

比如:假設設置一條消息存活時間爲10秒鐘,那麼10秒鐘之後,如果沒有消費者消費,這條消息就會變爲dead letter(死信),就會把該消息轉發到指定交換機跟隊列當中,然後被消費

在隊列上指定一個Exchange,則在該隊列上發生如下情況,
1.消息被拒絕(basic.reject or basic.nack),且requeue=false
2.消息過期而被刪除(TTL)
3.消息數量超過隊列最大限制而被刪除
4.消息總大小超過隊列最大限制而被刪除
就會把該消息轉發到指定的一個exchange
同時也可以指定一個可選的x-dead-letter-routing-key,表示默認的routing-key,如果沒有指定,則使用消息的routeing-key(也跟指定的exchange有關,
如果是Fanout類型的exchange,則會轉發到所有綁定到該exchange的所有隊列)

代碼體現
在網關rpc控制器中引入notify連接池

 /**
 * @\Swoft\Rpc\Client\Annotation\Mapping\Reference(pool="notify.pool")
 *
 * @var NotifyInterface
 */

看看bean連接池中的設置,其中的’provider’=> bean(\App\Test\RpcProvider::class)是服務地址提供類,提供由consul服務得到的健康地址

 'notify'   => [
    'class'   => \Swoft\Rpc\Client\Client::class,
    'serviceName'    => 'notify',
    'version'    => '1.0',
    'setting' => [
        'timeout'         => 0.5,
        'connect_timeout' => 1.0,
        'write_timeout'   => 10.0,
        'read_timeout'    => 2,
    ],
    'packet'  => bean('rpcClientPacket'),
    'provider'=> bean(\App\Test\RpcProvider::class) //服務地址提供類
],
'notify.pool'  => [
    'class'  => \Swoft\Rpc\Client\Pool::class,
    'client' => bean('notify'),
    'minActive'   => 50,
    'maxActive'   => 500,
    'maxWait'     => 500,
    'maxWaitTime' => 10,
    'maxIdleTime' => 40,
    'time'=>5
],

現在看看\App\Test\RpcProvider::class這個類的代碼


/**
 * Class RpcProvider
 *
 * @since 2.0
 *
 * @Bean()
 */
class RpcProvider implements ProviderInterface
{
    /**
     * @Inject()
     *
     * @var Agent
     */
    private $agent;
    /**
     * @param Client $client
     *
     * @return array
     * @throws ReflectionException
     * @throws ContainerException
     * @throws ClientException
     * @throws ServerException
     * @example
     * [
     *     'host:port',
     *     'host:port',
     *     'host:port',
     * ]
     */
    public function getList(Client $client): array
    {
        // Get health service from consul
        //$services = $this->agent->services();
        //單個服務獲取自己的地址

        $services = [
            '106.52.210.201:9804'
        ];
        return $services;
    }
}

現在來看getList裏面的serveice方法,是從consul裏面獲取所有的健康地址,這裏可以調用負載均衡的類來調用某一個健康地址,這裏我省略了,直接返回一個可用的地址

public function services(): Response
{
    return $this->consul->get('/v1/agent/services');
}

那consul裏面的地址是怎麼來的,首先得註冊,看看這個consul的配置

<?php

return [
    'consul' => [
        'address' => '127.0.0.1',
        'port'    => 8500,
        'register' => [
            'ID'                =>'payxxx',
            'Name'              =>'payxxx',
            'Tags'              =>['primary'],
            'Address'           =>'106.52.210.201',
            'Port'              =>9805,
            'Check'             => [
                'tcp'      => '106.52.210.201:9801',
                'interval' => '10s',
                'timeout'  => '2s',
            ],
            'Weights'=>[
                'passing'=>5,
                'warning'=>1
            ]
        ],
        'discovery' => [
            'dc' => 'dc1',
            'passing'=>true,
            'tag'=>'primary'
        ]
    ],
];

然後通過這個類把地址註冊到consul服務中

class ConsulProvider {
    const REGISTER_PATH = '/v1/agent/service/register';
    const DISCOVERY_PATH = '/v1/health/service/';
    const KV_PATH='/v1/kv';
    private $address = "http://127.0.0.1";
    private $port = 8500;
    private $registerId = '';
    private $registerName = 'user';
    private $registerTags = [];
    private $registerEnableTagOverride = false;
    private $registerAddress = 'http://127.0.0.1';
    private $registerPort = 88;
    private $registerCheckId = '';
    private $registerCheckName = 'user';
    private $registerCheckTcp = '127.0.0.1:8099';
    private $registerCheckInterval = 10;
    private $registerCheckTimeout = 1;
    private $discoveryDc = "";
    private $discoveryNear = "";
    private $discoveryTag = "";
    private $discoveryPassing = true;

    public function getServiceList(string $serviceName, $config)
    {
        $query = [
            'passing' =>$config['discovery']['passing'],
            'dc'      =>$config['discovery']['dc']
        ];
        if (!empty($config['discovery']['tag'])) {
            $query['tag'] =$config['discovery']['tag'];
        }
        $queryStr    = http_build_query($query);
        $path        = sprintf('%s%s', self::DISCOVERY_PATH, $serviceName);
        $result=$this->Curl_request('http://'.$config['address'].':'.$config['port'].$path."?".$queryStr,'GET');
        $services   = json_decode($result, true);
        $address=[];
        foreach ($services as $key=>$v){
            foreach ($v['Checks'] as $k=>$c){
                //判斷是否是活躍的,並且名稱是想要查詢的服務
                if($c['ServiceName']==$serviceName && $c['Status']=='passing'){
                    $address[$key]['address']=$v['Service']['Address'].':'.$v['Service']['Port'];
                    $address[$key]['Weight']=$v['Service']['Weights']['Passing'];
                }
            }
        }
        return $address;
    }

    /**
     * register service
     *
     * @param array ...$params
     *
     * @return bool
     */
    public function registerService(...$params)
    {
         //$request=new HttpRequest();
         //$request->put('http://'.$params[0]['address'].':'.$params[0]['port'].self::REGISTER_PATH,json_encode($params[0]['register']));
         $result=$this->Curl_request('http://'.$params[0]['address'].':'.$params[0]['port'].self::REGISTER_PATH,'PUT',json_encode($params[0]['register']));
         output()->writeln(sprintf('<success>RPC service register success by consul ! tcp=%s:%d</success>', $params[0]['address'],$params[0]['port']));
    }

    /**
     * @param string $serviceName
     * @return string
     */
    private function getDiscoveryUrl(string $serviceName): string
    {
        $query = [
            'passing' => $this->discoveryPassing,
            'dc'      => $this->discoveryDc,
            'near'    => $this->discoveryNear,
        ];

        if (!empty($this->discoveryTag)) {
            $query['tag'] = $this->discoveryTag;
        }

        $queryStr    = http_build_query($query);
        $path        = sprintf('%s%s', self::DISCOVERY_PATH, $serviceName);
        return      sprintf('%s:%d%s?%s', $this->address, $this->port, $path, $queryStr);
    }

    public  function Curl_request($url, $method = 'POST', $data = [])
    {
        $method = strtoupper($method);
        //初始化
        $ch = curl_init();
        //設置請求地址
        curl_setopt($ch, CURLOPT_URL, $url);
        // 檢查ssl證書
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        // 從檢查本地證書檢查是否ssl加密
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $url);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        //curl_setopt($ch, CURLOPT_HTTPHEADER, "Content-type:application/json;charset=utf-8", "Accept:application/json");
        //設置請求數據
        if (!empty($data)) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        }
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        $res = curl_exec($ch);
        curl_close($ch);
        return $res;
    }

    public function  putKV($data,$config){

        $result=$this->Curl_request('http://'.$config[0]['address'].':'.$config[0]['port'].self::KV_PATH,'PUT',$data);
        var_dump($result);
    }
}

有了可用的地址,就可以調用相關的服務了,業務活動的主動方,在完成業務處理之後,通過rpc調用notifyServeice微服務向業務活動的被動方發送消息

public function order()
{
    //預發送消息(消息狀態子系統)
    $msg_id=session_create_id(md5(uniqid()));
    $data=[
        'msg_id'=>uniqid(),
        'version'=>1,
        'create_time'=>time(),
        'message_body'=>['order_id'=>12133,'shop_id'=>2],
        'notify_url'=>'http://127.0.0.1:9804/notify/index',//通知地址
        'notify_rule'=>[1=>5,2=>10,3=>15],//單位爲秒
        'notify_retries_number'=>0, //重試次數,
        'default_delay_time'=>1,//毫秒爲單位
        'status'=>1, //消息狀態
    ];
    var_dump($this->notifyService->publish($data));
    return ['1'];
}

發佈消息的publish方法,首先設置參數控制(延遲時間),一個緩存隊列和一個延遲隊列,連接rabbitmq,當消息週期達到時,把消息轉移到延遲(死信)隊列中,接下來是緩存隊列的聲明以及綁定隊列名字和交換機,然後是延遲(死信)隊列聲明以及綁定隊列名字和交換機,發佈消息到交換機當中,並且綁定好路由關係

public function publish($data): array
{
    $delayTime = $data['default_delay_time']; //參數控制(延遲時間)
    //緩存的一組交換機及隊列
    $cacheExchangeName = 'cacheExchange_test1_' . $delayTime;
    $cacheQueueName = 'cache_delay_test1_' . $delayTime;
    //延遲隊列
    $delayExchangeName = 'delayExchange';
    $delayRouteKey = '/delay';
    $delayQueueName = 'delay';
    $connection = $this->rabbit->connect();
    $connectionRabbit = $connection->connection;
    $channel = $connectionRabbit->channel(); //建立通道

     //是希望消息的存活週期達到時,把消息轉移到死信隊列當中
    $table = new \PhpAmqpLib\Wire\AMQPTable();
    $table->set('x-dead-letter-exchange', $delayExchangeName); //消息死亡之後轉移到的交換機
    $table->set('x-dead-letter-routing-key', $delayRouteKey); //消息死亡之後綁定的路由key
    $table->set('x-message-ttl', $delayTime);   //設置消息存活時間

     //緩存隊列
    $channel->exchange_declare($cacheExchangeName, \PhpAmqpLib\Exchange\AMQPExchangeType::DIRECT, false, true, false);

    //注意參數位置及如果之前已經生成了隊列,不能覆蓋修改
    $channel->queue_declare($cacheQueueName, false, true, false, false, false, $table);

    $channel->queue_bind($cacheQueueName, $cacheExchangeName, '');

    //死信(延遲)
    $channel->exchange_declare($delayExchangeName, \PhpAmqpLib\Exchange\AMQPExchangeType::DIRECT, false, true, false);
    $channel->queue_declare($delayQueueName, false, true, false, false);
    $channel->queue_bind($delayQueueName, $delayExchangeName, $delayRouteKey);

    $msg = new \PhpAmqpLib\Message\AMQPMessage(json_encode($data), ['delivery_mode' => \PhpAmqpLib\Message\AMQPMessage:: DELIVERY_MODE_PERSISTENT]);
     //發佈消息到交換機當中,並且綁定好路由關係
    if($channel->basic_publish($msg,$cacheExchangeName,'')){
        $res=["status" => 1, "result" => '發佈通知'];
    }else{
        $res=["status" => 1, "result" => '發佈通知'];
    }
    $channel->close();
    $connection->release(true);
    return $res;
}

發佈消息到交換機以後,設置一個監聽事件,定時器去消費延遲隊列,首先獲取延遲任務,連接rabbitmq,聲明並綁定交換機和隊列名稱,調用basic_consume,給最大通知次數+1,如果通知次數超過3次,則返回通知失敗次數過多,把消息存入redis,然後把這個消息從隊列中取出來標記已消費,也就是迴應ack,如果沒有到達最大通知次數,通過協程建立一個http連接,通知(‘106.52.210.201’, 9515)這個地址的商戶,如果返回成功,則顯示通知成功,如果失敗,則修改延遲時間,重新發布消息,繼續投遞延遲任務,最後不管成功失敗,都要回應ack,表示這條消息已經被消費

public function handle(EventInterface $event): void
{
    //延遲幾秒,註冊自身才能調用
    swoole_timer_tick(2000, function () {
        sgo(function () {
            try {
                //自動初始化一個Context上下文對象(協程環境下)
                $context = ServiceContext::new();
                Context::set($context);
                //獲取延遲任務消費
                $delayExchangeName = 'delayExchange';
                $delayRouteKey = '/delay';
                $delayQueueName = 'delay';

                $connection = $this->rabbit->connect();
                $connectionRabbit = $connection->connection;
                $channel = $connectionRabbit->channel(); //建立通道

                $channel->exchange_declare($delayExchangeName, \PhpAmqpLib\Exchange\AMQPExchangeType::DIRECT, false, true, false);
                $channel->queue_declare($delayQueueName, false, true, false, false);
                $channel->queue_bind($delayQueueName, $delayExchangeName, $delayRouteKey);

                //消費者
                $channel->basic_consume($delayQueueName, '', false, false, false, false, function ($message) {
                    $data = json_decode($message->body, true);
                    var_dump($data);
                    //按照階梯投遞
                    $data['notify_retries_number'] += 1;
                    if ($data['notify_retries_number'] >= 3) {
                        //寫入到redis當中
                        var_dump("失敗次數過多");
                        $message->delivery_info['channel']->basic_ack($message->delivery_info['delivery_tag']);
                        return;
                    }

                    //執行業務邏輯(通知商戶),根據url地址通知商戶
                    $cli = new \Swoole\Coroutine\Http\Client('106.52.210.201', 9515);
                    $cli->set([ 'timeout' => 1]);
                    $cli->post($data['notify_url'],$data);
                    if('success'==$cli->body){
                        var_dump("通知成功");
                    }else{
                        //繼續投遞延遲任務
                        $data['default_delay_time']=$data['notify_rule'][$data['notify_retries_number']]*1000;
                        $this->notifyService->publish($data);
                        var_dump("通知失敗繼續投遞");
                    }
                    //迴應ack
                    $message->delivery_info['channel']->basic_ack($message->delivery_info['delivery_tag']);
                });

                 // Loop as long as the channel has callbacks registered
                while ($channel->is_consuming()) {
                    $channel->wait(); //阻塞消費
                }
                /**
                 * @param \PhpAmqpLib\Channel\AMQPChannel $channel
                 * @param \PhpAmqpLib\Connection\AbstractConnection $connection
                 */
                function shutdown($channel, $connection)
                {
                    $channel->close();
                    $connection->close();
                }

                register_shutdown_function('shutdown', $channel, $connection);


            } catch (\Exception $e) {
                var_dump($e->getMessage());
            }
        });
    });
}

然後看看商戶迴應方法,測試用

/**
* @RequestMapping("index")
 * @throws Throwable
 */
public function index(): Response
{

    if(mt_rand(1,2)==1){
        $content='success';
    }else{
        $content='fail';
    }
    return Context::mustGet()
        ->getResponse()
        ->withContentType(ContentType::HTML)
        ->withContent($content);
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章