缓存设计中的要点

高性能

  • 简洁的通讯协议,快速连接
  • 操作基于内存,快速读写
  • 成熟的数据结构,快速定位
  • 基于牛X的语言实现,快速运行
  • 主从架构,读写分离

高可用

  • 异常监控,自动化应急
  • 数据灾备,快速恢复
  • 主从架构,从服务上位
  • 分布式、集群化
  • 服务自动降级
  • 多级缓存

存储

  • 成熟的数据结构,减少冗余
  • 数据压缩方案,减小数据大小
  • 置换算法:FIFO、LRU、LFU
  • 索引,以空间换时间
  • 数据分片,快捷扩容、无限扩容
  • 一定要设置过期时间!

常见缓存过期策略

策略 优点 缺点
定时过期
每个设置了过期时间的key,都携带一个定时器做倒计时,到期自动删除
立即清除,无空间浪费 定时器占用大量cpu,影响性能
惰性过期
只有当访问的时候,才会判断是否过期,过期则删除
最大化节省cpu 过期数据未被访问时,会占用存储,造成空间浪费
定期过期
每隔一定时间,随机扫描一定数量带有有效期的key,并清除其中已过期的key
前两种的这种方案 难点
需要合理设置 “时间”、 “数量”

常见存储置换策略:FIFO、LRU、LFU

  • FIFO(first in first out)
    • 淘汰最早的数据
  • LRU(least recently used)
    • 淘汰最长时间未被使用的数据
  • LFU(least frequently used)
    • 淘汰使用次数最少的数据

Redis置换策略配置(maxmemory-policy)

  • noeviction:不置换
  • volatile-[ lru | random | ttl ]
    • 对具备有效期的key,按lru/随机/最短置换
  • allkeys-[ lru | random ]
    • 对所有key,按lru/随机置换

一致性

缓存与数据库保持一致
- 低效做法:使用事务,数据库与缓存的更新
- 符合ACID
- 业务普遍做法:先更新数据库,再删除缓存
- 严谨但复杂的做法:消息队列、订阅binlog

旁路缓存原则:

读操作:
- 先读缓存
- 如果命中,直接返回
- 如果未命中,访问DB,并写入缓存

写操作:
- 删缓存,而不是更新缓存
- 先写DB,再删缓存

问题:
以下方式,存在什么隐患?
- 先更新缓存,再更新数据库
- 先删除缓存,再更新数据库
- 先更新数据库,再更新缓存

防雪崩

高并发时失效
雪崩:在用户高并发瞬间,如果缓存不可用(失效),用户的请求压力,都转到数据库,导致数据库挂掉,并最终导致整个系统挂掉。

如何防止缓存雪崩的发生?

  • 缓存服务高可用
    • 服务挂了,做什么都没用
  • 多个缓存不能同时过期
    • 随机失效,而不是同时或定点
  • 多级缓存
    • 多级缓存,尽可能把数据库挡在后面
  • 使用互斥锁
    • 只有拿到锁的请求,才能查库
  • 排队限流
    • 控制查库的并发峰值
  • 提前预热
    • 预先更新缓存
  • 公用性质缓存更应重视
    • 如:首页、推荐位
    • 个性化数据相对风险小
// 业务中改成这样是否有问题
function getDataBuyKey($strKey) {
    // 从缓存中取数据
    $arrData = getCacheData($strKey);
    // 如果未命中缓存,或缓存即将失效,抢占锁
    if (!$arrData || $arrData['ttl'] - time() < 10) {
        // 如果抢锁成功(带有效期的锁),可以查库
        if (redis::set('lock_' . $strKey, 1, array('nx', 'ex' => 10))) {
            $arrData = getDbData($strKey); // 查库
            setCacheData($arrData); // 更新缓存
        }
    }
    return $arrData;
}

防穿透

被故意不命中
用户伪造大量请求,故意不命中缓存,当这些请求集中转到数据库时,导致数据库挂掉,并最终导致整个系统挂掉。

如何防止缓存穿透的发生?

  • 缓存空值
    • 额外缓存一份空值,以防反复查库
  • 缓存数据范围
    • 如,应用ID范围、页码范围、时间段
  • 预先使用布隆过滤(bloom filter)
    • 一定不存在的数据,请求将被拦截
function getDataBuyKey($strKey) {
    $arrData = [];
    // 先执行key的有效验证
    if (checkKey($strKey)) {
        // 从缓存中取数据,未取到,返回null
        $arrData = getCacheData($strKey);
    }
    // 如果未命中缓存,或者缓存即将失效,抢占锁
    if (is_null($arrData) || $arrData['ttl'] - time() < 10) {
        // 如果抢锁成功(带有效期的锁),可以查库
        if (redis::set('lock_' . $strKey, 1, array('nx', 'ex' => 10))) {
            $arrData = (array)getDbData($strKey); // 查库
            setCacheData($arrData); // 更新缓存,若为空值,写入"[]"
        }
        is_null($arrData) && $arrData = []
    }
    return $arrData; // 返回空数据,或即将失效的数据,或最新的数据
}

cookie中的domain设置

项目中遇到这样一个问题:用户登录后,跳转到该域名下的其他页面时,cookie的值改变了。由于是在app中,只能抓包,看到请求header头中有两个同名cookie。而在服务端,超全局变量cookie数组,只取了第一个cookie。由于项目变动,重新设置cookie时只设置了当前域名下的cookie,所以旧的cookie永远无法清除。

这种问题,其实如果基础好一点,对cookie足够了解,是很快能够解决的。由于很久不写这些基础,所以还是花了很长时间。因为当时无法确定cookie的domain和path,所以只能靠猜去清除cookie。

