用openresty做后台服务器

【腾讯云】校园优惠再度来袭cloud.tencent.com

与OpenResty及其原作者邂逅

OpenResty是一个优秀的开源项目,作者是章亦春。官网是openresty.org/en/。这已经是我第二次在公司项目中使用它展开业务了。分享使用经历的时候,顺便帮春哥推广一下:

未分类

其实,我想说的是春哥真的像知乎上传的一样,热情,专业。同时也希望大家都到邮件列表里问问题,像我一样莽莽撞撞直接私发邮件,确实显得不大矜持,汗!

我们后台选用的第三方库和文件

我们后台采用: openresty框架+lua脚本+c函数+mongoDB数据库.这其中的技术细节我们都论证过,现把他们都链接粘出来。

  1. Lua调用mongo的驱动 github.com/bigplum/lua…
  2. OpenResty官网下载 openresty.org/en/download…
  3. Lua调用C函数blog.csdn.net/wuxiangege/…

架构思想和坑

想成为高手,得首先学会跟高手一样思考问题,我觉得这也是每一个有志向的程序员练级所必须经历的。下面是我跟架构师的工作交流时,从正面和侧面所总结的,即为什么要采用:openresty框架+lua脚本+c函数+mongoDB数据库作为后台的原因。

  • 对于openresty,我的理解是openresty = web服务器nginx + lua虚拟机。它的好处官网都有,如”能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关”
    。对于我们的项目,架构师看中的似乎是openresty对nginx的lua扩展,因为接下来我们会调用第三方提供的dll文件,即lua调C。在操作数据库方面,openresty提供的很多精良的数据库驱动,如memcached、mysql、redis。虽然官方没有提供mongodb,但bigplum大神已经做到了,见上方链接。经过测试,bigplum大神提供的测试文件可用,对文档的数据库、集合和文档的增删改查均可用,点个赞。
  • openresty官方已经实现了redis的驱动,我们为什么选用mongodb而不是redis?架构师是这么回答我的,1、redis太重了,mongodb稍微轻一点点,而且我们的项目是在win上部署的对内存有一些要求,架构师希望它在普通的pc上也能运行;2、项目要求数据库同步,也就是a/b/c/d/四台机器数据保持数据的一致性。在这一点上,我们采取的是mongodb的副本集。

讨论太廉价了,直接上代码吧

nginx.conf文件

worker_processes  1;