虽然通过git log找到原先设置cookie的域名成功清除了旧cookie,但是如果早点看资料,应该并不难解决。(看文档胜过一切)

对于cookie只需要牢记两点:设置范围作用域
- 设置范围:只能设置当前域名以及父域名(无法设置子域或者同级域名)
- 作用域:当前域名以及所有子域名

举个简单的例子:
对于域名 test.com,在访问a.test.com时,只能设置domain为a.test.com或者test.com;如果设置domain为a.test.com,则child.a.test.comtool.child.a.test.com等都是能够访问到该cookie的。而b.test.com是无法访问到的。

如果设置domain为test.com,则a.test.com以及test.com下的所有子域名都能访问下,这意味着它的同级域名b.test.com也能够访问到。有什么用呢?

这样可以用来解决sso单点登录或者跨域cookie等问题。当然,现在的单点登录一般是通过返回的ticket凭证到服务端做校验去获取信息。

比如,对于a.test.comb.test.com两个项目,公用user.test.com用户系统做单点登录,可以通过设置domain为test.com,在user退出的时候清除根域名下的cookie以达到协同退出的功能。

回到上面的问题:所以在当前域名无法清除cookie的时候,只可能是它的上级域名设置了cookie。而对于path,一般默认情况下是 / 根目录。

参考资料:cookie设置域名问题,cookie跨域

php的几种运行模式CLI、CGI、FastCGI、mod_php

常用运行模式

CLI

CLI:命令行,可以在控制台或者shell中输入命令获取输出,没有header头信息

CGI

公共网关接口(Common Gateway Interface/CGI):是一种重要的互联网技术,可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。CGI描述了服务器和请求处理程序之间传输数据的一种标准。即web server将用户请求以消息的方式转交给PHP独立进程,PHP与web服务之间无从属关系。

FastCGI

快速通用网关接口(Fast Common Gateway Interface/FastCGI):是一种让交互程序与Web服务器通信的协议。FastCGI是早期通用网关接口(CGI)的增强版本。

CGI 和 FastCGI 是一种通信协议规范,不是一个实体

CGI 程序和FastCGI程序,是指实现这两个协议的程序,可以是任何语言实现这个协议的。(PHP-CGI 和 PHP-FPM就是实现FastCGI的程序)

FastCGI和CGI的区别

  • CGI每接收一个请求就要fork一个进程处理,只能接收一个请求作出一个响应。请求结束后该进程就会结束。
  • FastCGI会事先启动起来,作为一个cgi的管理服务器存在,预先启动一系列的子进程来等待处理,然后等待web服务器发过来的请求,一旦接受到请求就交由子进程处理,这样由于不需要在接受到请求后启动cgi,会快很多。
  • FastCGI使用进程/线程池来处理一连串的请求。这些进程/线程由FastCGI服务器管理,而不是Web服务器。 当进来一个请求时,Web服务器把环境变量和这个页面请求通过一个Socket长连接传递给FastCGI进程。FastCGI像是一个常驻型的CGI,它可以一直执行,在请求到达时不会花费时间去fork一个进程来处理(这是CGI对位人诟病的fork-and-execute模式)。正是因为它只是一个通信协议,它还支持分布式的运算,即FastCGI程序可以在网站服务器以外的主机上执行并且接受来自其他网站服务器的请求。

FastCGI整个流程

  1. Web server启动时载入FastCGI进程管理器
  2. FastCGI自身初始化,启动多个CGI解释器进程(可见多个php-cgi)并等待来自Web server的请求
  3. 当请求Web server时,Web server通过socket请求FastCGI进程管理器,FastCGI进程管理器选择并连接到一个CGI解释器,Web server将CGI环境变量和标准输入发送到FastCGI子进程php-cgi
  4. FastCGI子进程处理请求完成后将标准输出和错误从同一连接返回给Web server,当FastCGI子进程结束后请求便结束。FastCGI子进程接着等待处理来自FastCGI进程管理器的下一个连接,在CGI模式中,php-cgi在此便退出了。

PHP-FPM:PHP的FastCGI进程管理器

PHP-CGI 和 PHP-FPM的区别

php-cgi与php-fpm一样,也是一个fastcgi进程管理器

php-cgi的问题在于:
- php-cgi变更php.ini配置后需重启php-cgi才能让新的php-ini生效,不可以平滑重启
- 直接杀死php-cgi进程,php就不能运行了。

PHP-FPM和Spawn-FCGI就没有这个问题,守护进程会平滑从新生成新的子进程。针对php-cgi的不足,php-fpm应运而生。

PHP-FPM 的管理对象是php-cgi。使用PHP-FPM来控制PHP-CGI的FastCGI进程

mod_php(传统模式)

apache的php模块,将PHP做为web-server的子进程控制,两者之间有从属关系。

最明显的例子就是在CGI模式下,如果修改了PHP.INI的配置文件,不用重启web服务便可生效,而模块模式下则需要重启web服务。

以mod_php模式运行PHP,意味着php是作为apache的一个模块来启动的,因此只有在apache启动的时候会读取php.ini配置文件并加载扩展模块,在apache运行期间是不会再去读取和加载扩展模块的。如果修改php的配置,需要重启apache服务

Apache的工作模式 prefork的工作原理

一个单独的控制进程(父进程)负责产生子进程,这些子进程用于监听请求并作出应答。

Apache总是试图保持一些备用的 (spare)或是空闲的子进程用于迎接即将到来的请求。这样客户端就无需在得到服务前等候子进程的产生。

在Unix系统中,父进程通常以root身份运行以便邦定80端口,而 Apache产生的子进程通常以一个低特权的用户运行。User和Group指令用于配置子进程的低特权用户。运行子进程的用户必须要对他所服务的内容有读取的权限,但是对服务内容之外的其他资源必须拥有尽可能少的权限。

Apache的工作模式 worker的工作原理

每个进程能够拥有的线程数量是固定的。服务器会根据负载情况增加或减少进程数量。

一个单独的控制进程(父进程)负责子进程的建立。每个子进程能够建立ThreadsPerChild数量的服务线程和一个监听线程,该监听线程监听接入请求并将其传递给服务线程处理和应答。

nginx默认是使用的fastcgi模式,可以配合fpm使用

js和php闭包的使用和区别

匿名函数

如果只是省去函数名,单纯的当作一个函数式方法返回,只能称为匿名函数(闭包需要将匿名函数当作结果返回),比如:

// js
var foo = function(x, y) {
    return x + y ;
};
console.log(foo(1, 2));  // 3
<?php
// php
$foo = function($a, $b) {
    return $a + $b;
}; // 一定要加分号
echo $foo(1, 2); // 3

闭包

闭包通常是用来创建内部变量,使得这些变量不得被外部随意修改,而只能通过指定的函数接口去修改

js闭包

这里举一个阮老师博客里的例子,阮老师博客:学习Javascript闭包(Closure)

js基础

参考链接:深入理解JS中声明提升、作用域(链)和this关键字

  • js比较特殊的一点是:函数内部可以直接读取到全局变量(对于阮老师的这句话不是很能理解,大概是想表达的意思:父作用域的变量可以在子作用域直接访问,而不需要去声明访问真正的全局变量?)
    • 大部分语言,变量都是先声明在使用,而对于js,具有声明提升的特性(不管在哪里声明,都会在代码执行前处理)
    • 函数和变量的声明总是会隐式地被移动到当前作用域的顶部,函数的声明优先级高于变量的声明
    • var 会在当前作用域声明一个变量,而未声明的变量,会隐式地创建一个全局变量
// 声明提升
console.log(a);  // 1, 未报错
var a = 1;
// 上文链接中的例子
function testOrder(arg) {
    console.log(arg); // arg是形参,不会被重新定义
    console.log(a); // 因为函数声明比变量声明优先级高,所以这里a是函数
    var arg = 'hello'; // var arg;变量声明被忽略, arg = 'hello'被执行
    var a = 10; // var a;被忽视; a = 10被执行,a变成number
    function a() {
        console.log('fun');
    } // 被提升到作用域顶部
    console.log(a); // 输出10
    console.log(arg); // 输出hello
}; 
testOrder('hi');
/* 输出:
hi 
function a() {
        console.log('fun');
    }
10 
hello 
*/
// 全局作用域
var foo = 42;
function test() {
    // 局部作用域
    foo = 21;
}
test();
foo; // 21
// 全局作用域
foo = 42;
function test() {
    // 局部作用域
    var foo = 21;
}
test();
foo; // 42
  • js变量的查找是从里往外的,直到最顶层(全局作用域),并且一旦找到,即停止向上查找。所有内部函数可以访问函数外部的变量,反之无效
function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log(a, b, c);
    }
    bar(b * 3);
}
foo(2);
function foo() {
    var a = 1;
}
console.log(a);  //a is not defined
function foo1() {
    var num = 0;
    addNum = function() {  // 这里未通过var去声明,默认是全局变量
        num += 1;
    };
    function foo2() {
        console.log(num);
    }
    return foo2;
}
var tmp = foo1();
tmp();  // 0

addNum();
tmp(); // 1

这里第二次调用foo2函数,foo1函数的局部变量num并没有被初始化为0,说明打印的是内存中的num。正常函数在每次调用结束后都会销毁局部变量,在重新调用的时候会再次声明变量;而这边没有重新声明的原因是:把foo2函数赋值给了一个全局变量tmp,导致foo2函数一直存在内存中,而foo2函数依赖于foo1函数存在,所以foo1函数也存在内存中,并没有被销毁,所以foo1的局部变量也是存在内存中。

  • this的上下文基于函数调用的情况。和函数在哪定义无关,而和函数怎么调用有关。
    • 在全局上下文(任何函数以外),this指向全局对象(windows)
    • 在函数内部时,this由函数怎么调用来确定
      • 当函数作为对象方法调用时,this指向该对象

下面是阮老师博客里的两个思考题:

var name = "The Window";
var object = {
  name : "My Object",
  getNameFunc : function(){
    return function(){
      return this.name;
    };
  }
};
alert(object.getNameFunc()()); // The Window
var name = "The Window";
var object = {
    name : "My Object",
  getNameFunc : function(){
      var that = this;
    return function(){
        return that.name;
    };
  }
};
alert(object.getNameFunc()());// My Object

this的作用域好像一直是个比较奇怪的东西,对于上面两个例子,我的理解是:第一个例子,是在方法里调用的this,而这个this并没有声明,会隐式地创建一个全局变量,所以调用的全局的name;第二个,调用的that的时候,会向顶级链式查找是否声明that,而这个that有this赋值,这里的this又是通过对象方法调用,则该this指向这个object对象,所有最终调用的是object作用域内的name。不知道这么理解是不是有问题,还望大神指正。

那其实js闭包的主要目的:访问函数内部的局部变量,即延长作用域链
参考链接:js闭包MDN文档

php闭包

php回调函数

mixed call_user_func ( callable $callback [, mixed $parameter [, mixed $... ]] )
mixed call_user_func_array ( callable $callback , array $param_arr )

这两个函数都是把第一个参数作为回调函数d调用,后面接收参数,区别就是第二个函数第二参数接收数组;在使用上唯一的区别就是,call_user_func函数无法接收引用传递; 个人觉得同样是使用,call_user_func 相比call_user_func_array完全可以被替代,不知道是不是有一些性能上的优势。具体使用样例,请参考官方文档。