events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;
    lua_package_path '/c/Users/wuxian/Desktop/BlackHole/openresty-1.9.15.1-win32/lualib/resty/mongol/init.lua;;';


    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        location / {
            #解决跨域问题
            if ($request_method = 'OPTIONS') {
                add_header 'Access-Control-Allow-Origin' '*';
                add_header 'Access-Control-Allow-Headers' 'Content-Type';
                return 204;
            }   
            if ($request_method = 'POST') {
                add_header 'Access-Control-Allow-Origin' '*';
            }   

            #处理业务的文件
            default_type text/html;
            content_by_lua_file './lua_script/app.lua'; 
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

app.lua文件

local mongo = require "resty.mongol"
local cjson = require "cjson.safe"

-----------------------------------------------------------------------------
--lua call c module
square = package.loadlib("./ADD.dll", "isquare")
alert  = package.loadlib("./ADD.dll", "alert")
add    = package.loadlib("./ADD.dll", "l_add")
sub    = package.loadlib("./ADD.dll", "l_sub")

-----------------------------------------------------------------------------
--Connect mongodb database
conn = mongo:new()

ok, err = conn:set_timeout(5000) 
ok, err = conn:connect("127.0.0.1", 27017)
if not ok then
    ngx.say("connect failed: "..err)
end

local db = conn:new_db_handle("tamigroup")
if not db then
    ngx.say(db)
end

local col = db:get_col("number")

-----------------------------------------------------------------------------
-- Process request data
local function post_request(data)
    local result = {}
    local request = cjson.decode(data)
    if not request or not request["cmd"] then
        result["result"] = "failed"
        result["reason"] = "no 'cmd' field or not a json struct"
        return result
    end

    local sql_str = ""

    --测试
    if request["cmd"] == "demo" then
        local result = {}
        result["result"] = "ok"
        result["reason"] = "This is a test cmd"
        return result
    end

    --插入人员
    if request["cmd"] == "add" then
        local result = {}

        local t = {}
        table.insert(t, {name=request["name"], id=request["id"]})
        r, err = col:insert(t, nil, true)
        if not r then 
            result["result"] = "insert failed: "..err
        else
            result["result"] = "ok"
        end
        return result
    end

    --删除人员
    if request["cmd"] == "delete" then
        local result = {}

        r, err = col:delete({name=request["name"]}, nil, true)
        if not r then 
            request["result"] = "delete failed: "..err
        else
            result["request"] = "ok" 
        end
        return result
    end

    --修改人员
    if request["cmd"] == "update" then
        local result = {}

        r,err = col:update({name=request["old"]},{name=request["new"]}, nil, nil, true)
        if not r then 
            request[result] = "update failed: "..err
        else
            request[result] = "ok"
        end
        return result
    end

    --查询人员
    if request["cmd"] == "find" then
        local result = {}

        r = col:find({name=result["name"]})
        r:limit(1)
        for i , v in r:pairs() do
            result["data"] = v["name"];
        end

        if not result["data"] then
            result["result"] = "He or she don't exist"
        else
            result["result"] = "ok"
        end
        return result
    end

    --调用c模块
    if request["cmd"] == "lua_call_c" then
        local result = {}
        result["data"] = square(12)
        result["add"] = add(100, 50)
        result["result"] = "ok"
        return result
    end

end

-----------------------------------------------------------------------------
-- Parse request body
ngx.req.read_body()
local method = ngx.var.request_method
if method ~= "POST" then
    local result = {}
    result["result"] = "failed"
    result["reason"] = "use POST method"
    ngx.say(cjson.encode(result))
else
    local data = ngx.req.get_body_data()
    ngx.say(cjson.encode(post_request(data)))
end

后记

整个项目的大致思路就是这酱紫的。在这里面,我们曾调研lua在win下怎样调用c;openresty怎样驱动mongodb;mongodb搭建集群数据怎么保持一致…….也傻乎乎的给春哥和云风写过邮件等等。最终前期调研终于顺利完成,累。

nginx 499 日志记录client closed connection while waiti

error.log 中出现大量以下日志(info级别才会记录如下日志):

2013/11/13 11:26:10 [info] 18382#0: *2799 client closed connection while waiting for request, client: 127.0.0.1, server: 0.0.0.0:80

应该是客户端设置了HTTP请求超时,比如1秒后就超时,这时会给服务端发送一个关闭TCP连接的包(TCP四次挥手的FIN包),Nginx检测到客户端关闭连接后,就会记录一条这样的日志,并且此时 nginx access log 里面记录的是499这个 status code。

出现这个问题,通常可能是因为你接口响应时间太长了,超过了客户端设置的超时设置,建议在 nginx access log 里记录一下 $upstreamresponsetime $request_time 两个时间,看一下一般请求响应时间是多少。如果确实时间长,那就优化代码吧。

nginx+php-fpm故障排查

小明初到一家公司做运维的工作,刚来的第一天就开始部署LNMP(Linux+Nginx+MySQL+PHP)环境,结果出现了问题。

他来向我请教,具体问题现象、原因和解决思路如下:

问题一
nginx进程CPU和内存不均衡,某个进程占用资源特别高,如何解决?
回答:我让小明绑定下CPU的亲缘性(设置nginx配置worker_cpu_affinity项为auto,auto这个特殊值(1.9.10版本)允许自动绑定工作进程到可用的CPU上。),绑定后CPU和内存使用就均衡了。

问题二
小明又问close系统调用消耗很高怎么解决?

回答:且听我娓娓道来,继续看下文。

strace−cpstrace−cp(pgrep -n nginx)

未分类

$ top

未分类

系统32c的,top查看负载去到75.14,

查看过nginx和php-fpm的

错误日志也没有什么发现。

strace 跟踪close的系统调用,

都是以下的信息:

strace−T−ttpstrace−T−ttp(pgrep -n nginx) 2&>1 |grep -B 10 close > ./close.log

未分类

$ lsof | more

未分类

遂怀疑是连接创建关闭消耗了太多的资源,便让小明加了台机器专门跑nginx,
前端挂了个nginx用长连接跟后端的nginx连接,接了个nginx之后负载果然就下来了。

未分类

前端未挂nginx压测ab压测结果:

未分类

前端挂了nginx压测ab压测结果:

未分类

tps基本没变第一个Time per,requset快了87.52%。
接着继续排查tps上不去的原因,继续strace后端的nginx。

strace−cpstrace−cp(pgrep -n nginx)

未分类

发现现在是wrtiev占用高了,strace 跟踪close的系统调用,
发现很多以下的输出:

connect(26, {sa_family=AF_INET, 
sin_port=htons(9000), 
sin_addr=inet_addr("127.0.0.1")},
16) = -1 EINPROGRESS
(Operation now in progress)

问题应该不是在nginx上,
应该是在php-fpm上了。
继续

strace−cpstrace−cp(pgrep -n php-fpm)

显示下图所示:

未分类

access cpu时间消耗最多那就先
排查access
系统调用:

strace−T−ttpstrace−T−ttp(pgrep -n php-fpm) 2&>1 |
grep -B 10 access >
./access.log

未分类

php-fpm进程频繁的去读取文件,整个操
作下来花费4ms的时间。

然后排查recvfrom:

strace−T−ttpstrace−T−ttp(pgrep -n 
php-fpm) 2&>1 |
grep -B 10 recvfrom > 
./recvfrom.log

未分类

频繁的去访问10.0.0.156的6379,端口,明显就是访问redis读取数据的过程,
整个过程花费12ms。
让小明把上面两个strace信息发给开发,第一个得到回复是老版本的流程,
新版本改了,但还是有些判断没有处理。
第二个问题让开发使用redis连接池,无需频繁创建连接读取数据,
频繁创建连接开销很大的。

总 结
当遇上性能问题时,排查日志无法解决时,使用strace工具来查看一下系统调用,看时间到底消耗在哪里了,可以轻松的找到问题所在。

mysql数据库开发常见问题及优化

mysql 数据库是被广泛应用的关系型数据库,其体积小、支持多处理器、开源并免费的特性使其在 Internet 中小型网站中的使用率尤其高。在使用 mysql 的过程中不规范的 SQL 编写、非最优的策略选择都可能导致系统性能甚至功能上的缺陷。

未分类

恰巧就在前几天,本人所在公司的云事业部举办了一场关于 mysql 的技术交流会,其中一个 part 正是聚焦于开发过程中 mysql 数据库设计及使用的常见问题,并提出相关优化方案。根据会议内容并查阅相关资料,本人对这个 part 进行了一次小结,结合自己的工作经历及理解形成此文以供分享,希望能有助于各位同行解决工作中的相关问题。

本文将就以下三个问题进行展开:

  1. 库表设计

  2. 慢SQL 问题

  3. 误操作、程序 bug 时怎么办

一、库表设计

1.1 引擎选择

在 mysql 5.1 中,引入了新的插件式存储引擎体系结构,允许将存储引擎加载到正在运新的 mysql 服务器中。使用 mysql 插件式存储引擎体系结构,允许数据库专业人员或者设计库表的软件开发人员为特定的应用需求选择专门的存储引擎,完全不需要管理任何特殊的应用编码要求,也无需考虑所有的底层实施细节。因此,尽管不同的存储引擎具有不同的能力,应用程序是与之分离的。此外,使用者可以在服务器、数据库和表格三个层级中存储引擎,提供了极大的灵活性。

mysql 常用的存储引擎包括 MYISAM、Innodb 和 Memory,其中各自的特点如下:

  1. MYISAM : 全表锁,拥有较高的执行速度,一个写请求请阻塞另外相同表格的所有读写请求,并发性能差,占用空间相对较小,mysql 5.5 及以下仅 MYISAM 支持全文索引,不支持事务。

  2. Innodb:行级锁(SQL 都走索引查询),并发能力相对强,占用空间是 MYISAM 的 2.5 倍,不支持全文索引(5.6 开始支持),支持事务

  3. Memory : 全表锁,存储在内存当中,速度快,但会占用和数据量成正比的内存空间且数据在 mysql 重启时会丢失。

基于以上特性,建议绝大部份都设置为 innodb 引擎,特殊的业务再考虑选用 MYISAM 或 Memory ,如全文索引支持或极高的执行效率等。

1.2 分表方法

在数据库表使用过程中,为了减小数据库服务器的负担、缩短查询时间,常常会考虑做分表设计。分表分两种,一种是纵向分表(将本来可以在同一个表的内容,人为划分存储在为多个不同结构的表)和横向分表(把大的表结构,横向切割为同样结构的不同表)。

其中,纵向分表常见的方式有根据活跃度分表、根据重要性分表等。其主要解决问题如下:

  1. 表与表之间资源争用问题;

  2. 锁争用机率小;

  3. 实现核心与非核心的分级存储,如UDB登陆库拆分成一级二级三级库

  4. 解决了数据库同步压力问题。

横向分表是指根据某些特定的规则来划分大数据量表,如根据时间分表。其主要解决问题如下:

  1. 单表过大造成的性能问题;

  2. 单表过大造成的单服务器空间问题。

1.3 索引问题

索引是对数据库表中一个或多个列的值进行排序的结构,建立索引有助于更快地获取信息。 mysql 有四种不同的索引类型:

  1. 主键索此 ( PRIMARY )

  2. 唯一索引 ( UNIQUE )

  3. 普通索引 ( INDEX )

  4. 全文索引(FULLTEXT , MYISAM 及 mysql 5.6 以上的 Innodb )

建立索引的目的是加快对表中记录的查找或排序,索引也并非越多越好,因为创建索引是要付出代价的:一是增加了数据库的存储空间,二是在插入和修改数据时要花费较多的时间维护索引。

在设计表或索引时,常出现以下几个问题:

  1. 少建索引或不建索引。这个问题最突出,建议建表时 DBA 可以一起协助把关。

  2. 索引滥用。滥用索引将导致写请求变慢,拖慢整体数据库的响应速度(5.5 以下的 mysql 只能用到一个索引)。

  3. 从不考虑联合索引。实际上联合索引的效率往往要比单列索引的效率更高。

  4. 非最优列选择。低选择性的字段不适合建单列索引,如 status 类型的字段。

二、慢 SQL 问题

2.1 导致慢 SQL 的原因

在遇到慢 SQL 情况时,不能简单的把原因归结为 SQL 编写问题(虽然这是最常见的因素),实际上导致慢 SQL 有很多因素,甚至包括硬件和 mysql 本身的 bug。根据出现的概率从大到小,罗列如下:

  1. SQL编写问题

  2. 业务实例相互干绕对 IO/CPU 资源争用

  3. 服务器硬件

  4. MYSQL BUG

2.2 由 SQL 编写导致的慢 SQL 优化

针对SQL编写导致的慢 SQL,优化起来还是相对比较方便的。正如上一节提到的正确的使用索引能加快查询速度,那么我们在编写 SQL 时就需要注意与索引相关的规则:

  1. 字段类型转换导致不用索引,如字符串类型的不用引号,数字类型的用引号等,这有可能会用不到索引导致全表扫描;

  2. mysql 不支持函数转换,所以字段前面不能加函数,否则这将用不到索引;

  3. 不要在字段前面加减运算;

  4. 字符串比较长的可以考虑索引一部份减少索引文件大小,提高写入效率;

  5. like % 在前面用不到索引;

  6. 根据联合索引的第二个及以后的字段单独查询用不到索引;

  7. 不要使用 select *;

  8. 排序请尽量使用升序 ;

  9. or 的查询尽量用 union 代替 (Innodb);

  10. 复合索引高选择性的字段排在前面;

  11. order by / group by 字段包括在索引当中减少排序,效率会更高。

除了上述索引使用规则外,SQL 编写时还需要特别注意一下几点:

  1. 尽量规避大事务的 SQL,大事务的 SQL 会影响数据库的并发性能及主从同步;

  2. 分页语句 limit 的问题;

  3. 删除表所有记录请用 truncate,不要用 delete;

  4. 不让 mysql 干多余的事情,如计算;

  5. 输写 SQL 带字段,以防止后面表变更带来的问题,性能也是比较优的 ( 涉及到数据字典解析,请自行查询资料);

  6. 在 Innodb上用 select count(*),因为 Innodb 会存储统计信息;

  7. 慎用 Oder by rand()。

三、分析诊断工具

在日常开发工作中,我们可以做一些工作达到预防慢 SQL 问题,比如在上线前预先用诊断工具对 SQL 进行分析。常用的工具有:

  1. mysqldumpslow

  2. mysql profile

  3. mysql explain

具体使用及分析方法在此就不赘述,网上有丰富的资源可以参考。

四、误操作、程序 bug 时怎么办

提出这个问题显然主要是针对刚开始工作的年轻同行们……实际上误操作和程序 bug 导致数据误删或者混乱的问题并非少见,但是刚入行的开发工作者会比较紧张。一个成熟的企业往往会有完善的数据管理规范和较丰富的数据恢复方案(初创公司除外),会进行数据备份和数据容灾。当你发现误操作或程序 bug 导致线上数据被误删或误改动时,一定不能慌乱,应及时与 DBA 联系,第一时间进行数据恢复(严重时直接停止服务),尽可能减少影响和损失。对于重要数据(如资金)的操作,在开发时一定要反复进行测试,确保没有问题后再上线。

完美数据迁移-MongoDB Stream的应用

一、背景介绍

最近微服务架构火的不行,但本质上也只是风口上的一个热点词汇。
作为笔者的经验来说,想要应用一个新的架构需要带来的变革成本是非常高的。

尽管如此,目前还是有许多企业踏上了服务化改造的道路,这其中则免不了”旧改”的各种繁杂事。
所谓的”旧改”,就是把现有的系统架构来一次重构,拆分成多个细粒度的服务后,然后找时间
升级割接一把,让新系统上线。这其中,数据的迁移往往会成为一个非常重要且繁杂的活儿。

拆分服务时数据迁移的挑战在哪?

  1. 首先是难度大,做一个迁移方案需要了解项目的前身今世,评估迁移方案、技术工具等等;

  2. 其次是成本高。由于新旧系统数据结构是不一样的,需要定制开发迁移转化功能。很难有一个通用的工具能一键迁移;

  3. 再者,对于一些容量大、可靠性要求高的系统,要能够不影响业务,出了问题还能追溯,因此方案上还得往复杂了想。

二、常见方案

按照迁移的方案及流程,可将数据迁移分为三类:

1. 停机迁移

最简单的方案,停机迁移的顺序如下:

未分类

采用停机迁移的好处是流程操作简单,工具成本低;然而缺点也很明显,
迁移过程中业务是无法访问的,因此只适合于规格小、允许停服的场景。

2. 业务双写

业务双写是指对现有系统先进行改造升级,支持同时对新库和旧库进行写入。
之后再通过数据迁移工具对旧数据做全量迁移,待所有数据迁移转换完成后切换到新系统。

示意图:

未分类

业务双写的方案是平滑的,对线上业务影响极小;在出现问题的情况下可重新来过,操作压力也会比较小。

笔者在早些年前尝试过这样的方案,整个迁移过程确实非常顺利,但实现该方案比较复杂,
需要对现有的代码进行改造并完成新数据的转换及写入,对于开发人员的要求较高。
在业务逻辑清晰、团队对系统有足够的把控能力的场景下适用。

3. 增量迁移

增量迁移的基本思路是先进行全量的迁移转换,待完成后持续进行增量数据的处理,直到数据追平后切换系统。

示意图:

未分类

关键点

  • 要求系统支持增量数据的记录。
    对于MongoDB可以利用oplog实现这点,为避免全量迁移过程中oplog被冲掉,
    在开始迁移前就必须开始监听oplog,并将变更全部记录下来。
    如果没有办法,需要从应用层上考虑,比如为所有的表(集合)记录下updateTime这样的时间戳,
    或者升级应用并支持将修改操作单独记录下来。

  • 增量数据的回放是持续的。
    在所有的增量数据回放转换过程中,系统仍然会产生新的增量数据,这要求迁移工具
    能做到将增量数据持续回放并将之追平,之后才能做系统切换。

MongoDB 3.6版本开始便提供了Change Stream功能,支持对数据变更记录做监听。
这为实现数据同步及转换处理提供了更大的便利,下面将探讨如何利用Change Stream实现数据的增量迁移。

三、Change Stream 介绍

Chang Stream(变更记录流) 是指collection(数据库集合)的变更事件流,应用程序通过db.collection.watch()这样的命令可以获得被监听对象的实时变更。
在该特性出现之前,你可以通过拉取 oplog达到同样的目的;但 oplog 的处理及解析相对复杂且存在被回滚的风险,如果使用不当的话还会带来性能问题。
Change Stream 可以与aggregate framework结合使用,对变更集进行进一步的过滤或转换。

由于Change Stream 利用了存储在 oplog 中的信息,因此对于单进程部署的MongoDB无法支持Change Stream功能,
其只能用于启用了副本集的独立集群或分片集群

监听的目标

未分类

变更事件

一个Change Stream Event的基本结构如下所示:

{
   _id : { <BSON Object> },
   "operationType" : "<operation>",
   "fullDocument" : { <document> },
   "ns" : {
      "db" : "<database>",
      "coll" : "<collection"
   },
   "documentKey" : { "_id" : <ObjectId> },
   "updateDescription" : {
      "updatedFields" : { <document> },
      "removedFields" : [ "<field>", ... ]
   }
   "clusterTime" : <Timestamp>,
   "txnNumber" : <NumberLong>,
   "lsid" : {
      "id" : <UUID>,
      "uid" : <BinData>
   }
}

字段说明

未分类

Change Steram支持的变更类型有以下几个:

未分类

利用以下的shell脚本,可以打印出集合 T_USER上的变更事件:

watchCursor=db.T_USER.watch()
while (!watchCursor.isExhausted()){
   if (watchCursor.hasNext()){
      printjson(watchCursor.next());
   }
}

下面提供一些样例,感受一下

insert 事件

{
    "_id": {
        "_data": "825B5826D10000000129295A10046A31C593902B4A9C9907FC0AB1E3C0DA46645F696400645B58272321C4761D1338F4860004"
    },
    "operationType": "insert",
    "clusterTime": Timestamp(1532503761, 1),
    "fullDocument": {
        "_id": ObjectId("5b58272321c4761d1338f486"),
        "name": "LiLei",
        "createTime": ISODate("2018-07-25T07:30:43.398Z")
    },
    "ns": {
        "db": "appdb",
        "coll": "T_USER"
    },
    "documentKey": {
        "_id": ObjectId("5b58272321c4761d1338f486")
    }
}

update事件

{
 "_id" : {
  "_data" : "825B5829DF0000000129295A10046A31C593902B4A9C9907FC0AB1E3C0DA46645F696400645B582980ACEC5F345DB998EE0004"
 },
 "operationType" : "update",
 "clusterTime" : Timestamp(1532504543, 1),
 "ns" : {
  "db" : "appdb",
  "coll" : "T_USER"
 },
 "documentKey" : {
  "_id" : ObjectId("5b582980acec5f345db998ee")
 },
 "updateDescription" : {
  "updatedFields" : {
   "age" : 15
  },
  "removedFields" : [ ]
 }
}

replace事件

{
    "_id" : {
        "_data" : "825B58299D0000000129295A10046A31C593902B4A9C9907FC0AB1E3C0DA46645F696400645B582980ACEC5F345DB998EE0004"
    },
    "operationType" : "replace",
    "clusterTime" : Timestamp(1532504477, 1),
    "fullDocument" : {
        "_id" : ObjectId("5b582980acec5f345db998ee"),
        "name" : "HanMeimei",
        "age" : 12
    },
    "ns" : {
        "db" : "appdb",
        "coll" : "T_USER"
    },
    "documentKey" : {
        "_id" : ObjectId("5b582980acec5f345db998ee")
    }
}

delete事件

{
    "_id" : {
        "_data" : "825B5827A90000000229295A10046A31C593902B4A9C9907FC0AB1E3C0DA46645F696400645B58272321C4761D1338F4860004"
    },
    "operationType" : "delete",
    "clusterTime" : Timestamp(1532503977, 2),
    "ns" : {
        "db" : "appdb",
        "coll" : "T_USER"
    },
    "documentKey" : {
        "_id" : ObjectId("5b58272321c4761d1338f486")
    }
}

invalidate 事件

执行db.T_USER.drop() 可输出

{
    "_id" : {
        "_data" : "825B582D620000000329295A10046A31C593902B4A9C9907FC0AB1E3C0DA04"
    },
    "operationType" : "invalidate",
    "clusterTime" : Timestamp(1532505442, 3)
}

更多的Change Event 信息可以参考这里https://docs.mongodb.com/manual/reference/change-events/

四、实现增量迁移

本次设计了一个简单的论坛帖子迁移样例,用于演示如何利用Change Stream实现完美的增量迁移方案。

背景如下:
现有的系统中有一批帖子,每个帖子都属于一个频道(channel),如下表

未分类

新系统中频道字段将采用英文简称,同时要求能支持平滑升级。
根据前面篇幅的叙述,我们将使用Change Stream 功能实现一个增量迁移的方案。

相关表的转换如下图:

未分类

原理

topic 是帖子原表,在迁移开始前将开启watch任务持续获得增量数据,并记录到 topic_incr表中;
接着执行全量的迁移转换,之后再持续对增量表数据进行迁移,直到无新的增量为止。

接下来我们使用Java程序来完成相关代码,mongodb-java–driver 在 3.6 版本后才支持 watch 功能
需要确保升级到对应版本:

<dependency>
     <groupId>org.mongodb</groupId>
     <artifactId>mongo-java-driver</artifactId>
     <version>3.6.4</version>
</dependency>

定义Channel频道的转换表

public static enum Channel {
    Food("美食"),
    Emotion("情感"),
    Pet("宠物"),
    House("家居"),
    Marriage("征婚"),
    Education("教育"),
    Travel("旅游")
    ;
    private final String oldName;

    public String getOldName() {
        return oldName;
    }

    private Channel(String oldName) {
        this.oldName = oldName;
    }

    /**
     * 转换为新的名称
     * 
     * @param oldName
     * @return
     */
    public static String toNewName(String oldName) {
        for (Channel channel : values()) {
            if (channel.oldName.equalsIgnoreCase(oldName)) {
                return channel.name();
            }
        }
        return "";
    }

    /**
     * 返回一个随机频道
     * 
     * @return
     */
    public static Channel random() {
        Channel[] channels = values();
        int idx = (int) (Math.random() * channels.length);
        return channels[idx];
    }
}

为 topic 表预写入1w条记录

private static void preInsertData() {
    MongoCollection<Document> topicCollection = getCollection(coll_topic);

    // 分批写入,共写入1w条数据
    int current = 0;
    int batchSize = 100;

    while (current < 10000) {
        List<Document> topicDocs = new ArrayList<Document>();

        for (int j = 0; j < batchSize; j++) {
            Document topicDoc = new Document();

            Channel channel = Channel.random();
            topicDoc.append(field_channel, channel.getOldName());
            topicDoc.append(field_nonce, (int) (Math.random() * nonce_max));

            topicDoc.append("title", "This is the tilte -- " + UUID.randomUUID().toString());
            topicDoc.append("author", "LiLei");
            topicDoc.append("createTime", new Date());
            topicDocs.add(topicDoc);
        }

        topicCollection.insertMany(topicDocs);
        current += batchSize;
        logger.info("now has insert {} records", current);
    }
}

上述实现中,每个帖子都分配了随机的频道(channel)

开启监听任务,将topic上的所有变更写入到增量表

MongoCollection<Document> topicCollection = getCollection(coll_topic);
MongoCollection<Document> topicIncrCollection = getCollection(coll_topic_incr);

// 启用 FullDocument.update_lookup 选项
cursor = topicCollection.watch().fullDocument(FullDocument.UPDATE_LOOKUP).iterator();
while (cursor.hasNext()) {

    ChangeStreamDocument<Document> changeEvent = cursor.next();
    OperationType type = changeEvent.getOperationType();
    logger.info("{} operation detected", type);

    if (type == OperationType.INSERT || type == OperationType.UPDATE || type == OperationType.REPLACE
            || type == OperationType.DELETE) {

        Document incrDoc = new Document(field_op, type.getValue());
        incrDoc.append(field_key, changeEvent.getDocumentKey().get("_id"));
        incrDoc.append(field_data, changeEvent.getFullDocument());
        topicIncrCollection.insertOne(incrDoc);
    }
}

代码中通过watch 命令获得一个MongoCursor对象,用于遍历所有的变更。
FullDocument.UPDATE_LOOKUP选项启用后,在update变更事件中将携带完整的文档数据(FullDocument)。

watch()命令提交后,mongos会与分片上的mongod(主节点)建立订阅通道,这可能需要花费一点时间。

为了模拟线上业务的真实情况,启用几个线程对topic表进行持续写操作;

private static void startMockChanges() {

    threadPool.submit(new ChangeTask(OpType.insert));
    threadPool.submit(new ChangeTask(OpType.update));
    threadPool.submit(new ChangeTask(OpType.replace));
    threadPool.submit(new ChangeTask(OpType.delete));
}

ChangeTask 实现逻辑如下:

while (true) {
    logger.info("ChangeTask {}", opType);
    if (opType == OpType.insert) {
        doInsert();
    } else if (opType == OpType.update) {
        doUpdate();
    } else if (opType == OpType.replace) {
        doReplace();
    } else if (opType == OpType.delete) {
        doDelete();
    }
    sleep(200);
    long currentAt = System.currentTimeMillis();
    if (currentAt - startAt > change_during) {
        break;
    }
}

每一个变更任务会不断对topic产生写操作,触发一系列ChangeEvent产生。

  • doInsert:生成随机频道的topic后,执行insert
  • doUpdate:随机取得一个topic,将其channel字段改为随机值,执行update
  • doReplace:随机取得一个topic,将其channel字段改为随机值,执行replace
  • doDelete:随机取得一个topic,执行delete

以doUpdate为例,实现代码如下:

private void doUpdate() {
    MongoCollection<Document> topicCollection = getCollection(coll_topic);

    Document random = getRandom();
    if (random == null) {
        logger.info("update skip");
        return;
    }

    String oldChannel = random.getString(field_channel);
    Channel channel = Channel.random();

    random.put(field_channel, channel.getOldName());
    random.put("createTime", new Date());
    topicCollection.updateOne(new Document("_id", random.get("_id")), new Document("$set", random));

    counter.onChange(oldChannel, channel.getOldName());
}

启动一个全量迁移任务,将 topic 表中数据迁移到 topic_new 新表

final MongoCollection<Document> topicCollection = getCollection(coll_topic);
final MongoCollection<Document> topicNewCollection = getCollection(coll_topic_new);

Document maxDoc = topicCollection.find().sort(new Document("_id", -1)).first();
if (maxDoc == null) {
    logger.info("FullTransferTask detect no data, quit.");
    return;
}

ObjectId maxID = maxDoc.getObjectId("_id");
logger.info("FullTransferTask maxId is {}..", maxID.toHexString());

AtomicInteger count = new AtomicInteger(0);

topicCollection.find(new Document("_id", new Document("$lte", maxID)))
        .forEach(new Consumer<Document>() {

            @Override
            public void accept(Document topic) {
                Document topicNew = new Document(topic);
                // channel转换
                String oldChannel = topic.getString(field_channel);
                topicNew.put(field_channel, Channel.toNewName(oldChannel));

                topicNewCollection.insertOne(topicNew);
                if (count.incrementAndGet() % 100 == 0) {
                    logger.info("FullTransferTask progress: {}", count.get());
                }
            }

        });
logger.info("FullTransferTask finished, count: {}", count.get());

在全量迁移开始前,先获得当前时刻的的最大 _id 值(可以将此值记录下来)作为终点。
随后逐个完成迁移转换。

在全量迁移完成后,便开始最后一步:增量迁移

注:增量迁移过程中,变更操作仍然在进行

final MongoCollection<Document> topicIncrCollection = getCollection(coll_topic_incr);
final MongoCollection<Document> topicNewCollection = getCollection(coll_topic_new);

ObjectId currentId = null;
Document sort = new Document("_id", 1);
MongoCursor<Document> cursor = null;

// 批量大小
int batchSize = 100;
AtomicInteger count = new AtomicInteger(0);

try {
    while (true) {

        boolean isWatchTaskStillRunning = watchFlag.getCount() > 0;

        // 按ID增量分段拉取
        if (currentId == null) {
            cursor = topicIncrCollection.find().sort(sort).limit(batchSize).iterator();
        } else {
            cursor = topicIncrCollection.find(new Document("_id", new Document("$gt", currentId)))
                    .sort(sort).limit(batchSize).iterator();
        }

        boolean hasIncrRecord = false;

        while (cursor.hasNext()) {
            hasIncrRecord = true;

            Document incrDoc = cursor.next();

            OperationType opType = OperationType.fromString(incrDoc.getString(field_op));
            ObjectId docId = incrDoc.getObjectId(field_key);

            // 记录当前ID
            currentId = incrDoc.getObjectId("_id");

            if (opType == OperationType.DELETE) {

                topicNewCollection.deleteOne(new Document("_id", docId));
            } else {

                Document doc = incrDoc.get(field_data, Document.class);

                // channel转换
                String oldChannel = doc.getString(field_channel);
                doc.put(field_channel, Channel.toNewName(oldChannel));

                // 启用upsert
                UpdateOptions options = new UpdateOptions().upsert(true);

                topicNewCollection.replaceOne(new Document("_id", docId),
                        incrDoc.get(field_data, Document.class), options);
            }

            if (count.incrementAndGet() % 10 == 0) {
                logger.info("IncrTransferTask progress, count: {}", count.get());
            }
        }

        // 当watch停止工作(没有更多变更),同时也没有需要处理的记录时,跳出
        if (!isWatchTaskStillRunning && !hasIncrRecord) {
            break;
        }

        sleep(200);
    }
} catch (Exception e) {
    logger.error("IncrTransferTask ERROR", e);
}

增量迁移的实现是一个不断 tail 的过程,利用 **_id 字段的有序特性 ** 进行分段迁移;
即记录下当前处理的 _id 值,循环拉取在 该 _id 值之后的记录进行处理。

增量表(topic_incr)中除了DELETE变更之外,其余的类型都保留了整个文档,
因此可直接利用 replace + upsert 追加到新表。

最后,运行整个程序

[2018-07-26 19:44:16] INFO ~ IncrTransferTask progress, count: 2160
[2018-07-26 19:44:16] INFO ~ IncrTransferTask progress, count: 2170
[2018-07-26 19:44:27] INFO ~ all change task has stop, watch task quit.
[2018-07-26 19:44:27] INFO ~ IncrTransferTask finished, count: 2175
[2018-07-26 19:44:27] INFO ~ TYPE 美食:1405
[2018-07-26 19:44:27] INFO ~ TYPE 宠物:1410
[2018-07-26 19:44:27] INFO ~ TYPE 征婚:1428
[2018-07-26 19:44:27] INFO ~ TYPE 家居:1452
[2018-07-26 19:44:27] INFO ~ TYPE 教育:1441
[2018-07-26 19:44:27] INFO ~ TYPE 情感:1434
[2018-07-26 19:44:27] INFO ~ TYPE 旅游:1457
[2018-07-26 19:44:27] INFO ~ ALLCHANGE 12175
[2018-07-26 19:44:27] INFO ~ ALLWATCH 2175

查看 topic 表和 topic_new 表,发现两者数量是相同的。
为了进一步确认一致性,我们对两个表的分别做一次聚合统计:

topic表

db.topic.aggregate([{
    "$group":{
        "_id":"$channel",
        "total": {"$sum": 1}
        }
    },
    {
        "$sort": {"total":-1}
        }
    ])

topic_new表

db.topic_new.aggregate([{
    "$group":{
        "_id":"$channel",
        "total": {"$sum": 1}
        }
    },
    {
        "$sort": {"total":-1}
        }
    ])

前者输出结果:

未分类

后者输出结果:

未分类

前后对比的结果是一致的!

五、后续优化

前面的章节演示了一个增量迁移的样例,在投入到线上运行之前,这些代码还得继续优化:

  • 写入性能,线上的数据量可能会达到亿级,在全量、增量迁移时应采用合理的批量化处理;
    另外可以通过增加并发线程,添置更多的Worker,分别对不同业务库、不同表进行处理以提升效率。
    增量表存在幂等性,即回放多次其最终结果还是一致的,但需要保证表级有序,即一个表同时只有一个线程在进行增量回放。

  • 容错能力,一旦 watch 监听任务出现异常,要能够从更早的时间点开始(使用startAtOperationTime参数),
    而如果写入时发生失败,要支持重试。

  • 回溯能力,做好必要的跟踪记录,比如将转换失败的ID号记录下来,旧系统的数据需要保留,
    以免在事后追究某个数据问题时找不着北。

  • 数据转换,新旧业务的差异不会很简单,通常需要借助大量的转换表来完成。

  • 一致性检查,需要根据业务特点开发自己的一致性检查工具,用来证明迁移后数据达到想要的一致性级别。

BTW,数据迁移一定要结合业务特性、架构差异来做考虑,否则还是在耍流氓。

六、小结

服务化系统中扩容、升级往往会进行数据迁移,对于业务量大,中断敏感的系统通常会采用平滑迁移的方式。
MongoDB 3.6 版本后提供了 Change Stream 功能以支持应用订阅数据的变更事件流,
本文使用 Stream 功能实现了增量平滑迁移的例子,这是一次尝试,相信后续这样的应用场景会越来越多。

Nginx、FPM配置及优化

Nginx配置

nginx的配置主要分为6个区域,main(全局设置),events(工作模式),http(http服务),upstream(负载均衡),server(主机设置),location(url规则)。

main
events {

}
http {
    upstream domain {

    }
    server {
        location {

        }
    }
}

main模块

user www www;
worker_processes 4;
worker_cpu_affinity 0001 0010 0100 1000;
error_log   /home/git/logs/error_log error;
pid        /usr/local/var/run/nginx/nginx.pid;
worker_rlimit_nofile 65535;
  • user 用来指定worker进程运行的用户及用户组
  • worker_processes 来指定开启的worker进程数,如果是多核CPU,建议指定和CPU的数量一样的进程数即可,这里的CPU数量指物理核数。
  • worker_cpu_affinity 将不同的woker进程绑定到不同的cpu,这里指绑定到CPU[0-3],降低由于多CPU核切换造成的寄存器等现场重建带来的性能损耗。
  • error_log 用来定义全局错误日志文件。日志输出级别有debug、info、notice、warn、error、crit可供选择,其中,debug输出日志最为最详细,而crit输出日志最少。
  • pid 指定master进程ID存储位置。
  • worker_rlimit_nofile 指定最多打开的文件描述符(fd),查看用户级fd限制(ulimit -n),查看系统级fd限制(cat /proc/sys/fs/file-max),系统fd与系统内存有关。

events 模块

events用来指定nginx的事件模型及连接数上限。

events {
    use epoll;
    worker_connections  65535;
    multi_accept on;
}
  • use 用来指定具体的事件模型,包括select、poll、epoll、kqueue、/dev/poll、eventport、rtsig,其中select、poll是标准的事件模型,epoll(用于linux平台)和kqueue(用于BSD平台)是高效的事件模型。
  • worker_connections 定义每个nginx进程接收的最大连接数,最大客户端连接数Max_clients = worker_processes * worker_connections / 2,而作为反向代理时,Max_clients = worker_processes * worker_connections / 4。
  • multi_accept 让NGINX在接收到一个新连接通知后调用accept()来接受尽可能多的连接。

http模块

负责http服务器的设置,包括虚拟主机和反向代理。

http{
    server_tokens off;
    include    mime.types;
    default_type  application/octet-stream;
    log_format  main '$http_host$clientip$time_local$request_time$request$status$body_bytes_sent$http_referer$http_user_agent';
    access_log  /home/git/logs/access_log  main;
    add_header DPOOL_HEADER $hostname;
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    keepalive_timeout 30;
    keepalive_requests 1024;

    send_timeout 10;
    client_body_timeout 10;
    client_header_timeout 10;

    map $http_x_forwarded_for $clientip {
            default $http_x_forwarded_for;
            ""      $remote_addr;
    }
    proxy_set_header X-Forwarded-For $clientip;

    proxy_redirect off;
    chunked_transfer_encoding off;
    proxy_set_header Host $host;
    proxy_ignore_client_abort on;

    proxy_connect_timeout 75;
    proxy_send_timeout 150;
    proxy_read_timeout 150;

    proxy_buffer_size 4k;
    proxy_buffers 4 32k;
    proxy_busy_buffers_size 64k;
    proxy_temp_file_write_size 64k;

    open_file_cache max=65535 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 3;

    client_header_buffer_size 4k;
    large_client_header_buffers 4 8k;
    client_max_body_size 8m;
    client_body_buffer_size 1024k;
    server_names_hash_bucket_size 256;

    gzip on;
    gzip_min_length 1k;
    gzip_buffers 4 16k;
    gzip_comp_level 2;
    gzip_proxied     expired no-cache no-store private auth;
    gzip_types text/plain text/css text/xml application/x-javascript application/json;
    gzip_vary on;

    fastcgi_temp_path /dev/shm/nginx_tmp;
    fastcgi_cache_path /dev/shm/nginx_cache levels=1:2 keys_zone=card_cache:20m inactive=5m max_size=1024m;
    fastcgi_cache_key "$request_method$host$request_uri";
    fastcgi_cache_min_uses 1;
    fastcgi_cache_methods GET HEAD POST;
    fastcgi_cache_bypass $cookie_nocache $arg_nocache;
    fastcgi_no_cache $cookie_nocache $arg_nocache;
    fastcgi_cache_use_stale error timeout http_500 http_404;
}
  • server_tokens off用来关闭nginx版本号显示。
  • include 用来设定文件的mime类型,类型在配置文件mime.types中定义。
  • default_type 设定默认类型为二进制,即当文件类型未定义时使用。
  • log_format 用于记录日志参数及格式,此处声明为main,用于access_log的记录。
  • add_header 增加自定义响应header头,header名为自定义,header值为主机名。

sendfile

  • zero-copy机制高效传输
  • 将tcp_nopush和tcp_nodelay两个指令设置为on即数据包积累到一定量时,尽快发送。

keepalive

  • client到nginx的长连接
    • keepalive_timeout 设置keep-alive客户端连接在服务器端保持开启的超时值。
    • keepalive_requests 设置一个keep-alive连接上可以服务的请求的最大数量,当最大请求数量达到时,连接被关闭。默认是100。
  • nginx到后端server的长连接(反向代理)

nginx
http {
upstream BACKEND {
server 192.168.0.1;
keepalive 300;
}
server {
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
}
  • upstream流配置keepalive,指定每个nginx worker到后端的最大连接数。
  • location 配置proxy_http_version 1.1(http1.1才支持keepalive), proxy_set_header Connection “”(清空客户端设置,即忽略client与nginx的连接方式)。

超时

  • send_timeout 指定向客户端传输数据的超时时间。
  • client_body_timeout 设定客户端与服务器建立连接后发送Request Body的超时时间,如果客户端在此期间没有发送任何内容,那么Nginx将返回Http 408错误(Request Time Out)。
  • client_header_timeout 设定客户端与服务器建立连接后发送Request Header的超时时间,如果在此期间没有发送数据,则同client_body_timeout一样返回HTTP408。

map

  • 是变量设置的映射表,映射表由两列组成,匹配模式和对应的值,匹配模式可以使正则。这里将clientip的值是从http_x_forwarded_for通过映射规则获取,从而获取到客户端真实的IP,而不是代理服务器IP(也可以使用nginx realip模块实现)。
  • “” 匹配当http_x_forwarded_for为空字符串时(第一次经过代理服务器)
  • default 在没有匹配到任何规则时执行(非第一次经过代理服务器)

proxy

  • proxy_ignore_client_abort 默认是关闭的,即请求过程中如果客户端端主动关闭请求或者客户端网络断掉,那么Nginx会记录499。
  • proxy_connect_timeout 定义了连接代理服务器的超时时间,一般情况下,这个值不超过75s。
  • proxy_read_timeout 定义了与代理服务器获读超时时间。
  • proxy_send_timeout 定义了与代理服务器写超时间时间。
  • proxy_buffer_size 用来响应头的缓冲区,一般4k就够了。
  • proxy_buffers 设置用来接收响应的缓冲区的数量和大小。
  • proxy_busy_buffers_size 设定高负荷下的缓冲区大小,建议为proxy_buffers中单个缓冲区大小的2倍。
  • proxy_max_temp_file_size和proxy_temp_file_write_size 指定临时文件的一次写入临时文件的大小及响应内容大于proxy_buffers指定的缓冲区时, 写入硬盘的临时文件的大小。

openfile

  • open_file_cache 缓存将最近使用的文件描述符和相关元数据(如修改时间,大小等)存储在缓存中,这里为1,000个元素定义了一个缓存,到期时间为20s。
  • open_file_cache_valid 定义时间段(以秒为单位),之后将重新验证open_file_cache中的元素。
  • open_file_cache_min_uses 将在非活动时间段之后从高速缓存中清除元素。 此指令可用于配置最小访问次数以将元素标记为活动使用。

client buffer

  • client_header_buffer_size和large_client_header_buffers 设置用于读取客户端请求头的缓冲区大小,先根据client_header_buffer_size配置的值分配一个buffer,如果分配的buffer无法容纳 request_line/request_header,那么就会再次根据large_client_header_buffers配置的参数分配large_buffer,如果large_buffer还是无法容纳,那么就会返回414(处理request_line)/400(处理request_header)错误。
  • client_max_body_size 设置nginx允许接收的客户端请求内容的最大值,及客户端请求Header头信息中设置的Content-Lenth大最大值。如果超出该指令设置的最大值,nginx将返回“Request Entity Too Large”的错误信息(HTTP的413错误码)
  • client_body_buffer_size 允许客户端请求的最大单个文件字节数。
  • server_names_hash_max_size 当配置多个server时需要开启。

Gzip

  • gzip on,开启Gzip,gzip可以帮助Nginx减少大量的网络传输工作。
  • gzip_min_length 设置允许压缩的页面最小字节数,页面字节数从header头中的Content-Length中进行获取。
  • gzip_buffers 设置系统获取几个单位的缓存用于存储gzip的压缩结果数据流。
  • gzip_comp_level gzip压缩比,1 压缩比最小处理速度最快,9 压缩比最大但耗CPU,超过2时,压缩率提升已经不明显。
  • gzip_types 匹配MIME类型进行压缩,(无论是否指定)”text/html” 类型总是会被压缩的。
  • gzip_vary 是否发送Header头Vary: Accept-Encoding响应头字段,通知接收方响应使用了gzip压缩。
  • gzip_proxied 根据某些请求和应答来决定是否在对代理请求的应答启用压缩。

fastcgi cache

  • fastcgi_temp_path 缓存文件的临时目录。
  • fastcgi_cache_path 用于设置缓存文件的存放路径。
    • levels:指定了该缓存空间有两层hash目录,设置缓存目录层数
    • keys_zone为这个缓存区起名为zone_name,20m指代缓存空间为20MB
    • inactive=5m 代表如果缓存文件5分钟内没有被访问,则删除
    • max_size代表最大缓存size
  • fastcgi_cache_methods 指定缓存的HTTP method。
  • fastcgi_cache_min_uses URL经过多少次访问被缓存。
  • fastcgi_cache_key 缓存的key名。
  • fastcgi_cache_use_stale 针对错误码的缓存。
  • fastcgi_no_cache和fastcgi_cache_bypass,通过set变量值控制指定参数的0/1,来控制缓存的使用,因为并不是所有情况下都需要缓存。

server模块

server {
    listen 80;
    root /home/git/www/;
    server_name xstudio.me yueqian.sinaapp.com;

    access_log /home/git/logs/access_log main;
    error_log /home/git/logs/error_log error;

    if ($uri !~ "^/(?:crossdomain.xml|favicon.ico|static/.*|robots.txt)$") {
        rewrite  ".*" /index.php last;
    }

    location ~* ^.+.(jpg|jpeg|gif|png|bmp|css|js)$ {
        access_log off;
        expires 1d;
        break;
    }

    location ~ .php$ {
        set $script_uri "";
        if ( $request_uri ~* "([^?]*)?" ) {
                set $script_uri $1;
        }
        fastcgi_param SCRIPT_URL $script_uri;
        fastcgi_pass 127.0.0.1:9001;
        include fastcgi_params;
    }
}
  • listen 监听的服务端口 后边加default_server指定默认虚拟主机。
  • server_name 用来指定IP地址或者域名。
  • root 表示在这整个server虚拟主机内,全部的root web根目录,区别于location下root。

正则

  • ~ 为区分大小写的匹配。
  • ~* 不区分大小写的匹配(匹配firefox的正则同时匹配FireFox)。
  • !~ 不匹配的。
  • ^~ 标识符后面跟一个字符串,将在这个字符串匹配后停止进行正则表达式的匹配。
  • = 表示精确的查找地址。

Rewrite

  • last :相当于Apache里德(L)标记,表示完成rewrite。
  • break;本条规则匹配完成后,终止匹配,不再匹配后面的规则。
  • redirect:返回302临时重定向。
  • permanent:返回301永久重定向。

文件缓存

  • expires 控制 HTTP 应答中的“ Expires ”和“ Cache-Control ”的头标
    • time:可以使用正数或负数。“Expires”头标的值将通过当前系统时间加上设定time值来设定。
    • time值还控制”Cache-Control”的值:
    • 负数表示no-cache
    • 正数或零表示max-age=time

FPM

  • fastcgi_param 设置fastcgi接收的参数,最终传递给PHP,SCRIPT_URL为url path。
  • fastcgi_pass fastcgi的转发地址。

HTTPS

http {
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;
    server {
        listen              443 ssl;
        server_name         www.example.com;
        keepalive_timeout   30;

        ssl_certificate     www.example.com.crt;
        ssl_certificate_key www.example.com.key; 

        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

        ssl_prefer_server_ciphers on;
        ssl_dhparam /etc/ssl/certs/dhparam.pem;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4";

        add_header X-Frame-Options DENY;
        add_header X-Content-Type-Options nosniff;
        add_header X-Xss-Protection 1;
        #...
  • ssl_session_timeout : 客户端可以重用会话缓存中ssl参数的过期时间
  • ssl_session_cache 设置ssl/tls会话缓存的类型和大小,nginx工作进程共享ssl会话缓存。
  • ssl_certificate证书其实是个公钥,它会被发送到连接服务器的每个客户端,ssl_certificate_key私钥是用来解密的,所以它的权限要得到保护但nginx的主进程能够读取。
  • add_header Strict-Transport-Security,使用 HSTS 策略强制浏览器使用 HTTPS 连接
    • max-age:设置单位时间内強制使用 HTTPS 连接
    • includeSubDomains:可选,所有子域同时生效
    • preload:可选,非规范值,用于定义使用『HSTS 预加载列表』
    • always:可选,保证所有响应都发送此响应头,包括各种內置错误响应
  • HTTP/HTTPS混合配置
  • nginx
    server {
    listen 80;
    listen 443 ssl;
    server_name www.example.com;
    ssl_certificate www.example.com.crt;
    ssl_certificate_key www.example.com.key;
    #…
    }

upstream模块

在多个应用实例间做负载均衡是一个被广泛使用的技术,用于优化资源效率,最大化吞吐量,减少延迟和容错。nginx可以作为一个非常高效的HTTP(7层)负载均衡器来分发请求到多个应用服务器,并提高web应用的性能,可扩展性和可靠性。
nginx支持以下负载均衡机制(或者方法):
– round-robin/轮询: 到应用服务器的请求以round-robin/轮询的方式被分发
– least-connected/最少连接:下一个请求将被分派到活动连接数量最少的服务器
– ip-hash/IP散列: 使用hash算法来决定下一个请求要选择哪个服务器(基于客户端IP地址)

默认轮询(加权)

http {
    upstream myapp1 {
        server srv1.example.com weight=3;
        server srv2.example.com;
        server srv3.example.com;
    }

    server {
        listen 80;
        location / {
            proxy_pass http://myapp1;
        }
    }
}

在这个配置中,每5个新请求将会如下的在应用实例中分派: 3个请求分派去srv1,一个去srv2,另外一个去srv3.

最小连接数

当某些请求需要更长时间来完成时,最少连接可以更公平的控制应用实例上的负载。

upstream myapp1 {
    least_conn;
    server srv1.example.com  weight=3;
    server srv2.example.com;
    server srv3.example.com;
}

IP Hash

请注意,在轮询和最少连接负载均衡方法中,每个客户端的后续请求被分派到不同的服务器。对于同一个客户端没有任何方式保证发送给同一个服务器。如果需要将一个客户端绑定给某个特定的应用服务器,那么可以使用ip-hash负载均衡机制。

upstream myapp1 {
    ip_hash;
    server srv1.example.com;
    server srv2.example.com;
    server srv3.example.com;
}

FPM配置

global全局配置

[global]
pid = /home/git/php/var/run/php-fpm.pid
error_log = /home/git/logs/php-fpm.log
log_level = notice
rlimit_files = 65535
events.mechanism = epoll
daemonize = yes
process_control_timeout = 10
include=/home/git/php/etc/php-fpm.d/*.conf
  • pid主进程pid文件
  • error_log 错误日志文件
  • log_level 错误级别. 可用级别为: alert(必须立即处理), error(错误情况), warning(警告情况), notice(一般重要信息), debug(调试信息). 默认: notice.
  • daemonize 后台执行FPM
  • rlimit_files 设置文件打开描述符的rlimit限制。
  • events.mechanism 使用处理event事件的机制,可用以下选项:select、pool、epoll、kqueue (*BSD)、port (Solaris)。 默认值:不设置(自动检测)。
  • process_control_timeout 设置子进程接受主进程复用信号的超时时间,默认为0时,FPM无法真正实现平滑重启。具体看上一篇文章《详解nginx及FPM平滑重启》。
  • include 用于包含一个或多个配置文件

进程池配置

通过监听不同的端口可以定义多个不同的子进程池,进程池被用与记录和统计,对于fpm能够处理进程池数目的多少并没有限制,其中$pool变量可以在任何指令中使用,他将会替代相应的进程池名字。

[www]
user=git
group=git
listen = 127.0.0.1:9001
listen.allowed_clients = 127.0.0.1

pm = dynamic
pm.max_children = 100
pm.start_servers = 60
pm.min_spare_servers = 30
pm.max_spare_servers = 100
pm.max_requests = 500

slowlog = /home/git/logs/slow_log
request_slowlog_timeout = 10
catch_workers_output = yes

php_admin_value[open_basedir] = "./:/home/git/$pool/:/tmp/xhprof/"

env[SRV_SERVER_ROOT]   = /home/git/$pool/
env[SRV_DEVELOP_LEVEL] = 4
  • user和group指定worker运行的用户及组
  • listen指定监听的IP和端口
  • listen.allowwd_clients 设置允许连接到 FastCGI 的服务器 IPV4 地址,多个地址用’,’分隔,为空则允许任何地址发来链接请求。
  • pm 设置进程管理器如何管理子进程。可用值:static,ondemand,dynamic。必须设置。
    • static – 子进程的数量是固定的(pm.max_children)。
    • ondemand – 进程在有需求时才产生(当请求时才启动。与 dynamic 相反,在服务启动时 pm.start_servers 就启动了。
    • dynamic – 子进程的数量在下面配置的基础上动态设置。
  • pm.max_children pm 设置为 static 时表示创建的子进程的数量,pm 设置为 dynamic 时表示最大可创建的子进程的数量。
  • pm.start_servers 设置启动时创建的子进程数目。
  • pm.min_spare_servers 设置空闲服务进程的最低数目。
  • pm.max_spare_servers 设置空闲服务进程的最大数目。
  • pm.max_requests 最大处理请求数是指一个php-fpm的worker进程在处理多少个请求后就终止掉,master进程会重新派生一个新的。这个配置的主要目的是避免php解释器或程序引用的第三方库造成的内存泄露。
  • request_slowlog_timeout 当一个请求超过该设置的时间后,就会将对应的PHP调用堆栈信息完整写入到慢日志中。
  • slowlog 慢日志文件。
  • catch_workers_output 重定向运行过程中的stdout和stderr到主要的错误日志文件中. 如果没有设置, stdout 和 stderr 将会根据FastCGI的规则被重定向到 /dev/null。
  • php_admin_value 设定PHP配置值,且不会被php ini_set覆盖掉,open_basedir 定义php有权限读写的目录,$pool的值为进程池名字(www)。
  • env 设定环境变量,例如:根目录、开发/测试环境区分等,PHP可以通过$_SERVER读取。

正确的记录日志

看过本章第一节的同学应该还记得,log_by_lua* 是一个请求经历的最后阶段。由于记日志跟应答内容无关,Nginx 通常在结束请求之后才更新访问日志。由此可见,如果我们有日志输出的情况,最好统一到 log_by_lua* 阶段。如果我们把记日志的操作放在 content_by_lua* 阶段,那么将线性的增加请求处理时间。

在公司某个定制化项目中,Nginx 上的日志内容都要输送到 syslog 日志服务器。我们使用了lua-resty-logger-socket这个库。

调用示例代码如下(有问题的):

-- lua_package_path "/path/to/lua-resty-logger-socket/lib/?.lua;;";
--
--    server {
--        location / {
--            content_by_lua_file lua/log.lua;
--        }
--    }

-- lua/log.lua
local logger = require "resty.logger.socket"
if not logger.initted() then
    local ok, err = logger.init{
        host = 'xxx',
        port = 1234,
        flush_limit = 1,   --日志长度大于flush_limit的时候会将msg信息推送一次
        drop_limit = 99999,
    }
    if not ok then
        ngx.log(ngx.ERR, "failed to initialize the logger: ",err)
        return
    end
end

local msg = string.format(.....)
local bytes, err = logger.log(msg)
if err then
    ngx.log(ngx.ERR, "failed to log message: ", err)
    return
end

在实测过程中我们发现了些问题:

缓存无效:如果 flush_limit 的值稍大一些(例如 2000),会导致某些体积比较小的日志出现莫名其妙的丢失,所以我们只能把 flush_limit 调整的很小
自己拼写 msg 所有内容,比较辛苦

那么我们来看lua-resty-logger-socket这个库的 log 函数是如何实现的呢,代码如下:

function _M.log(msg)
   ...

    if (debug) then
        ngx.update_time()
        ngx_log(DEBUG, ngx.now(), ":log message length: " .. #msg)
    end

    local msg_len = #msg

    if (is_exiting()) then
        exiting = true
        _write_buffer(msg)
        _flush_buffer()
        if (debug) then
            ngx_log(DEBUG, "Nginx worker is exiting")
        end
        bytes = 0
    elseif (msg_len + buffer_size < flush_limit) then  -- 历史日志大小+本地日志大小小于推送上限
        _write_buffer(msg)
        bytes = msg_len
    elseif (msg_len + buffer_size <= drop_limit) then
        _write_buffer(msg)
        _flush_buffer()
        bytes = msg_len
    else
        _flush_buffer()
        if (debug) then
            ngx_log(DEBUG, "logger buffer is full, this log message will be "
                    .. "dropped")
        end
        bytes = 0
        --- this log message doesn't fit in buffer, drop it

        ...

由于在 content_by_lua* 阶段变量的生命周期会随着请求的终结而终结,所以当日志量小于 flush_limit 的情况下这些日志就不能被累积,也不会触发 _flush_buffer 函数,所以小日志会丢失。

这些坑回头看来这么明显,所有的问题都是因为我们把 lua/log.lua 用错阶段了,应该放到 log_by_lua* 阶段,所有的问题都不复存在。

修正后:

 lua_package_path "/path/to/lua-resty-logger-socket/lib/?.lua;;";

    server {
        location / {
            content_by_lua_file lua/content.lua;
            log_by_lua_file lua/log.lua;
        }
    }

这里有个新问题,如果我的 log 里面需要输出一些 content 的临时变量,两阶段之间如何传递参数呢?

方法肯定有,推荐下面这个:

    location /test {
        rewrite_by_lua_block {
            ngx.say("foo = ", ngx.ctx.foo)
            ngx.ctx.foo = 76
        }
        access_by_lua_block {
            ngx.ctx.foo = ngx.ctx.foo + 3
        }
        content_by_lua_block {
            ngx.say(ngx.ctx.foo)
        }
    }

更多有关 ngx.ctx 信息,请看这里https://github.com/openresty/lua-nginx-module#ngxctx

为所有PHP-FPM容器构建单独的Nginx Docker镜像

最近,原文作者一直在使用Docker容器来开发PHP微服务套件。一个问题是PHP应用已经搭建,可以和PHP-FPM和Nginx(取代了简单的Apche/PHP环境)一起工作,因此每个PHP微服务需要两个容器(以及两个Docker镜像):一个PHP-FPM容器和一个NGinx容器。
这个应用运行了6个以上的服务,如果做个乘法,在开发和生产之间会有约30个容器。作者决定构建一个单独的NGinx Docker镜像,它可以使用PHP-FPM的主机名作为环境变量并运行单独的配置文件,而没有为每个容器构建单独的NGinx镜像。

未分类

在本文中,原文作者简要说明从上图中的方法1到方法2的转换,最后采用的方案中采用了一种新的定制Docker镜像。该镜像的代码是开源的,如果读者碰到类似问题,可以随时签出该部分代码。

为什么用 NGinx?

NGinx和PHP-FPM配合使用能使PHP应用的性能更好,但不好的是和PHP Apache镜像不同,PHP-FPM Docker镜像缺省并没有和NGinx进行绑定。如果需要通过NGinx容器和PHP-FPM连接,需要在NGind配置里为该后端增加DNS记录。比如,如果名为php-fpm-api的PHP-FPM容器正在运行,NGinx配置文件应该包含下面部分:

    location ~ .php$ {
        fastcgi_split_path_info ^(.+.php)(/.+)$;
        # This line passes requests through to the PHP-FPM container
        fastcgi_pass php-fpm-api:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }

如果只服务于单独的NGinx容器,NGinx配置中容器名字写死还可以接受,但如上所述,需要允许多个NGinx容器,每个对应于一个PHP服务。创建一个新的NGinx镜像(以后需要进行维护和升级)会有些痛苦,即使管理一批不同的数据卷,仅仅改变变量名看起来也有很多工作。

第一种方案: 使用Docker文档中的方法

最初,作者认为这会很简单。Docker文档中有少许的几个章节讨论如何使用envsubst来完成该工作,但不幸的是,在其NGinx配置文件中,这种方法不奏效。

vhosts.conf

server {
    listen 80;
    index index.php index.html;
    root /var/www/public;
    client_max_body_size 32M;
    location / {
        try_files $uri /index.php?$args;
    }
    location ~ .php$ {
        fastcgi_split_path_info ^(.+.php)(/.+)$;
        fastcgi_pass ${NGINX_HOST}:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

该vhosts.conf文件使用了NGinx内置变量,因此当依照文档运行Docker命令(/bin/bash -c “envsubst < /etc/nginx/conf.d/mysite.template > /etc/nginx/conf.d/default.conf && nginx -g ‘daemon off;’”)时,得到错误提示$uri和$fastcgi_script_name没有定义。这些变量通常通过NGinx传入,因此不能简单的识别出他们是什么并传给自身,而且这使容器的动态性变差。

用另一个Docker镜像来救急,差点成功

接下来,作者开始研究不同的NGinx镜像。找到的两个,但它们都在随后的几年中都没有任何更新。作者开始使用martin/nginx,试图找到可以工作的原型。
Martin镜像和其它镜像有点不一样,因为它要求特定的文件夹结构。在root下增加Dockerfile:

FROM martin/nginx

接下来,我添加了一个app/空目录和conf/目录,conf/目录下只有一个文件vhosts.conf:

server {
    listen 80;
    index index.php index.html;
    root /var/www/public;
    client_max_body_size 32M;
    location / {
        try_files $uri /index.php?$args;
    }
    location ~ .php$ {
        fastcgi_split_path_info ^(.+.php)(/.+)$;
        fastcgi_pass $ENV{"NGINX_HOST"}:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

这个文件和之前的配置文件几乎一样,除了有一行的改动:

fastcgi_pass $ENV{“NGINX_HOST”}:9000;。现在想要启动带命名为php-fpm-api的PHP容器的NGinx容器,就可以构建一个新的镜像,让它在以下环境变量下运行:

docker build -t shiphp/nginx-env:test .
docker run -it --rm -e NGINX_HOST=php-fpm-api shiphp/nginx-env:test

它可以正常工作了。但是,这种方法有两个困扰的地方:
1. 正在使用的基础镜像已经有两年了。这会引入安全和性能风险。
2. 有个空的/app目录看起来并不必需,因为文件会被存储在一个不同的目录中。

最终解决方案

作者认为作为定制解决方案,从Martin镜像开始比较好,因此给项目建了分叉,创建了新的NGinx基础镜像并修复了上述两个问题。现在,如果要在NGinx容器中允许动态命名的后端,可以参照:

# 从Docker Hub得到最新版本
docker pull shiphp/nginx-env:latest
# 运行名为"php-fpm-api"的PHP容器 
docker run --name php-fpm-api -v $(pwd):/var/www php:fpm
# 允许链接到PHP-FPM容器的NGinx容器
docker run --link php-fpm-api -e NGINX_HOST=php-fpm-api shiphp/nginx-env

如果想增加自己的文件或NGinx配置文件,来定制镜像,用Dockerfile来扩展它就可以:

FROM shiphp/nginx-env

ONBUILD ADD <PATH_TO_YOUR_CONFIGS> /etc/nginx/conf.d/

...

现在所有的PHP-FPM容器都使用了它们自己的Docker镜像实例,这样在升级NGinx,改变权限或做某些调整时,就变得非常轻松了。 所有的代码都在Github上,如果读者看到任何问题或有改进建议,可以直接创建一个问题单。如果有疑问或任何Docker相关的,可以在Twitter上找到我继续探讨。

查看英文原文:https://www.shiphp.com/blog/2018/nginx-php-fpm-with-env

MySQL数据库如何实现多字段过滤

我国移动互联网进入了飞速发展阶段,互联网人才日益受到企业的重视,其中PHP开发人才便是其中之一,在互联网旅游、金融、餐饮、娱乐、社交等一些新兴企业与软件开发企业中,PHP开发岗位相对占有核心地位,今天给大家分享的技术知识是——mysql数据库如何实现多字段过滤。

1. 多字段过滤查询

类比现实:查询公司中户籍是北京的、年龄超过30岁、性别是男的同事信息;

查询场景:查询商品名称是’King doll’、商品价格是9.49的商品。

查询SQL:

SELECT prod_id, prod_name, prod_price F ROM Products W HERE prod_name = 'King doll' AND prod_price = 9.49; 

查询结果:

未分类

2. 多字段过滤连接

WHERE字句中有多个字段进行查询过滤,过滤条件如何连接的呢?MySql允许给出多个WHERE字句进行过滤,它们可以使用AND或者OR进行连接!

AND连接类比现实:飞鹰小学5年2班身高超过1米3、不戴眼镜的男同学去操场上跑步。根据上述描述可以得出结论必须同时满足身高超过1米3、不戴眼镜、男生这三个条件的同学才需要去操场跑步,任何一个条件不满足都不用去操场跑步。

OR连接类比现实:飞鹰小学5年2班身高超过1米3或不戴眼镜的同学去操场上跑步,根据上述描述可以得出结论只要满足身高超过1米3,或不戴眼镜这两个条件中任意一个条件的学生就要去操场跑步,即身高超过1米3的同学要去跑步,不戴眼镜的同学要去跑步。只要满足任意一个条件就需要去操场可以!

2.1 AND操作符

AND运算符作用?用来指示检索满足所有给定条件的行。如果需要同时满足多个过滤条件,只需要在过滤条件之间添加AND即可。

测试案例:查询商品单价在2-5之间,商品数量大于等于10的订单数据。
测试SQL:

SELECT * FROM OrderItems W HERE item_price B ETWEEN 2 AND 5 AND quantity >= 10 ORDER BY order_num DESC; 

测试结果:

未分类

结果说明:如果有多个过滤条件需要同时满足,那么只需要在哪些过滤条件之间加上AND关键字即可,查询条件理论上个数不限!

2.2 OR操作符

OR操作符作用?用来指示检索满足任一给定条件的行。如果有多个过滤条件,那么需要过滤条件之间添加OR即可。

测试案例:查询商品单价在3-5之间,或商品数量大于等于200的订单数据。

测试SQL:

SELECT * F ROM OrderItems W HERE item_price B ETWEEN 3 AND 5 OR quantity >= 200 ORDER BY order_num DESC; 

测试结果:

未分类

结果说明:只要满足商品单价在[3,5]之间,或者商品数量大于等于200的订单都满足条件。

2.3 AND和OR进行对比

AND必须满足全部条件,OR只需要满足任一条件。

类比理解:现在有一群黑色和白色的公企鹅,如果取走黑色母企鹅,即SELECT * FROM 企鹅 WHERE 颜色=黑色 AND 性别=母是查询不到企鹅的,因为两个条件必须同时满足,性别=母是没有;如果要取走颜色是白色或性别是母的企鹅,即SELECT * FROM 企鹅 WHERE 颜色=白色 OR 性别=母,那么就可以将白色公企鹅查询出来。

2.4 执行次序

将AND和OR结合使用进行复杂的数据过滤,那么就会出现执行次序的问题。

类比现实:比如小学中学习有括号四则混合运算,那么运算就要满足一定
顺序;比如公司中查询月薪超过10w,并且职位是管理层或开发者的员工。

测试情景:查询商品单价是3.49,商品编号是BNBG01或BNBG03的订单。

分析思考:查询商品单价必须满足3.49,而商品编号只需要满足BNBG01或BNBG03任一个即可。

测试SQL:

SELECT * F ROM OrderItems W HERE item_price B ETWEEN 3 AND 5 OR quantity >= 200 ORDER BY order_num DESC; 

测试结果:

未分类

结果分析:

  1. 数据没有满足我们的预期,为什么呢?单价必须等于3.49。
  2. 在SQL的世界中AND运算符优先于OR运算符,好比乘法运算优先于加减法运算先执行。
  3. SELECT * F ROM OrderItems W HERE item_price=3.49 AND prod_id='BNBG01' OR prod_id='BNBG03';实际查询的结果是单价等于3.49并且商品编号是’BNBG01’的订单,或者商品编号是’BNBG03’的订单,所以和我们预期是不一样的!

如何解决AND和OR的顺序问题呢?使用圆括号明确地分组进行相应的操作。
测试SQL:

SELECT * F ROM OrderItems W HERE item_price= 3.49 AND (prod_id = 'BNBG01' OR prod_id= 'BNBG03') ; 

测试结果:

未分类

结果分析:

  1. 从查询结果中可以看到,(prod_id=’BNBG01′ OR prod_id=’BNBG03′)作为1个整体变成1个执行单元;
  2. 圆括号的优先级高于AND,AND优先级高于OR;
  3. 如果查询过滤条件过多,使用AND或者OR,那么就应该使用圆括号明确地分组操作,不要以来默认地计算次序!使用圆括号的好处在于可消除歧义,增强可读性。