<?php
// 引用传递
function foo(&$a, &$b) {
    $a ++;
    $b --;
};
$a = $b = 10;
call_user_func_array('foo', [&$a, &$b]);
echo $a."\n", $b; // 11, 9
基本用法

基本用法,跟js的闭包类似
- 普通调用

<?php
global $tmp = 'hello world';
function foo() {
    var_dump(global $tmp);
}
foo(); // null, 函数内部无法直接调用上级作用域的变量,除非声明为全局变量
<?php
$foo1 = function() {
    $a = 10;
    $foo2 = function() {
        var_dump($a);
    };
    return $foo2;
};
$tmp = $foo1();
$tmp();  // null,原因同上 
  • php想要能够获取上级作用域的变量,需要通过use传递
<?php
$foo1 = function () {
    $a = 10;
    $foo2 = function () use ($a) {
        var_dump($a);
        $a ++;
    };
    $foo2();
    return $foo2;
};
$tmp = $foo1();
$tmp();  // 10, 10,  use并不能实际改变变量的值,只是值传递
<?php
$foo1 = function () {
    $a = 10;
    $foo2 = function () use (&$a) {
        var_dump($a);
        $a ++;
    };
    $foo2();
    return $foo2;
};
$tmp = $foo1();
$tmp();  // 10, 11,  通过值传递改变变量的值
  • 下面两段代码的区别,不是很明白,望大佬指点,为什么后一个值传递就可以获取到已经改变后变量的值。好像都是在调用方法之前,已经执行过变量的递增了吧?
<?php
// 值传递
$foo1 = function () {
    $a = 10;
    $foo2 = function () use ($a) {
        var_dump($a);
    };
    $a ++;
    return $foo2;
};
$tmp = $foo1();
$tmp();  // 10
<?php
// 引用传递
$foo1 = function () {
    $a = 10;
    $foo2 = function () use (&$a) {
        var_dump($a);
    };
    $a ++;
    return $foo2;
};
$tmp = $foo1();
$tmp();  // 11
  • 正确使用
<?php
// 值传递
$foo = function () {
    $a = 10;
    $foo2 = function ($num) use ($a) {
        var_dump($num + $a);
    };
    return $foo2;
};
$tmp = $foo();
$tmp(100); // 110
<?php
// 引用传递
$foo = function () {
    $a = 10;
    $foo2 = function ($num) use (&$a) {
        var_dump($num + $a);
        $a ++;
    };
    return $foo2;
};
$tmp = $foo();
$tmp(100); // 110
$tmp(100); // 111
$tmp(100); // 112  跟js类似,保证变量常驻内存
php Closure 类

共同点

都是为了扩展作用域,获取内部变量

区别

js能够在方法内部直接获取到父级作用域的变量,而php需要通过use声明,并且默认是值传递

应用场景

  • 不是很能理解应用场景,搜索了一下,很多只是写了一个闭包实现的购物车,感觉并不是那么的实用。
  • 如果只是单纯的使用匿名函数,感觉还不如封装成一个私有方法
    >这些只是个人粗鄙的理解,望指正.

phpstorm或vscode使用psr2规范

  • 安装composer
  • 全局安装phpcs
composer global require squizlabs/php_codesniffer

vscode直接插件搜索phpcs安装

phpstorm

全局安装phpcs后,会在C:\Users{user name}\AppData\Roaming\Composer\vendor\bin下生成一个phpcs.bat,后面会用到
- phpstorm -> setting
- languages & Frameworks->PHP->Code Sniffer点击Configuration右侧的按钮
- 找到刚才的phpcs.bat,点击Validate,确认
- Editor->Inspection->PHP
- 双击PHP Code Sniffer validation,点击Coding standard右侧的刷新按钮,然后选择psr2,确定

参考链接: 如何优雅地使用phpstorm?

使用pm2增删改常驻进程脚本

业务中使用pm2来管理MQ的消费者(需常驻),使用pm2有个好处就是,如果进程意外退出了,pm2可以将它自动重启。下面介绍常见的使用方法:新增、删除、修改。

一、新增一个常驻进程

1.1 先创建一个json配置文件,比如:


{ /** * docs: http://pm2.keymetrics.io/docs/usage/application-declaration/#attributes-available */ name: "cront/Trade/deliveyNotice", args: "cront/Trade/deliveyNotice", script: "/opt/ci123/www/html/api_shop/webroot/index.php", exec_interpreter: "/opt/ci123/php/bin/php", exec_mode: "fork", max_memory_restart: "100M", out_file: "/tmp/api_shop/Delivery.log", error_file: "/tmp/api_shop/Delivery.log", instances: 1 }

我们使用代码位置(cront/Trade/deliveyNotice)作为name,方便管理。args是在执行script时加在其后面的参数。

1.2 启动脚本


pm2 start /path/to/your/config.json

start后接上一步写的json配置文件。

二、删除常驻进程

2.1 首先找到我们要删除/关闭的进程在pm2里的id是多少。


// e.g. pm2 list|grep cront/Trade/deliveyNotice pm2 list|grep xxx

pm2 list可以查看所有pm2管理的进程,我们根据关键字把需要删除的进程筛选出来,找到id。

2.2 从pm2中删除进程


pm2 delete id

id是上一步找到的进程id(进程在pm2中的id,不是pid)。

三、修改常驻进程,并重启

pm2有restart和reload命令。

一般认为restart的意思是先关闭旧进程,再重启一个新的进程。
而reload的意思是先重启一个新的进程,再关闭旧进程。


pm2 restart /path/to/your/config.json #或者 pm2 reload /path/to/your/config.json

restart和reload都不能真正实现进程的平滑重启。只有将消费进程改造后,才可以实现平滑重启。

四、其他

4.1 pm2也支持将所有config写在一个json文件里;

4.2 使用pm2管理常驻进程的初衷,是想借助pm2的进程重启功能;其他一些工具比如supervisor也有类似功能。

4.3 pm2管理非node进程时,只能使用fork模式,不能使用cluster模式;

4.4 官方对配置文件的说明: Process File

RabbitMQ-匿名队列

匿名队列

创建RabbitMQ队列的时候,如果没有指定队列名,系统会自动创建一个随机字符串作为队列名,比如:

这是RabbitMQ规定的。匿名队列不是没有名字,而是名字是由系统自动、随机生成的。

匿名队列 vs 具名队列

匿名队列的好处是方便,但是队列名在RabbitMQ里有它的独特用处。当多个Consumer同时订阅一个队列上的同一种消息(比如:Direct交换机,Consumer的binding_key相同),RabbitMQ会在这些Consumer之间轮流投递消息。

总结起来,各自优缺点如下:

  1. 需要消息持久化时,队列名必不可少(虽然匿名队列本质上也有名字,但是很难记对不?故障恢复的时候怎么办呢?);
  2. 匿名队列更适合用在临时环境,一般匿名+队列自动删除同时使用,解决临时队列使用后的维护问题;
  3. 具名队列在迁移Consumer时更具优势,可以先在新服务器上运行Consumer,再关闭旧服务器上的Consumer;
  4. 匿名队列不需要取名字,写法上要简洁写。

总体上,如果业务里要使用队列,建议首先考虑具名队列。如果队列太多怎么办呢?建议使用消息总线,免去维护的烦恼。

RabbitMQ入门

学习东西有三个境界:了解、熟练使用、理解原理。大神一般都是第四个境界:根据原理造轮子。对于RabbitMQ,我目前大概在第二个境界。以下介绍均按照自己理解来,个人不保证完全正确,而且也认为肯定有些不准或错误的地方,建议仅作参考,欢迎指正。

一、RabbitMQ是一款开源企业级消息代理软件,实现了AMQP协议(和MQTT协议)。

AMQP,即Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准高级消息队列协议。
MQTT,即Message Queuing Telemetry Transport,是一种轻量级的、灵活的网络协议,常用于物联网。
官网:https://www.rabbitmq.com/
文档:https://www.rabbitmq.com/documentation.html

二、主要概念

  1. Virtual Host:
    你可以参照windows概念:一机多用户。为了在一个单独的代理上实现多个隔离的环境(用户、用户组、交换机、队列 等),AMQP提供了一个虚拟主机(virtual hosts - vhosts)的概念。Virtual Host是隔离的最高级别。

  2. Exchange: 交换机
    交换机用来发送AMQP实体消息。接收生产者的消息,将消息路由到[0, n](n>0,整数)个队列。消息内容可以是文本,也可以是二进制数据。不同类型的交换机有不同的消息路由策略。每个队列创建的时候,必须指定一个Exchange,这个Exchange是下面描述中的某一种: (以下中文名可能和网上的不一样)

    2.1 Direct exchange: 直连交换机。
    根据消息携带的路由键(routing key)将消息投递给对应队列(完全匹配),可以发送给多个队列(消息复制)。

    2.2 Fanout exchange: 扇形交换机。
    将消息发给绑定到该交换机的所有队列,忽略(队列绑定到exchange时绑定的)路由键。可以处理广播消息。

    2.3 Topic exchange: 主题交换机。

    a. 根据消息携带的路由键,将消息发送给相应队列。和Direct不同的是,主题交换机的匹配规则可以是模糊匹配。
    
    b. "#"(井号)表示任意数量(0个or多个)单词。当一个队列的绑定键为 "#"(井号) 的时候,这个队列将会无视消息的路由键,接收所有的消息。
    
    c. "*"(星号)表示一个单词。当 * (星号) 和 # (井号) 这两个特殊字符都未在绑定键中出现的时候,此时主题交换机就拥有的直连交换机的行为。
    

    2.4 Headers exchange: 消息头交换机/首部交换机。

    a. 和http请求一样,每个RabbitMQ的消息也有消息头,消息头内部有很多键值对,这些键值对可以自己定义。
    
    b. 头交换机使用一个或多个消息头(x-match可以为any或all)来代替路由键建立路由规则。通过判断消息头的值能否与指定的绑定相匹配来确立路由规则。
    
    c. x-match为any时,任意一个消息头符合即可。相应地,all表示必须满足所有定义的消息头。
    
  3. queue: 队列
    队列需要连接到某具体的交换机(在代码中, channel创建时需要指定交换机,queue再通过channel发送/接收消息,channel是建立在tcp连接上的可复用逻辑通道,避免频繁的tcp连接)。

  4. channel: 通道
    4.1 见queue中的描述。
    4.2 channel可以减少tcp频繁创建、断开的开销。
    4.3 binding_key: 绑定
    4.4 在绑定(Binding)Exchange与Queue的同时,一般会指定一个binding key;消费者将消息发送给Exchange时,一般会指定一个routing key;当binding key与routing key相匹配时,消息将会被路由到对应的Queue中。
    4.5 routing_key在某些交换机下会被忽略,相应地binding_key也会被忽略(binding_key的作用就是和routing_key做匹配)。
    4.6 可以理解成binding_key就是生产者指定的"routing_key"。

  5. 消息消费模式
    5.1 订阅-发布
    5.2 消费者主动拉取

  6. 部分特性
    6.1 消息持久化。消息持久化需要以下指标同时持久化:

    a. 交换机持久化
    
    b. 队列持久化
    
    c. 消息设置持久化(Delivery Mode => 2)
    
    d. 如果消息、队列设置了过期时间,过期后的消息会被发送到“死信队列”(如果设置了的话),否则丢弃。
    
    e. 注意上面一条,持久化的消息也可能因为设置过期时间而被丢弃。
    

    6.2 过期时间说明:Message的TTL("expiration")是从数据到达Queue中后开始计时的,而不是Message被创建时计时。Queue的TTL("x-expires")定义是Queue被自动删除前可以处于未使用状态的时间。如果你设置了Queue为自动删除,那么"x-expires"这个参数就有效。

    官方解释:
    [1] Auto-delete:queue that has had at least one consumer is deleted when last consumer unsubscribes

    [2] Expiry time can be set for a given queue by setting the x-expires argument to queue.declare, or by setting the expires policy. This controls for how long a queue can be unused before it is automatically deleted. Unused means the queue has no consumers

    一条消息什么时候过期是由两个因素决定的:Message中的"expiration",以及Queue中的"x-message-ttl",他们都是一种消息过期策略,消息具体过期时间以 min("x-expires", "x-message-ttl")为准。

    6.3 消费者ack机制:

    a. 在确认消息已被获取后,才删除消息;
    
    b. 自动确认ack机制: 如果消息不重要,自动ack的消息可以增加消息的消费速度。
    

    6.4 生产者confirm机制:

    a. 确认消息成功投递(到队列)后,执行回调函数;
    
    b. 不论是持久化,还是生产者confirm,对RabbitMQ吞吐量都有一定影响;
    

下单接口优化

背景

对于比较关键的下单接口,性能直接影响到商户的使用体验。目前,我们的下单接口大部分耗时都在4s-5s之间,少数耗时在2-3s。具体耗时多少,也和实际覆盖到的流程有关,不同分支流程,需要处理的业务逻辑往往不一样,调用接口数量也不一样。

思路

4s-5s耗时算比较多的,考虑到有些商户网络比较差,客户端从下单到接收到返回数据的实际耗时会更多,实际体验也会更差。

缩减耗时,可以从mysql优化入手,但是我们的sql已经挺快了(我的意思是,它的优化空间并不是很大,继续优化的难度会越来越大),网络耗时的优化也难以有很明显的效果。

从ztrace的监控面板上可以看到,几乎所有请求都是串联的,只要改为并发,就可以大大减小接口总耗时。这么多的接口,只要拿5、6个做并发,就可以节省接近1s的时间,所有优化空间很大。

特别是一些诸如“获取用户信息”、“获取商户信息”、“获取商品信息”之类的接口,它们对执行顺序没有特别要求,完全可以在接口开始时就做并发。

调用链跟踪系统的一个巨大优点就是直观,每个部分的耗时都可以显示出来,并且进行对比。

操刀

PHP有对并发访问的支持:curl_multi_init,但是要用到我们项目里,要尽量减小对原流程的干涉,所以做了一些独特的设计。

cURL批处理的用法,是先初始化一个批处理句柄:curl_multi_init,然后通过curl_multi_add_handle把普通的curl句柄加入批处理句柄,再统一执行(并发)。

下面是一个例子,来自:php.net

<?php
// 创建一对cURL资源
$ch1 = curl_init();
$ch2 = curl_init();

// 设置URL和相应的选项
curl_setopt($ch1, CURLOPT_URL, "http://www.example.com/");
curl_setopt($ch1, CURLOPT_HEADER, 0);
curl_setopt($ch2, CURLOPT_URL, "http://www.php.net/");
curl_setopt($ch2, CURLOPT_HEADER, 0);

// 创建批处理cURL句柄
$mh = curl_multi_init();

// 增加2个句柄
curl_multi_add_handle($mh,$ch1);
curl_multi_add_handle($mh,$ch2);

$running=null;
// 执行批处理句柄
do {
    usleep(10000);
    curl_multi_exec($mh,$running);
} while ($running > 0);

// 关闭全部句柄
curl_multi_remove_handle($mh, $ch1);
curl_multi_remove_handle($mh, $ch2);
curl_multi_close($mh);

?>

我直接找了一个相关库,并把它clone到了公司github上,保留了授权(感谢Apache License,感谢作者,附上原地址:jmathai/php-multi-curl,内部项目地址:shop/php-multi-curl

为了少改业务流程,我设计了一个方法:preExec,它的参数和execApi一样。
它的作用是生成请求的curl句柄,并将其加入到批处理(还没执行),然后计算请求的hash,设置$ci->preExec[$request_hash](可以通过它去获取这次请求的结果)。

如何计算请求hash:请求hash的计算元素,包括请求url、请求params、请求method。

function requestHash($url, $p, $method)
{
    $url = strtolower(trim(trim($url), '&?/\\'));
    $phash = $this->arrayHash($p);
    $method = strtolower(trim($method)) == 'post' ? 'post' : 'get';
    return md5($url . $phash . $method);
}
function arrayHash($p, $filter = array('mdstr', 'expire'))
{
    $para_sort = array();
    foreach ($p as $k => $v) {
        if ($filter && in_array($v, $filter, true)) continue;
        // array object bool int float null resource...
        $para_sort[$k] = is_array($v) ? $this->arrayHash($v)
            : (is_object($v) ? $this->arrayHash((array)$v) : strval($v));
    }
    ksort($para_sort);
    return md5(http_build_query($para_sort));
}

当我们去主动获取请求结果时,如果当前请求已经执行完成,会立即返回请求结果,其他还没执行完的请求继续执行,不堵塞业务流程。如果当前请求没执行完,会堵塞直到当前请求执行结束。

执行execApi时,先判断是否有设置$ci->preExec[$request_hash],如果没设置,就是一次普通的curl访问,和以前的流程一样(单次请求,堵塞直到获取到结果);如果有设置,那就执行curl批处理(所有的并发请求都在这时开始执行。如果已经执行过了就不再执行),并通过$ci->preExec[$request_hash]去获取当前请求的结果。获取到结果后,会将$ci->preExec[$request_hash]删除。

所以,只要我们在流程开始前执行preExec,那执行到execApi的时候就会自动做并发,并获取结果。

在流程1和流程2,execApi分别执行普通curl访问、并发curl访问。

DEBUG流程

下面讲一些遇到的坑。

开始做并发时很顺利,也成功实现了并发。但是当我把它和调用链跟踪结合起来的时候,一大堆问题就来了。

为了准确跟踪接口调用时间,我们需要记录每个curl的执行开始时刻,和执行过程的耗时。

每创建一个curl句柄后,我们都会向curl批处理会话中添加这个单独的curl句柄,但是这个时候curl是没开始执行的。如果我们把创建curl的时刻作为url开始访问的时刻,记录到的接口生命周期会比实际的长很多(提前记录了)。

这时我想到了通过记录curl结束时间和curl耗时,反推开始时间。而curl有个回调函数,可以在接收到数据的时候执行:CURLOPT_WRITEFUNCTION(有没有curl开始执行的回调函数呢?很遗憾没有!)

于是,我使用了CURLOPT_WRITEFUNCTION来记录curl调用的结束时刻。但是在235上,设置CURLOPT_WRITEFUNCTION后总是得不到返回数据(curl_exec总是返回TRUE),我发现CURLOPT_RETURNTRANSFER设置失效了!

** 235上的libcurl版本是:7.19.7**

CURLOPT_WRITEFUNCTION 回调函数名。该函数应接受两个参数。第一个是 cURL resource;第二个是要写入的数据字符串。数 据必须在函数中被保存。 函数必须准确返回写入数据的字节数,否则传输会被一个错误所中 断。

CURLOPT_RETURNTRANSFER TRUE 将curl_exec()获取的信息以字符串返回,而不是直接输出。

一顿折腾,在Stack Overflow上找到一点提示 ready go

When I placed that line before any of the other options, it was simply ignored.

我尝试在设置CURLOPT_WRITEFUNCTION后再次设置CURLOPT_RETURNTRANSFER,这次跑通了。

BUT!我自己的电脑上又出问题了,设置了CURLOPT_WRITEFUNCTIONCURLOPT_RETURNTRANSFER就失效,CURLOPT_RETURNTRANSFER生效的时候,就不执行CURLOPT_WRITEFUNCTION。在需要二选一的时候,我选择了放弃。

** 我电脑上的libcurl版本是:7.55.0**

我放弃了CURLOPT_WRITEFUNCTION。找到了一个替代回调函数CURLOPT_HEADERFUNCTION,这个函数会在接收到返回的header信息时调用。虽然它不能精确表示结束时间,但是马马虎虎也能用。最重要的是,这个函数始终都会被调用。

CURLOPT_HEADERFUNCTION 设置一个回调函数,这个函数有两个参数,第一个是cURL的资源句柄,第二个是输出的 header 数据。header数据的输出必须依赖这个函数,返回已写入的数据大小。

这时,调用链跟踪和并发成功结合起来了,监控的数据基本正确,也能正常运行。

如果看到ztrace上面,一个并发前只需要100ms+的接口,在并发后要800ms+,请相信我,curl_getinfo($ch, CURLINFO_TOTAL_TIME)返回的就是这个值,不是程序有bug。具体原因期待大神解释。

调试过程中还遇到一个libcurl的bug,这个bug只在 7.55.0 上有(没错,我电脑上的就是这个版本:broken_heart:)。这个bug就是curl_getinfo($ch, CURLINFO_TOTAL_TIME)会在某些时候返回一个超大的浮点数,比如4295.xx。而经过我观察,只需要减掉4295就行了(究竟对不对,就看我猜的对不对了:pray:)

结语

好了,上面是修改ApiShopClient的整个过程,主要时间花在了适配调用链跟踪上面,从这个debug流程也能看到,php在涉及到底层的时候有点力不从心(大神忽略掉这句)。

调用链跟踪系统-介绍

背景资料

Dapper,大规模分布式系统的跟踪系统

Zipkin

如果要查看zipkin资料,官网的英文文档是最正确的,讲解也很系统;

在阅读文章前,建议先至少看一下Dapper的翻译;

一、介绍

调用链跟踪系统进行了比较大的升级,主要改变有:

  1. 对接zipkin日志标准(仍保留了些许不同);
  2. 使用zipkin的数据库和界面;
  3. 大幅降低对正常业务流程的性能影响;
  4. 将数据压缩后再传递给收集器;
  5. rabbitmq使用单独的数据通道,不再和业务共用一个;
  6. 采用文件日志和mysql记录结合的方式,分别记录调用链的业务数据和性能数据。 业务数据100%记录,性能数据采样记录(5%);
  7. 支持业务数据的上下文透明传输

下面详细讲解系统的构成、设计考量、使用和特点。

二、结构

分布式跟踪系统整体结构

浅绿色的是数据最终落地的地方。

名词解释:临时存储

临时存储究竟是干嘛的呢?在zipkin里,一个Span被分割成两部分,一个是Client/Producer,另一个是Server/Consumer。Span的含义更接近于一次RPC,它横跨了两个服务节点,所以在两个节点分别上报信息,最后再合并成一个Span。

后端收到两部分Span的时间不一样,临时存储就用来存储先到达的Span,等另一半到达后再取出来做合并。

名词解释:收集器

从MQ接收上报的Span,做Span的合并,并存储到持久存储(mysql)

名词解释:压缩

压缩成二进制,再通过MQ传递。实际应用中,是先将Span转换为数组,将key替换成a-z的单字母,再转成json后使用zlib压缩(需要zlib扩展)。

和压缩相关的还有一个叫 msgpack 的东西,但是它需要单独安装PHP扩展,所以放弃了。

三、设计考量

考量一:降低对业务的影响

只在少数地方嵌入跟踪代码。实际操作中,在项目入口(index.php)、rpc请求、输出这三个地方做了埋点。

耗时操作在请求以外执行。我们知道PHP没有异步,但是PHP有个非常有用的函数register_shutdown_function,我们把耗时操作全放里面,这样可以尽量减少对线上业务的影响。如果是fastcgi模式,我们会优先使用fastcgi_finish_request这个函数。http://php.net/manual/zh/function.register-shutdown-function.php#108212

考量二:开关/采样率/消息生产速率控制

一共设计了两个开关(业务日志开关、Span上报开关),默认情况下这两个开关都是开启的。在此基础上,增加了采样率控制,仅当Span上报开关开启,且当前Span需要采样时,Span才会通过MQ传递给后端。业务日志开关控制biz-ztrace日志的记录。

需要注意的时,即便把开关全部关闭,traceid也会生成、传递,但不会有日志记录,也不会有Span上报。

对于消息速率控制,在实际中采用的是“过量丢弃”,阈值是10000。只要队列里的消息超过这个值,即不再接收消息,直接抛弃。

考量三:动态增加消费能力

随着系统扩大,采集的数据增多,动态增强数据的消费能力非常重要。目前的做法是通过加开消费者来实现。之所以能直接增加消费者,是因为它们使用的同一个临时存储,我们只是把消息的消费瓶颈,转移到了临时存储的IO瓶颈上。

想象一下,我们有两个消费者A、B,分别使用两个不同的临时存储,Client端的Span被A接收到,Server端的Span被B接收到,由于它们的临时存储隔开的,A、B都无法做Span的合并!

几乎所有的调用链跟踪系统实现方案,都绕不开这个问题,可能是一个Span被分发到不同处理实例,也可能是同一Trace的不同Span被分发到不同处理实例——这都会带来很多麻烦。

解决方法也很多,比如根据traceid做hash,再转发给不同的后端实例,这样同一个trace下的Span都会被,且只会被转发给同一处理实例。

但是,仅仅做hash是不够的,还必须借助“一致性Hash”算法。下面的例子解释了原因。

假设已经有一个处理实例CA,spanA、SpanB都属于同一个trace。CA已经接收到spanA了,这时我们新增了一个CB(和CA使用不同临时存储),如果hash结果表明spanB应该转发给CB(这完全可能发生),那又会出现CA、CB都无法合并这个Span的情况。

关于一致性Hash,我们后面再讲。

考量四:业务日志、性能日志分开

之所以要分开,是因为业务日志很多(随着采集端的增加,数据增长更多),我们还没处理过这么多的数据。

目前,业务日志记录了$_GET、$_POST、$_SERVER和apiReturn,收集端有seller和api。每天产生的日志共2.5G左右,每月日志总量大概在25G。日志总量和采集端数量呈正相关。

四、使用

下面的例子都有判断$trace_open;,并且使用了 try {} catch () {},这样做是为了减少可能出现的异常,必须要添加。

记录业务日志

业务日志是记录在文件里的,用的最多。

过期数据处理

我们把太久远的数据称为“过期数据”,因为它们被再次使用的概率很低。我们用的最多的是近期的数据,近期数据才是最重要的,业务日志和性能统计数据都适用这个道理。

我们处理过期数据的方式很简单——打包压缩、删除,这是目前能找到的最划算的处理方法。具体处理逻辑:

文件日志:保留一个月的压缩日志,保留一周内的原始日志。
数据库:保留两个月的数据。

global $trace_open;
if ($trace_open) {
    try {
        $tracer = \Tricolor\ZTracker\Core\GlobalTracer::tracer();
        $tracer->log('get', $_GET);
    } catch (\Exception $e) {}
}

记录跟踪日志

跟踪日志会出现在ui里(如果被采样了的话)

global $trace_open;
if ($trace_open) {
    try {
        $tracer = \Tricolor\ZTracker\Core\GlobalTracer::tracer();
        $tracer->currentSpan()->putTag('store_id', (int)$_POST['store_id']);
    } catch (\Exception $e) {}
}

透明传输业务字段(试验性质)

在调用链上游设置一个值,在调用链下游可以获取到。

之所以说是试验性质,是因为设计里有,也有实现,但实际业务里还没使用过。

global $trace_open;
if ($trace_open) {
    try {
        $tracer = \Tricolor\ZTracker\Core\GlobalTracer::tracer();
        $tracer->currentContext()->set('from', 'mamagou');
        // get
        $tracer->currentContext()->get('from');
    } catch (\Exception $e) {}
}

五、特点

  1. 和zipkin的Span比起来,我们的Span结构略有不同。zipkin里的Sampled字段,在我们这里对应Decision字段,Decision字段除了记录是否采样,还记录了log开关和report开关。

  2. 数据库、UI使用zipkin,减少了一些开发量。zipkin的Span设计能适应较多的采集场景,比如单向数据传递、黑盒模式的远端节点(调用数据库时,数据库就是一个黑盒模型)。

  3. 和淘宝类似,加了业务字段透传功能,业务日志和跟踪日志分开。