目前我们日常使用互联网应用几乎都应用了个性化推荐技术,但是它的推广应用却不是一蹴而就的。
第一阶段
2012年今日头条上线,第一个利用推荐系统进行流量分发的内容APP
第二阶段
2015年淘宝双11全面开启“千人千面”时代
现在
知乎、抖音、美团等各大应用都在使用推荐系统进行个性化内容推荐
所见即所需,不再需要用户从成千上万的内容中去挑选自己感兴趣的。
精准推广,用户成交冲动更强。
因为推荐系统整个的技术难度较大,一般都是算法专业且有经验的团队来做。实践中可以分3个阶段来做:千人几面——千人百面——千人千面
协同过滤不单单只根据自己的喜好,而且还引入了相同行为的人的喜好来进行推荐,即我喜欢的内容,他也喜欢,那么他喜欢的其他内容我可能也很喜欢。以人类人这样推荐更加充分,而且可以深入挖掘用户潜在的兴趣。
那怎么计算内容相似度呢?
首先提取出内容的特征,又通计算得出了用户喜欢的特征,那么可以通过余弦相似度计算出内容间的相似度,做为个性化推荐的依据。余弦相似度是指通过计算两个向量的夹角余弦值来评估他们的相似度,夹角越小,两个向量越相似;夹角越大,两个向量越不同。
业务规则是指:设置一个或多个条件,当满足这些条件时会触发一个或多个操作。多个业务规则组成决策树,通常使用 DSL 语言来表述。
特定场景可以代替人工自动化决策,标准统一,错误率低。
现所有业务决策逻辑都是通过 if-else、switch 的方式硬编码在业务代码中,都要经过需求->开发->测试->上线的周期,时间长。
功能复用,减少重复开发。
通过业务数据分析,可以抽象出用户异常行为的规则:
然后,风控系统在判断是否为风险操作时,只需要规则引擎加载并执行风控规则,即可得到结果。
想要提高风控系统的准确性,只需要不断地迭代完善风控规则。
拿最常见的抽奖和做任务 2 种运营活动来说,都可以将具体活动逻辑抽象为业务规则:
① 抽奖,不同的人&不同的场景对应不同的奖池(中奖概率与奖品集合规则);
② 做任务,任务领取规则、任务完成指标动态可配(任务规则);
针对某些特定的用户或者某种场景的用户,下发特定的展示内容或者推送短信等触达消息,都可以将这些特定用户的逻辑梳理为内容分发规则。
只要是有明确业务规则的逻辑,且规则数量较大的场景,都可以运用规则引擎来解决,比如商品推荐、自动审核、内容分发的场景。
医生带货推荐商品
特定医生推荐特定商品
处方系统前置审核
特定诊断有什么特定审核规则
运营活动
特定用户做什么任务领取什么奖励
A/B test平台
特定用户下发特定内容
解决计算海量数据计算量大、延迟高的性能问题,如用户画像需要计算大量业务和事件数据的场景。
对平台积累的大量数据进行挖掘分析,创造出潜在的商业价值。
Flink 是一个分布式计算引擎,可以用来做批处理,即处理历史数据;也可以用来做流处理,即实时地处理数据流,并实时地产生数据的结果。
Flink 特点:
Flink 支持丰富的数据源,能满足大部分的业务场景需求。数据输入源支持 MySQL、Mongo 等数据库,也支持 Kafka 等事件消息中心;数据输出源支持 MySQL、Mongo、Redis 等数据库,也支持 Hbase、Hive 等数据仓库。
Flink 已成为了大数据计算引擎的首选,国内各大厂都有相应的落地实践案例,技术比较成熟。各云厂商也都提供了相应的云计算产品:
Flink 数据输入端接入 MySQL 数据库和 Kafka 消息中心,能同时对业务数据和业务事件 2 种数据类型进行处理。
Flink 计算输出结果一般存储在 Hive、Hbase 等大数据数仓。 因此,接入已有系统数据源并不需要做任何改造。
数据的计算逻辑任务,一般都会选择 SQL 作业的类型。
对开发者友好,只需要用 SQL 表达出对应逻辑即可。
如图所示,交易数据、处方数据、用户基础数据、用户行为数据作为数据源,经过大数据 Flink 计算清洗、结构化、预处理后,产出疾病特征、基本属性、行为特征画像数据。
借助于 Flink,这一切只需要编写 SQL 即可,数据的处理、分布式调度我们都不需要关心。
]]>用公式可以表达出风控规则和风险数据的系统关系:z=f(x, y),f 为系统风控规则,x 为系统实时输入风险数据,y 为系统的事实数据。
基于大数据实时计算和可热更新的通用规则引擎,搭建一套业务风控系统。
业务风险:刷单(订单)、薅羊毛(活动)、恶意注册和异常登录(用户)
业务服务:
系统引擎:
消息中心:各系统之间通过事件驱动,选用 Kafka
存储:
包含 3 个数据流。
风控规则通常分 2 种,即统计规则和主体属性规则。都可以抽象为通用公式:
下文将以 1天内同一患者ID订单数超过5笔 规则进行示例和说明。
Flink 输入数据为 JSON 格式,Flink 的数据源有 2 种:
指标数据异构,用空间换时间。Redis 的 zset 结构,通过 ZCOUNT key startTime endTime
操作即可统计任意时间段 startTime 至 endTime 内的统计需求。
规则 | 实现 | 写操作 | 读操作 |
---|---|---|---|
1天内同一患者ID订单数超过5笔 | key:患者id value:订单id score:下单时间 | ZADD O(M*log(N) | ZCOUNT O(log(N)+M) |
形如 1天内同一患者ID订单数超过5笔 规则,数据指标存储格式:
risk:order-patient-id:123456 |
统计指标为:
// startTime 和 endTime 对应为1天时间间隔 |
随着时间的推移,zset 会出现元素越来越多的情况,后续可以通过定期升级 key 版本号的方式来解决,每次升级版本号之后需要批处理初始化所有指标数据。
选用 Flink 的 SQL 作业类型,见 创建 SQL 作业。
形如 1天内同一患者ID订单数超过5笔 规则,定义源表和目标表是为了 SQL 中方便使用。
定义 MySQL 数据源表,字段跟数据表一一对应映射。
CREATE TABLE `risk_input_order` ( |
定义 Redis 目标表,对应 ZADD key value score
操作写入数据。
CREATE TABLE `risk_output_order_patient_id` ( |
直接使用 SQL 来清洗和合并数据。
-- 清洗患者id维度订单数据 |
在首次初始化指标数据或者新增数据指标的场景下,需要支持读取全量和增量数据,流批一体后,这样无需维护两套流程。
其实要做到流批一体,只需要 Flink 源表历史数据的过期时间不小于指标数据统计周期即可。系统 Kafka 消息过期时间增大为 30 天,则指标数据统计周期最大也为 30 天(已经满足风控规则要求),因此系统是可以做到流批一体的。
因为在风控场景,规则中的指标数据只需要最近统计周期时间的数据,可以直接重置 Kafka 消息位点来批处理源表历史数据,即可清洗出对应的指标数据。
CREATE TABLE `risk_input_order` ( |
封装规则引擎形成 risk-service 服务,供业务直接调用。
风险识别流程:
对外提供业务风险识别接口、业务数据或事件上报接口。
// 订单风控 |
很多的规则形成规则集,规则集组成一颗决策树,决策树是规则引擎核心的判断逻辑。
是一种自定义的 DSL 语法。支持运算符、支持基础数据类型、支持条件语句、支持并发语句块、并支持结构体和方法注入。
// 规则名必须唯一 |
形如 一天内同一患者ID订单数超过5笔 规则,规则体语法为:
rule "pa-daily-order-count" "一天内同一患者ID订单数规则" salience 10 |
通过对规则引擎注入预定义的结构体和方法,可以实现在规则体中获取事实和指标数据。
// 获取事实数据 |
// 获取指标数据函数 |
支持并行模式和混合模式执行,目前只考虑并行模式。
dataContext := context.NewDataContext() |
管理系统包含惩罚系统和分析系统。系统功能如下:
一个完整的风控规则发布流程:
研发工程师可以在管理后台很方便地编写规则,并支持版本管理:
只需要做 2 件事:
因为业务系统会同步调用风控系统进行风险识别,如果风控系统不可用时,则业务系统也不可用,因此需要系统降级措施。
关键词:不轴
这一年信息量还是挺大,做了很多项目,但是业务产出较少(有一些外部因素),职级也未能得到晋升。经过这一年的时间沉淀,不再自以为是,以更开放的心态接受身边事物的改变。
不再以为技术最牛逼,更多考虑的是用户体验,跟产品撕逼的情况少了,愿意去提供更好的方案,更会站在产品的角度去懂得产品同学的想法。业务第一,产品第二,技术第三,技术赋能产品,产品驱动业务,一切都是为了业务。
最近有一个业务需求,需求前期阶段就同产品同学对接过几次,确定了可行方案。但是到了需求评审阶段,又进行了多次修改调整,需求评审时间跨度1周+,这就导致剩余给开发和测试的时间只有 1 周了。项目参与同学怨声载道,不满情绪较多。
这时我就陷入了两难境地,为了让项目参与同学解气也一起喷产品同学(你这需求能不能行了),还是试图让项目同学理解这种特殊情况(项目同学:理解个屁啊,开发时间这么短,谁能理解我啊,到时候上不了线又说技术同学不给力)?
我是项目技术 Owner,要解决这种僵局需要多做少说,解释就是掩饰,只能想办法解决需求变更的原因。先拉上产品同学从技术视角提供可行的解决方案,从用户视角优化流程体验,确定最终的产品核心流程链路,这些一期先上线不能再变动。
其他的统统放后续优化迭代,在一期上线之后再处理。
这一年在上游产品输出一般的情况下,我们抽了大部分时间做了系统重构,填了很多坑(也又埋了一些坑),业务完成了收口,也为后续业务迭代打好基础。超过 85% 的业务已完成系统重构,2 个重构项目获得了标杆 OKR。
怎么选定重构项目?
从实际出发,是业务或开发痛点的才去重构,不要为了重构而重构。
怎么实施重构?
不着急大步推进重构,拆分成不同的迭代周期,每期的任务是具体的,是可以完成,是进度可控的。另外找到合适的时间点去做重构,借力合适的业务需求去做重构,就会事半功倍。
合适的时间点去做重构,有这样一个例子:
由于历史原因,我们有个问诊系统,对接了百度、腾讯且各自为独立系统,算上我们自己的系统有 3 套类似功能的系统,在 APP 端也是不同的功能流程。这样用户体验不好,开发维护体验也不好。
之前我们团队一直在小步重构自己的这套系统(功能已兼容其他 2 套系统),而其他2套对接三方的系统重构优先级放的比较低。
直到有一天,业务团队说三方对接业务暂停了,借此时机我们快速推进了问诊系统重构,把 APP 端的功能统一为一个流程了。
重构过程中,我比较关注怎么为以后的开发提效:
重构后有什么收获?
人员更熟悉业务了
之前由于人员流动,部分新同学进来,加上业务文档缺失,短期没人熟悉业务,导致问题频出。重构前,会先梳理出业务文档和技术方案,有了文档沉淀。重构后,团队参与重构的同学也熟悉业务了。
历史包袱少了
历史代码逻辑本来就设计不合理,因为逻辑混乱再加上后续的迭代修改,没人看得懂也没人敢优化逻辑,就成了一堆祖传代码(屎山)。后续修改维护都需要花大量时间梳理逻辑找到需要修改的点,另任何修改都需要测试全面覆盖用例。
系统更合理更可扩展了
举一个例子,诊室 IM 按钮的配置化改造后,后端可以直接配置下发。
{ |
95% 情况的修改不需要 APP 发版。前几天,公司 CTO 觉得现在的按钮顺序不太合理,找到我想要调整一下,我当时心里窃喜,还好我们已经提前做了配置化改造,不然就要被喷了。
可以配置化后,很快就完成了按钮顺序调整。这件事也让我更体会到技术手段的重要性,之前 Native 开发按钮,也不是不可用,只是不够灵活,任何修改需要发版,关键时刻救不了火。
每个人都是某块业务的 Owner,协作起来比较顺畅,执行力比较强(自我感觉)。
公司经历了几次组织架构调整,团队人员有进有出,也以开放的心态拥抱这种变化,因为唯一不变的就是变化。
最近一次组织架构调整,原本团队 20 人左右(包括 Native),对 APP 终端的业务功能负责。这次调整,我把 Native 交给了一个比我更合适的同事。
其一,我后端出身,对 Native 技术不熟悉,属于外行管内行的情况,可能由于我这方面的短视,无法提供更有建设性的意见,对这些同学的发展不利;其二,在团队内也没有找到合适的同学来补位。
但是我还是挺感谢这一段时间经历,对我来说是一个跨界,接触到了之前没关注的领域,拓展了自己的技术视野,会全局考虑技术实现方案,对后续工作也是很有帮助。
比如之前后端在设计技术方案的时候不会考虑 Native 这边,出现过 1 个页面需要调用 20 多个接口的情况(Native 同学咋没原则,不知道拒绝),Native 代码里也充斥着大量的业务逻辑实现。对此后续在团队里面形成规定:
相信团队的每一个人,不再觉得只有自己能做。把一些事授权给团队成员去做,对他们才会有更多成长机会。
以点带面,良币驱逐劣币。抓好团队这些关键人,让他们经历更多事情的蹂躏快速成长起来,负责更多的事,担更多的责任,避免团队因我这个单点而导致的翻车。
加入了后端技术委员会,能为团队做更多的事,面对更有挑战的一些技术难题。
以下 2 个案例都是为了解决团队发版日必过凌晨的问题,团队发布效率低导致加班严重。
发布流水线
微服务化之后,稍微大一点的项目发布都会有 10+ 的应用,之前全是人工手动到发布平台上一个一个应用点发布,效率低,且容易出现发布遗漏应用的情况。
另外如果是多个项目发布,那么就需要排队等待,后面发布的项目就遭殃了。
有段时间我深受其害,想办法解决这种情况。刚好质效部门有个同学有些想法,于是我俩来回拉扯了几次,确定了发布流水线大概功能:每个迭代需求系统自动生成发布清单,
多个迭代可以合并发布,支持一键部署完成代码合并和应用发布。这哥们也是给力,过了一段时间就做好上线了,当然我们部门也就成了第一个吃螃蟹的人。效果很明显,后续发布效率提高了很多。
预发布环境
之前工程部门已经搞过一次,但是没有落地成功。一是方案不是很合理;二是需要对接的业务团队较多,协调困难;三是没有做到开箱即用(工程部只管搭好环境,预发环境的应用还需要业务团队自己部署)。
基于之前的教训,我先是设计出整体实施方案,并得到了技术委员会的通过,然后拉上各业务线技术负责人,告知背景并需要得到资源支持(1 个人就好,小部分应用需要做一点小改造), 最后我就拉上运维开始实施,搭建环境并批量部署应用。
预发环境搭建好之后,白天可以在预发环境(真实的数据一致的环境)测试验收,因为降低了发布风险所以可以提前发布生产了,后续发布到很晚的情况就更少了。
负责的业务更多了,大部分事情都会先到我这里。可一天时间就这么多(995 又咋样),怎么办?搬出我的时间四象限:
职场即战场,很残酷很现实,需要实力和运气,如果当前的情况左右不了,就随它去吧,先丰富自己。
]]>这周末,终于有时间来彻底解决博客迁移问题了。通过改造 Hexo 博客系统,使其支持 Docker 部署,彻底摆脱了运行环境依赖,以后再更换云服务器厂商时,就可以做到快速平滑迁移了。
我的博客做过定制化改造,使用的是 hexo-theme-yilia 作为主题,评论使用的是 disqus-php-api,支持 HTTPS 协议,因此本次改造主要涉及这些。
由于没有了 Google Cloud 的免费使用资格,只能在国内挑选较便宜的腾讯云云服务器厂商,购买了一台低配云服务器。
这台服务器的操作系统是 CentOS,我们选用 Docker Compose 作为容器编排工具。
# 1.删除旧的Docker版本 |
# 1.获取docker-compose脚本 |
之前的目录结构较为单一,需调整项目目录结构。调整后的目录结构如下:
├── _config.yml # Hexo配置文件 |
其中,disqus
和 yilia
目录分别对应 disqus-php-api 和 hexo-theme-yilia 这 2 个子项目,并采用 submodule
模式管理这些源代码。
在
submodule
模式下,clone
和pull
命令会有一些变化,分别为git clone --recursive https://github.com/fan-haobai/blog.git
和git pull && git submodule foreach git pull origin master
。
本博客系统,主要依赖 NodeJS
、PHP
、Nginx
环境,因此分别构建 3 个容器。
Docker Compose 会根据 docker-compose.yml
配置文件,来自动编排容器。配置如下:
version: '3' |
其中,services
下为需要编排的 nodejs
、php
、nginx
容器服务。每个容器服务都可以灵活配置,常见的配置参数如下:
Docker Compose 支持多配置文件,且为覆盖关系。因此将
ssl-override.yml
作为获取 HTTPS 证书时启动容器的配置文件。
环境变量统一配置在 docker.env
文件中,并增加示例环境文件 docker.example.env
。环境变量目前较少,如下:
# 是否启用HTTPS证书 |
Dockerfile 文件统一放在 dockerfiles
目录下,并分别建立 nodejs
、php
、nginx
文件夹。
该容器下需要安装 git
、npm
。Dockerfile 文件如下:
FROM node:12-alpine |
其中,start.sh
为容器的启动脚本,主要作用为生成静态资源文件。内容如下:
|
该容器基于官方的基础镜像,安装一些必要的扩展。Dockerfile 文件如下:
FROM php:7.3.7-fpm-alpine3.9 |
其中,conf.d
下为 php
的配置文件。
该容器基于官方的基础镜像,并安装 cron
、wget
、python
。Dockerfile 文件如下:
FROM nginx:latest |
其中,conf.d
下为 nginx
的配置文件,ssl
下为 HTTPS 证书的生成脚本。
ssl
下的 init_ssl.sh
为首次获取 HTTPS 证书脚本,refresh_cert.sh
为更新 HTTPS 证书脚本。
其中,init_ssl.sh
脚本内容如下:
|
ssl-override.yml
会覆盖docker-compose.yml
中的环境变量,因此会将环境变量ENABLE_SSL
设置为false
,并将php
解析到127.0.0.1
,以确保nginx
容器在首次能成功启动。
而 refresh_cert.sh
脚本内容如下:
|
其中,SSL_DOMAINS
为环境变量文件 docker.env
中配置需要支持 HTTPS 的域名。
在该容器启动后,会执行 start.sh
脚本。其内容如下:
|
其中需要注意,当不启用 HTTPS 协议时,需要将 Nginx 配置修改为不启用 HTTPS;而启用时,会添加每 2 个月重新生成证书的定时任务。nginx
也需要改为前台启动模式,否则容器会因没有前台程序而自动退出。
前面的一切都准备就绪后,部署就异常简单了,后续再迁移时,也只需要简单做部署这一步就好了。
cp docker.example.env docker.env |
/bin/bash dockerfiles/nginx/ssl/init_ssl.sh |
注意:如果无需支持HTTPS协议,则跳过此步骤,并将环境变量
ENABLE_SSL
修改为false
。
docker-compose up --force-recreate --build -d |
如果一切顺利,那么运行 docker ps -a
命令就能看到已成功启动的容器,如下:
docker ps -a |
通过 www.fanhaobai.com 域名也可以直接访问到本站了。
使用 webhook-cli 工具可以支持代码自动部署,详细见 我的博客发布上线方案 — Hexo。
更新 »
当然,现在已有很多开源软件,如 Kong、Gravitee、Zuul。
这些开源网关固然功能齐全,但对于我们业务来说,有点太重了,我们有部分定制化需求,为此我们自建了一个轻量级的 OpenAPI 网关,主要供第三方渠道对接使用。
从第三方请求 API 链路来说,第三方渠道通过 HTTP 协议请求 OpenAPI 网关,网关再将请求转发到对应的内部服务端口,这些端口层通过 gRPC 调用请求到服务层,处理完请求后依次返回。
从事件回调请求链路来说,服务层通过 HTTP 协议发起事件回调请求到 OpenAPI 网关,并立即返回成功。OpenAPI 网关异步完成第三方渠道事件回调请求。
由于网关存在内部服务和第三方渠道配置,更为了实现配置的热更新,我们采用了 ETCD 存储配置,存储格式为 JSON。
配置分为以下 3 类:
a、第三方 AppId 配置
b、内部服务地址
c、内外 API 映射关系
利用 ETCD 的 watch 监听,可以轻易实现配置的热更新。
当然也还是需要主动拉取配置的情况,如重启服务的时候。
第三方调用 API 接口的时序,大致如下:
为了简化对接流程,我们统一了 API 接口的请求参数格式。请求方式支持 POST 或者 GET。
签名采用 md5 加密方式,算法可描述为:
1、将参数 p、m、a、t、v、ak、secret 的值按顺序拼接,得到字符串;
2、md5 第 1 步的字符串并截取前 16 位, 得到新字符串;
3、将第 2 步的字符串转化为小写,即为签名;
PHP 版的请求,如下:
$appId = 'app id'; |
不同的接口版本,可以转发请求到不同的服务,或同一个服务的不同接口。
通过事件回调机制,第三方可以订阅自己关注的事件。
只需要配置第三方 AppId 信息,包括 secret、回调地址、模块权限。
即,需要在 ETCD 执行如下操作:
$ etcdctl set /openapi/app/baidu '{ |
a、配置内部服务地址
即,需要在 ETCD 执行如下操作:
$ etcdctl set /openapi/backend/form_openapi '{ |
b、配置内外 API 映射关系
同样,需要在 ETCD 执行如下操作:
$ etcdctl set /openapi/api/inquiry/createMedicine.v2 '{ |
c、接入事件回调
接入服务也需要按照第三方接入方式,并申请 AppId。回调业务参数约定为:
Golang 版本的接入,如下:
const ( |
根据不同业务,拆分不同的 Topic。
推送至 Kafka 的消息统一使用 JSON 结构,数据如:
{ |
消息结构定义为:
// 消息事件数据 |
其中,Type
为消息类型,Type
为消息事件的 PB 结构体名称;Data
为 PB 协议的事件数据,见下文。
由于 PB 只序列化字段类型和顺序,因此同一个 PB 数据流在反序列化时,存在多个类型消息事件解释。而同一个 Topic 会存在多个类型消息事件,只通过 PB 并不能区分消息,因此引入 Type 用来区分不同类型消息。
例如,当业务订单状态发生扭转时,会产生订单状态事件消息。
订单状态 |
通过消费 Kafka 消息,实现部分业务逻辑。在实现 Consume 时,需要注意以下几个事项:
1、原则上保持职责单一原则
即不同的业务逻辑要拆分到不同的 Consume 实现。
// Consume PaySms |
// Consume PayWx |
2、只消费自己关注的 Type 类型消息
// Consume PaySms |
3、消费异常重试机制
消息消费采用 至少一次 的消费语义,即 先消费后保存读取偏移量。若消费失败,则不更新读取偏移量,会继续消费该失败消息。
// 实现 ConsumerGroupHandler 接口 |
由于 至少一次 消费语义,会导致消息重复消费,因此消费逻辑需要做幂等处理。
4、不同业务逻辑的 Consume 应该使用不同的 Group
一是,为了减少不同业务逻辑失败时之间的相互影响;二是,同一个消息在同一个 Group 的 Consume,只会被消费一次,否则存在部分 Consume 丢失消息的情况。
consume.Listen("order-pay-sms", message.PaySms{}.Handle) |
为了保持最终数据一致性,消息在生产和消费时都做了重试机制。
1、推送失败重试机制
投递消息使用同步应答模式,当消息推送失败时,这里才用 最大努力尝试 策略保持数据最终一致性。
2、消费异常重试机制
特别注意需要处理脏数据,防止因为错误数据导致消费阻塞。
3、只消费自己关注的 Type 类型消息
具体实现,见 业务消费 部分。
在分布式系统中引入消息系统,使得各系统可以只关注自己的业务逻辑,系统维护性更强,同时能极大的提高系统的稳定性。但是由于具有异步特性,存在一定的使用场景限制,对于实时响应的系统,还是建议直接使用 RPC 调用完成交互。
]]>需求点,可以描述为:
同样这个需求,定了以下两个硬指标:
首先,我们必须承认的是,这确实是个简单的需求,但这也是个够坑爹的需求。主要遇到的问题如下:
为了实现快速上线,我们在原人民币的商品价格基础架构上,只能进行少量且合适的改造。所以,最后我们的改造方向为:尽量只改造商品价格源头系统,即商品中心,其他上层系统尽量不改动。
改造商品中心,商品价格支持卢比。可行的改造方案有 2 种:
1、数据表价格字段存卢比
将原人民币价格相关的数据表字段,存卢比值,数据表并新增人民币字段。
2、接口输出数据时转化为卢比
原人民币相关的数据表字段依然存人民币值,在接口输出数据时,将价格相关字段值转化为卢比。
针对以上方案,我们需要注意 2 个问题:
上述 方案 ①,商品中心只需改造数据表。然后每天根据汇率刷新商品价格,原价格字段就都变成了卢比。方案相对简单,也容易操作,但缺点是:对任然需要人民币价格的系统,即商品管理系统须改造。
方案 ②,需要改造商品中心业务逻辑。由于涉及的价格字段较多,改造较复杂,主要优点是:汇率变动对商品价格影响较小,且可拓展支持多币种价格(可以根据地区标识,获取相应的商品价格)。
最终,为了系统的可扩展性,我们选择了方案 ②。
这里主要改造了商品中心,主要解决 透传地区标识 和 支持多币种价格 这 2 个问题。
我们的业务系统主要分为 API 和 Service 项目,API 暴露出 HTTP 接口,API 与 Service 和 Service 与 Service 之前使用 RPC 接口通信。由于商品中心涉及到价格的接口繁多,不可能对每个接口都增加地区标识的参数。所以我们弄了一套调用链路透传地区标识的机制。
思路就是,先将地区标识放在全局上下文中,API 接口通过 Header 头X-Location
携带地区标识;而对于 RPC 接口,我们的 RPC 框架已支持了 Context,不需要改造。
由于 RPC 框架已支持了 Context,所以 API 和 RPC 接口透传全局上下文略有不同。实现如下:
class Location |
上述
init()
方法,需要在项目入口位置初始化。
其中,RPC 接口不需要操作全局上下文。因为 RPC Client 在调用时会自动获取全局变量$context
值并在 RPC 协议数据中追加 Context,同时 RPC Server 在收到请求时会自动获取 RPC 协议数据中的 Context 值并设置全局变量$context
。
RPC Client 传递 Context 实现如下:
protected function addGlobalContext($data) |
RPC Server 获取 Context 实现如下:
public function getGlobalContext($packet) |
当设置了 Context 后,RPC 通信时协议数据会携带location
字段,内容如下:
RPC |
到这里,我们只需要在全局上下文设置地区标识即可。一旦我们设置了地区标识,所有业务系统就会在本次的调用链路中透传这个地区标识。实现如下:
class Location |
设置了地区标识后,就可以在本次调用链路的所有业务系统中直接获取。实现如下:
class Location |
有了地区标识后,商品中心服务就可以根据地区标识对价格字段进行转化了。因为设计到价格的数据表和价格字段较多,这里直接从数据层(Model)进行改造。
下述的ReadBase
类是所有数据表 Model 的基类,所有获取数据表数据的方法都继承或调用自getOne()
和getAll()
方法,所以我们只需要改造这两个方法。
class ReadBase |
由于涉及到价格字段名字较多,且具有不确定性,所以这里使用后缀方式匹配。为了防止一些字段命名不规范,这里引入了黑名单机制。
protected function isExchangeField($field) |
前缀为
is_
的字段一般定义为标识字段,默认为非价格字段。
上述getExchangePrice()
方法,用来根据地区标识转化价格覆盖到原价格字段,并自增以_origin
后缀的人民币价格字段。
public function getExchangePrice(&$data) |
其中,getExchangePrice()
方法会调用Location::get()
获取地区标识,并根据汇率计算实时价格。
最终,商品中心改造后,得到的部分商品价格信息,如下:
# 人民币价格10,汇率10.87 |
对于所有 API 的项目,我们只需要让客户端在所有的请求中增加X-Location
头即可。
GET /product/detail/1 HTTP/1.1 |
API 项目需在入口文件处,初始化地区标识。如下:
Location::init(); |
对于商品管理系统,我们为了方便运营操作,所有商品价格都应以人民币。因此,我们只需要初始化地区标识为中国,如下:
Location::init(); |
为了实现需求很容易,但是要做到合理且快速却不简单。本文的实现的方案,避免了很多坑,但同时也可能又埋下了一些坑。没有一套方案是万能的,慢慢去优化吧!
]]>为了说明平滑加权轮询调度的平滑性,使用以下 3 个特殊的权重实例来演示调度过程。
服务实例 | 权重值 |
---|---|
192.168.10.1:2202 | 5 |
192.168.10.2:2202 | 1 |
192.168.10.3:2202 | 1 |
我们已经知道通过 加权轮询 算法调度后,会生成如下不均匀的调度序列。
请求 | 选中的实例 |
---|---|
1 | 192.168.10.1:2202 |
2 | 192.168.10.1:2202 |
3 | 192.168.10.1:2202 |
4 | 192.168.10.1:2202 |
5 | 192.168.10.1:2202 |
6 | 192.168.10.2:2202 |
7 | 192.168.10.3:2202 |
接下来,我们就使用平滑加权轮询算法调度上述实例,看看生成的实例序列如何?
假设有 N 台实例 S = {S1, S2, …, Sn},配置权重 W = {W1, W2, …, Wn},有效权重 CW = {CW1, CW2, …, CWn}。每个实例 i 除了存在一个配置权重 Wi 外,还存在一个当前有效权重 CWi,且 CWi 初始化为 Wi;指示变量 currentPos 表示当前选择的实例 ID,初始化为 -1;所有实例的配置权重和为 weightSum;
那么,调度算法可以描述为:
1、初始每个实例 i 的 当前有效权重 CWi 为 配置权重 Wi,并求得配置权重和 weightSum;
2、选出 当前有效权重 最大 的实例,将 当前有效权重 CWi 减去所有实例的 权重和 weightSum,且变量 currentPos 指向此位置;
3、将每个实例 i 的 当前有效权重 CWi 都加上 配置权重 Wi;
4、此时变量 currentPos 指向的实例就是需调度的实例;
5、每次调度重复上述步骤 2、3、4;
上述 3 个服务,配置权重和 weightSum 为 7,其调度过程如下:
请求 | 选中前的当前权重 | currentPos | 选中的实例 | 选中后的当前权重 |
---|---|---|---|---|
1 | {5, 1, 1} | 0 | 192.168.10.1:2202 | {-2, 1, 1} |
2 | {3, 2, 2} | 0 | 192.168.10.1:2202 | {-4, 2, 2} |
3 | {1, 3, 3} | 1 | 192.168.10.2:2202 | {1, -4, 3} |
4 | {6, -3, 4} | 0 | 192.168.10.1:2202 | {-1, -3, 4} |
5 | {4, -2, 5} | 2 | 192.168.10.3:2202 | {4, -2, -2} |
6 | {9, -1, -1} | 0 | 192.168.10.1:2202 | {2, -1, -1} |
7 | {7, 0, 0} | 0 | 192.168.10.1:2202 | {0, 0, 0} |
8 | {5, 1, 1} | 0 | 192.168.10.1:2202 | {-2, 1, 1} |
可以看出上述调度序列分散是非常均匀的,且第 8 次调度时当前有效权重值又回到 {0, 0, 0},实例的状态同初始状态一致,所以后续可以一直重复调度操作。
此轮询调度算法思路首先被 Nginx 开发者提出,见 phusion/nginx 部分。
这里使用 PHP 来实现,源码见 fan-haobai/load-balance 部分。
class SmoothWeightedRobin implements RobinInterface |
其中,getSumWeight()
为所有实例的配置权重和;getCurrentWeight()
和 setCurrentWeight()
分别用于获取和设置指定实例的当前有效权重;getMaxCurrentWeightPos()
求得最大当前有效权重的实例位置,实现如下:
public function getMaxCurrentWeightPos() |
recoverCurrentWeight()
用于调整每个实例的当前有效权重,即加上配置权重,实现如下:
public function recoverCurrentWeight() |
需要注意的是,在配置services
服务列表时,同样需要指定其权重:
$services = [ |
可惜的是,关于此调度算法严谨的数学证明少之又少,不过网友 tenfy 给出的 安大神 证明过程,非常值得参考和学习。
假如有 n 个结点,记第 i 个结点的权重是 $x_i$,设总权重为 $S = x_1 + x_2 + … + x_n$。选择分两步:
1、为每个节点加上它的权重值;
2、选择最大的节点减去总的权重值;
n 个节点的初始化值为 [0, 0, …, 0],数组长度为 n,值都为 0。第一轮选择的第 1 步执行后,数组的值为 $[x_1, x_2, …, x_n]$。
假设第 1 步后,最大的节点为 j,则第 j 个节点减去 S。
所以第 2 步的数组为 $[x_1, x_2, …, x_j-S, …, x_n]$。 执行完第 2 步后,数组的和为:
$x_1 + x_2 + … + x_j-S + … + x_n => x_1 + x_2 + … + x_n - S = S - S = 0$
由此可见,每轮选择第 1 步操作都是数组的总和加上 S,第 2 步总和再减去 S,所以每轮选择完后的数组总和都为 0。
假设总共执行 S 轮选择,记第 i 个结点选择 $m_i$ 次。第 i 个结点的当前权重为 $w_i$。 假设节点 j 在第 t 轮(t < S)之前,已经被选择了 $x_j$ 次,记此时第 j 个结点的当前权重为 $w_j = t * x_j - x_j * S = (t - S) * x_j < 0$, 因为 t 恒小于 S,所以 $w_j < 0$。
前面假设总共执行 S 轮选择,则剩下 S-t 轮 j 都不会被选中,上面的公式 $w_j = (t - S) * x_j + (S - t) * x_j = 0$。 所以在剩下的选择中,$w_j$ 永远小于等于 0,由于上面已经证明任何一轮选择后,数组总和都为 0,则必定存在一个节点 k 使得 $w_k > 0$,永远不会再选中节点 j。
由此可以得出,第 i 个结点最多被选中 $x_i$ 次,即 $m_i <= x_i$。
因为 $S = m_1 + m_2 + … + m_n$ 且 $S = x_1 + x_2 + … + x_n$。 所以,可以得出 $m_i == x_i$。
证明平滑性,只要证明不要一直都是连续选择那一个节点即可。
跟上面一样,假设总权重为 S,假如某个节点 i 连续选择了 t($t < x_i$) 次,只要存在下一次选择的不是节点 i,即可证明是平滑的。
假设 $t = x_i - 1$,此时第 i 个结点的当前权重为 $w_i = t * x_i - t * S = (x_i - 1) * x_i - (x_i - 1) * S$。证明下一轮的第 1 步执行完的值 $w_i + x_i$ 不是最大的即可。
$w_i + x_i => (x_i - 1) * x_i - (x_i - 1) * S + x_i =>$
$x_i^2 - x_i * S + S => (x_i - 1) * (x_i - S) + x_i$
因为 $x_i$ 恒小于 S,所以 $x_i - S <= -1$。 所以上面:
$(x_i - 1) * (x_i - S) + x_i <= (x_i - 1) * -1 + x_i = -x_i + 1 + x_i = 1$
所以第 t 轮后,再执行完第 1 步的值 $w_i + x_i <= 1$。
如果这 t 轮刚好是最开始的 t 轮,则必定存在另一个结点 j 的值为 $x_j * t$,所以有 $w_i + x_i <= 1 < 1 * t < x_j * t$。所以下一轮肯定不会选中 i。
尽管,平滑加权轮询算法改善了加权轮询算法调度的缺陷,即调度序列分散的不均匀,避免了实例负载突然加重的可能,但是仍然不能动态感知每个实例的负载。
若由于实例权重配置不合理,或者一些其他原因加重系统负载的情况,平滑加权轮询都无法实现每个实例的负载均衡,这时就需要 有状态 的调度算法来完成。
相关文章 »
3 种常见的轮询调度算法,分别为 简单轮询、加权轮询、平滑加权轮询。本文将用如下 4 个服务,来详细说明轮询调度过程。
服务实例 | 权重值 |
---|---|
192.168.10.1:2202 | 1 |
192.168.10.2:2202 | 2 |
192.168.10.3:2202 | 3 |
192.168.10.4:2202 | 4 |
简单轮询是轮询算法中最简单的一种,但由于它不支持配置负载,所以应用较少。
假设有 N 台实例 S = {S1, S2, …, Sn},指示变量 currentPos 表示当前选择的实例 ID,初始化为 -1。算法可以描述为:
1、调度到下一个实例;
2、若所有实例已被 调度 过一次,则从头开始调度;
3、每次调度重复步骤 1、2;
调度过程,如下:
请求 | currentPos | 选中的实例 |
---|---|---|
1 | 0 | 192.168.10.1:2202 |
2 | 1 | 192.168.10.2:2202 |
3 | 2 | 192.168.10.3:2202 |
4 | 3 | 192.168.10.4:2202 |
5 | 0 | 192.168.10.1:2202 |
这里使用 PHP 来实现,源码见 fan-haobai/load-balance 部分。
首先,定义一个统一的操作接口,主要有init()
和next()
这 2 个方法。
interface RobinInterface |
然后,根据简单轮询算法思路,实现上述接口:
class Robin implements RobinInterface |
其中,total
为总实例数量,services
为服务实例列表。由于简单轮询不需要配置权重,因此可简单配置为:
$services = [ |
在实际应用中,同一个服务会部署到不同的硬件环境,会出现性能不同的情况。若直接使用简单轮询调度算法,给每个服务实例相同的负载,那么,必然会出现资源浪费的情况。因此为了避免这种情况,一些人就提出了下面的 加权轮询 算法。
加权轮询算法引入了“权”值,改进了简单轮询算法,可以根据硬件性能配置实例负载的权重,从而达到资源的合理利用。
假设有 N 台实例 S = {S1, S2, …, Sn},权重 W = {W1, W2, …, Wn},指示变量 currentPos 表示当前选择的实例 ID,初始化为 -1;变量 currentWeight 表示当前权重,初始值为 max(S);max(S) 表示 N 台实例的最大权重值,gcd(S) 表示 N 台实例权重的最大公约数。
算法可以描述为:
1、从上一次调度实例起,遍历后面的每个实例;
2、若所有实例已被遍历过一次,则减小 currentWeight 为 currentWeight - gcd(S),并从头开始遍历;若 currentWeight 小于等于 0,则重置为 max(S);
3、直到 遍历的实例的权重大于等于 currentWeight 时结束,此时实例为需调度的实例;
4、每次调度重复步骤 1、2、3;
例如,上述 4 个服务,最大权重 max(S) 为 4,最大公约数 gcd(S) 为 1。其调度过程如下:
请求 | currentPos | currentWeight | 选中的实例 |
---|---|---|---|
1 | 3 | 4 | 192.168.10.4:2202 |
2 | 2 | 3 | 192.168.10.3:2202 |
3 | 3 | 3 | 192.168.10.4:2202 |
4 | 1 | 2 | 192.168.10.2:2202 |
… | … | … | …. |
9 | 2 | 1 | 192.168.10.3:2202 |
10 | 3 | 4 | 192.168.10.4:2202 |
这里使用 PHP 来实现,源码见 fan-haobai/load-balance 部分。
class WeightedRobin implements RobinInterface |
其中,getMaxWeight()
为所有实例的最大权重值;getGcd()
为所有实例权重的最大公约数,主要是通过gcd()
方法(可用gmp_gcd()
函数)求得 2 个数的最大公约数,然后求每一个实例的权重与当前最大公约数的最大公约数。实现如下:
private function getGcd() |
需要注意的是,在配置services
服务列表时,需要指定其权重:
$services = [ |
加权轮询 算法虽然通过配置实例权重,解决了 简单轮询 的资源利用问题,但是它还是存在一个比较明显的 缺陷。例如:
服务实例 S = {a, b, c},权重 W = {5, 1, 1},使用加权轮询调度生成的实例序列为 {a, a, a, a, a, b, c},那么就会存在连续 5 个请求都被调度到实例 a。而实际中,这种不均匀的负载是不被允许的,因为连续请求会突然加重实例 a 的负载,可能会导致严重的事故。
为了解决加权轮询调度不均匀的缺陷,一些人提出了 平滑加权轮询 调度算法,它会生成的更均匀的调度序列 {a, a, b, a, c, a, a}。对于神秘的平滑加权轮询算法,我将在后续文章中详细介绍它的原理和实现。
轮询算法是最简单的调度算法,因为它无需记录当前所有连接的状态,所以它是一种 无状态 的调度算法,这些特性使得它应用较广。
轮询调度算法并不能动态感知每个实例的负载,它完全依赖于我们的工程经验,人为配置权重来实现基本的负载均衡,并不能保证服务的高可用性。若服务的某些实例因其他原因负载突然加重,轮询调度还是会一如既往地分配请求给这个实例,因此可能会形成小面积的宕机,导致服务的局部不可用。
相关文章 »
重庆由于地理的优势,被长江和嘉陵江环抱,夜幕降临时就会浮现出一副美丽的夜景。重庆又是名副其实的雾都,我们去的这几天基本上都是白雾缭绕的景象。
在出发前,我就早早的在规划这次的线路。总的来说,行程不算匆忙,可以慢悠悠欣赏沿途的美景,而又不错过任何一处特色的景点。
我们的线路主要途径点:一颗树观景台——四川美术学院——李子坝——解放碑——洪崖洞。
Day1:周五下午 4 点经过 1 个半小时车程到达 重庆北站,由于下午时间较少且赶车较累,就只安排了一个景点,那就是去 一棵树观景台 俯瞰重庆的夜景,途中正好可以坐一下 长江索道。
Day2:经过一晚上的休整精力也算复原了,这一天睡过懒觉后,去了 四川美术学院 和 涂鸦一条街,下午刚好途径 李子坝 回到渝中区,逛了下 解放碑,欣赏了 洪崖洞 的夜景。
Day3:原计划上午去 磁器口古镇,后来计划有变,就只是去 小龙坎(沙坪坝的一个地名)吃了顿老火锅,然后就去 重庆西站 赶下午 4 点的动车回成都了。
这次旅行,我们共去了 7 个景点,原计划去的磁器口古镇后面也取消了。这些景点中推荐去一棵树观景台、四川美术学院、洪崖洞,且一棵树观景台和洪崖洞一定要晚上再去。
我们周五下午去的时候人并不多,所以没有排队,很快就坐上去了。是由于当时雾比较重,所以也就没怎么看到好的景色,建议晚上或者能见度高的天去。
一棵树观景台是俯瞰重庆夜景最好的位置,位于重庆的南山,正对渝中区,特别适合拍重庆的夜景。可以打车前往,建议晚上带上相机去。
四川美术学院位于重庆的黄桷坪地区,距涂鸦一条街很近,在这里可以感受到独特的艺术气息。
一个意境较佳的画馆一角:
从名字就能知道,这一条街处处都是涂鸦。至于是经过多长时间、怎么形成的,不得而知,我猜想是川美给他们学生布置的作业任务吧。从四川美术学院出来就到了,这里很适合拍照,女生会特别喜欢~
下午从涂鸦一条街坐轻轨 2 号线,就会经过此站。此站站台位于居民楼中,因此轻轨会穿楼而过,这种情形也只有在山城才会出现,形成了一道靓丽的风景线。
本想拍一个“口吞轻轨”的场景,最终因没找到合适的角度放弃了,有点遗憾~
差不多快到晚上的时候,我们就回到了渝中区来到了解放碑,这里都是现代化的商场和小吃一条街,地上高楼大厦,街上人头攒动。我们逛了 八一好吃街 和 解放碑好吃街,出来时肚子差不多已经鼓鼓的了。
洪崖洞是重庆最值得来的地方,也是人最多的地方。从解放碑步行过来后,我们先逛了一圈洪崖洞的商业街,然后为了找到一个好的拍摄位置,特意走了两遍千厮门大桥,从桥上拍到了洪崖洞全景。
我是个不会拍照的人,陪她出来旅游这点很是要命,不过很庆幸我也拍了几张她比较喜欢的照片。这两天的重庆之旅,用她的话来说 很治愈心情,这也是我跟她的第一次旅行,我们相约每年至少一次旅行,并有中意的下一个目的地了。
]]>PHPServer 完整的源代码,可前往 fan-haobai/php-server 获取。
该 PHPServer 的 Master 和 Worker 进程主要控制流程,如下图所示:
其中,主要涉及 3 个对象,分别为 入口脚本、Master 进程、Worker 进程。它们扮演的角色如下:
start
、stop
、reload
流程;整个过程,又包括 4 个流程:
fork
出一个 Master 进程;Master 进程先经过 保存 PID、注册信号处理器 操作,然后 创建 Worker 会fork
出多个 Worker 进程;在流程 ② 中,Worker 进程被 Master 进程
fork
出来后,就会 持续运行 并阻塞于此,只有 Master 进程才会继续后续的流程。
启动流程见 流程 ①,主要包括 守护进程、保存 PID、注册信号处理器、创建多进程 Worker 这 4 部分。
首先,在入口脚本中fork
一个子进程,然后该进程退出,并设置新的子进程为会话组长,此时的这个子进程就会脱离当前终端的控制。如下图所示:
这里使用了 2 次fork
,所以最后fork
的一个子进程才是 Master 进程,其实一次fork
也是可以的。代码如下:
protected static function daemonize() |
通常在启动时增加
-d
参数,表示进程将运行于守护态模式。
当顺利成为一个守护进程后,Master 进程已经脱离了终端控制,所以有必要关闭标准输出和标准错误输出。如下:
protected static function resetStdFd() |
为了实现 PHPServer 的重载或停止,我们需要将 Master 进程的 PID 保存于 PID 文件中,如php-server.pid
文件。代码如下:
protected static function saveMasterPid() |
因为守护进程一旦脱离了终端控制,就犹如一匹脱缰的野马,任由其奔腾可能会为所欲为,所以我们需要去驯服它。
这里使用信号来实现进程间通信并控制进程的行为,注册信号处理器如下:
protected static function installSignal() |
其中,SIGINT 和 SIGTERM 信号会触发stop
操作,即终止所有进程;SIGQUIT 和 SIGUSR1 信号会触发reload
操作,即重新加载所有 Worker 进程;此处忽略了 SIGUSR2 和 SIGHUP 信号,但是并未忽略 SIGKILL 信号,即所有进程都可以被强制kill
掉。
Master 进程通过fork
系统调用,就能创建多个 Worker 进程。实现代码,如下:
protected static function forkOneWorker() |
Worker 进程的持续运行,见 流程 ③ 。其内部调度流程,如下图:
对于 Worker 进程,run()
方法主要执行具体业务逻辑,当然 Worker 进程会被阻塞于此。对于 任务 ① 这里简单地使用while
来模拟调度,实际中应该使用事件(Select 等)驱动。
public static function run() |
其中,pcntl_signal_dispatch()
会在每次调度过程中,捕获信号并执行注册的信号处理器。
Master 进程的持续监控,见 流程 ② 。其内部调度流程,如下图:
对于 Master 进程的调度,这里也使用了while
,但是引入了wait
的系统调用,它会挂起当前进程,直到一个子进程退出或接收到一个信号。
protected static function monitor() |
第两次的
pcntl_signal_dispatch()
捕获信号,是由于wait
挂起时间可能会很长,而这段时间可能恰恰会有信号,所以需要再次进行捕获。
其中,PHPServer 的 停止 和 重载 操作是由信号触发,在信号处理器中完成具体操作;Worker 进程的健康检查 会在每一次的调度过程中触发。
由于 Worker 进程执行繁重的业务逻辑,所以可能会异常崩溃。因此 Master 进程需要监控 Worker 进程健康状态,并尝试维持一定数量的 Worker 进程。健康检查流程,如下图:
代码实现,如下:
protected static function checkWorkerAlive() |
Master 进程的持续监控,见 流程 ④ 。其详细流程,如下图:
入口脚本给 Master 进程发送 SIGINT 信号,Master 进程捕获到该信号并执行 信号处理器,调用stop()
方法。如下:
protected static function stop() |
若是 Master 进程执行该方法,会先调用stopAllWorkers()
方法,向所有的 Worker 进程发送 SIGINT 信号并等待所有 Worker 进程终止退出,再清除 PID 文件并退出。有一种特殊情况,Worker 进程退出超时时,Master 进程则会再次发送 SIGKILL 信号强制杀死所有 Worker 进程;
由于 Master 进程会发送 SIGINT 信号给 Worker 进程,所以 Worker 进程也会执行该方法,并会直接退出。
protected static function stopAllWorkers() |
代码发布后,往往都需要进行重新加载。其实,重载过程只需要重启所有 Worker 进程即可。流程如下图:
整个过程共有 2 个流程,流程 ① 终止所有的 Worker 进程,流程 ② 为 Worker 进程的健康检查 。其中流程 ① ,入口脚本给 Master 进程发送 SIGUSR1 信号,Master 进程捕获到该信号,执行信号处理器调用reload()
方法,reload()
方法调用stopAllWorkers()
方法。如下:
protected static function reload() |
reload()
方法只会在 Master 进程中执行,因为 SIGQUIT 和 SIGUSR1 信号不会发送给 Worker 进程。
你可能会纳闷,为什么我们需要重启所有的 Worker 进程,而这里只是停止了所有的 Worker 进程?这是因为,在 Worker 进程终止退出后,由于 Master 进程对 Worker 进程的健康检查 作用,会自动重新创建所有 Worker 进程。
到这里,我们已经完成了一个多进程 PHPServer。我们来体验一下:
php server.php |
首先,我们启动它:
php server.php start -d |
其次,查看进程树,如下:
pstree -p |
最后,我们把它停止:
php server.php stop |
现在,你是不是感觉进程控制其实很简单,并没有我们想象的那么复杂。( ̄┰ ̄*)
我们已经实现了一个简易的多进程 PHPServer,模拟了进程的管理与控制。需要说明的是,Master 进程可能偶尔也会异常地崩溃,为了避免这种情况的发生:
首先,我们不应该给 Master 进程分配繁重的任务,它更适合做一些类似于调度和管理性质的工作;
其次,可以使用 Supervisor 等工具来管理我们的程序,当 Master 进程异常崩溃时,可以再次尝试被拉起,避免 Master 进程异常退出的情况发生。
相关文章 »
进程是 程序的实体,是系统进行 资源分配和调度的基本单位,是操作系统结构的基础。每个进程都有自己唯一标识(PID),每个进程都有父进程,这些父进程也有父进程,所有进程都是init
进程(PID 为 1)的子进程。
我们来直观感受下它的存在,可以说它是看不见又摸不着。
pstree -p |
前台进程具有控制终端,会堵塞控制终端。它的特点是:
php server.php start |
通常,在控制终端使用Ctrl+C
组合键,会导致前台进程终止退出。
守护进程是一种运行在后台的特殊进程,因为它不属于任何一个终端,所以不会收到任何终端发来的任何信号。它与前台进程显著的区别是:
通常我们编写的程序,都需要在 后台不终止的长期运行 ,此时就可以使用守护进程。当然,我们可以在代码中调用系统函数,或者直接在启动命令后追加&
操作符,如下:
nohup php server.php start & |
通常
&
与 nohup 结合使用,忽略 SIGHUP 信号来实现一个守护进程。该方式对业务代码侵入最小,方便且成本低,常用于临时执行任务脚本的场景。
进程的用户空间是相互独立的,一般而言是不能相互访问。但很多情况下,进程间需要互相通信来进行数据传输、共享数据、通知事件、进程控制等,这就必须通过内核实现进程间通信。
进程间通信有管道、消息队列、信号、共享内存、套接字等方式,本文只介绍后 3 种。
共享内存是一段被映射到多个进程地址空间的内存,虽然这段共享内存是由一个进程创建,但是多个进程都可以访问。如下图:
共享内存是最快的进程间通信方式,但是可能会存在竞争,因此需要加锁。Linux 支持三种共享内存:mmap、Posix、以及 System V。
套接字是一个通信链的句柄,可以用域、端口号、协议类型来表示一个套接字,其中域分为 Internet 网络(IP 地址)和 UNIX 文件(Sock 文件)两种。当域为 Internet 网络时,通信流程如下图:
特别的是,当套接字域为 Internet 网络时,可以实现 跨主机的进程间通信。因此,若要实现跨主机进行进程间通信,则须选用套接字。
信号受事件驱动,是一种异步且最复杂的通信方式,用于通知接受进程有某个事件已经发生,因此常用于事件处理。信号的处理机制,如下图:
在 Linux 系统中,可使用kill -l
命令查看这 62 个信号值。其中常用值如下:
信号名称 | 值 | 说明 | 进程默认行为 |
---|---|---|---|
SIGHUP | 1 | 终端控制进程结束 | Terminate |
SIGINT | 2 | 键盘Ctrl+C被按下 | Terminate |
SIGQUIT | 3 | 键盘Ctrl+/被按下 | Dump |
SIGKILL | 9 | 无条件结束进程 | Terminate |
SIGUSR1 | 10 | 用户保留 | Terminate |
SIGUSR2 | 12 | 用户保留 | Terminate |
SIGALRM | 14 | 时钟定时信号 | Terminate |
SIGTERM | 15 | 程序结束 | Terminate |
SIGCHLD | 16 | 子进程结束 | Ignore |
实际中,硬件或者软件中断都会触发信号,但这里只列举两种信号产生方式。
按键/命令 | 信号名称 |
---|---|
Ctrl+C | SIGINT |
Ctrl+\ | SIGQUIT |
EXIT | SIGHUP |
通过kill
系统调用发送信号。例如,在 Shell 中使用kill -9
发送 SIGKILL 信号。对于kill
调用,需要注意以下两种特殊情况:
1、 特殊信号
可以发送编号为0
的信号来 检测进程是否存活。
$pid = 577; |
2、 特殊 PID
这里的参数$pid
,根据取值范围不同,含义也不同。具体如下:
进程共有 3 种处理信号的方式:
其中,默认行为进一步可以细分为以下几种:
默认处理类型 | 描述 |
---|---|
Terminate | 进程被中止(杀死) |
Dump | 进程被中止(杀死),并且输出 dump 文件 |
Ignore | 信号被忽略 |
Stop | 进程被停止 |
信号的默认行为类型,见 常用的信号值 默认行为部分。
使用ps -ajx
命令查看所有进程信息,如下:
父PID PID 组ID 会话ID 终端 时间 名称 |
进程组是一个或多个进程的集合。每个进程除了有一个 PID 之外还有一个进程组 ID(GID),每个进程都属于一个进程组,每个进程都有一个组长进程。
如上图中,1 个PHPServer: master
主进程和 2 个PHPServer: worker
子进程,属于同一个进程组11251
,可以看出主进程是组长进程。
会话是一个或多个进程组的集合,一个会话有对应的控制终端。如上图中,4 个PHPServer
进程和-bash
进程同属于一个会话,因为他们在一个pts/1
的控制终端。
需要说明的是,当用户退出(Logout)会话以后,系统默认对该会话下的进程进行如下操作:
而对于后台进程,用户在退出时系统默认不会发送 SIGHUP 信号,这是由 Shell 的huponexit
参数(默认off
)控制。可通过shopt -s huponexit
设置成on
(当前会话有效),此时后台进程会收到 SIGHUP 信号。
从进程层面来说,程序可以分为单进程和多进程模型。
单进程模型的程序,只有一个进程在运行。他是最基本的进程模型,实现起来比较简单,Redis 就是采用这种进程模型。
为了提高程序的并发处理能力,程序由单进程慢慢演变成了多进程,一 个 Master 进程和多个 Worker 进程是多进程常见的构成形态。可以说,现在大部分程序都是多进程模型,其中 Nginx 是典型的代表。
到这里,我们已经对进程有了基础的认识,后续我将用 PHP 一步步实现一个 PHPServer 应用。
相关文章 »
说到成都,很多人(包括小马哥)都会扔出一句“少不入川,老不出川”。他们认为,成都生活安逸,适合养老,年轻人容易失去斗志,但是我并不认同,小马哥后面也改变看法了,因为 N 逼的天美工作室。
现在的成都,有很多互联网公司入驻,虽说条件比不上北上深,但是在工作和生活平衡点上,是可以安居乐业的;政府也在大力培育“独角兽”企业,未来就业前景也是可期的。至于待遇方面,普遍比北上深低 20~30% 左右,还是那句话:如果你真牛逼,也没有什么是不可能的。
现在来说说聚美。目前我主要负责商品中心和库存中心系统,这两个系统作为核心的基础服务,日调用量最高且系统可靠性要求高。
聚美曾经辉煌过,所以大家对其一直有很高的期待,现在聚美正在崛起的路上。个人觉得公司的成功跟行业有很大关系,“风口上的猪”嘛,之前聚美正处风口上,现在自如正处于这个位置,这是我留恋自如的原因。
聚美 PHP 的生态是我见过做的最好的,这里也是 PHP 在国内大规模成熟应用的实例。毕竟作为电商,经历过业务顶峰,沉淀下来的,这是我来聚美的目的。
服务 Worker、连接池、RPC、服务调度、旁路、配置中心、消息中心、任务中心等解决方案,尽管目前我还未吃透这些设计,但是旁路的设计就能给我很赞的感觉。当然,这些都需要一个强大的架构团队来支持。一个互联网公司,只有有了一个好的技术生态,才能更好更快地响应业务需求。
为了承载你的梦想,首先得有个家。而 2018 的楼市,可谓惨不忍睹。全民炒房,全国房价飞涨,多个城市先后出台了摇号政策,才将这一轮炒房热 Hold 住。整个过程,最受伤的就是刚需。
成都楼市,在限购限价的政策下,均价控制在 1.5W 左右,当然现在买房,不光得有钱,还得先摇号。攻略如下:
整个过程,只可意会,不可言传,只有经历了才知道是多么的惊悚。
摇中就像中彩票一样,大部分的摇中概率为 1%。且自己买什么楼盘可选性不大,因为摇中什么才能买什么。
原本以为摇中就好,可没有想到,按序选房是多么的恐怖。因为在仅仅只有一大排房号的数字面前,需要 2 分钟从剩余的房源中选定自己看中的房号,就跟去菜市场买白菜一样。当然,这就需要提前做好功课,比如实地踩点,按优先顺序排出多个中意房号。一切都准备好了,但是你运气不太好,摇中的号靠后就不太可能选上中意的房源。
就摇号买房来说,终于不用拼爹了,而是拼运气。在 5.15 新政之后,这种情况得到改善,但是大学生落户的人就失去了购房资格(所在区社保需满 1 年)。我只是运气刚刚好,赶上新政之前的末班车,摇的号还比较靠前。总之一句话,如果是刚需,就尽快下手,占个坑,因为政策会让你琢磨不透。
成都未来的发展,就看这座未来之城——四川天府新区。至于为什么要花大量人力物力打造这座新城?简单的答案是:
接下来就介绍一下这个国家级新区。
四川天府新区规划与 2010 年,2014 年正式成为国家级新区。天府新区建成后,将与成都中心城区形成“双核共兴”的局面。如下为天府新区产业规划图:
我在网上看过一句话,说高新区是民营资本的聚集地,而天府新区是国家队资本的聚集地。我觉得应该说,国家队只是起先导引流作用。
规划这么美的天府新区,交通自然不能拖了后退。从成都地铁规划图中,可以看到新区地铁覆盖率较高,出行较方便。到 2020 年,开通的地铁有 1、5、6、15、18 号线。
习大大说,“绿水青山,就是金山银山”。由于优越的地理位置,决定了天府新区一诞生就是一座“公园城市”。兴隆湖作为天府新区的发展起步区,很好地响应了公园城市理念。
曾用名为“熊猫大厦”的 676 米超高地标建筑,正式命名为“一带一路大厦”,这也让人们一直铭记住天府新区是“一带一路”战略的一个重要环节。待“一带一路大厦”旁依附的超高建筑群建成后,成都的天际线将进一步提高,也使得成都的天空具有较强的层次感。
独角兽岛当然是独角兽企业(10 亿美元以上创业公司)的聚集地,建设独角兽岛,体现了成都大力培育独角兽企业的决心。独角兽岛先后分两期建成,一期于 2019 年建成,二期 2019 年开建并于 2022 年建成。
天府国际机场造型为“太阳神鸟”,位于高新东区简阳芦葭镇,定位为国家级国际航空枢纽。预计到 2020 年建成使用,届时成都即迈入双机场时代,成为中国大陆地区第三个拥有双国际机场的城市。
中国西部博览城简称西博城,是我国中西部最大的国际会展中心。由于西博城有着铝制的金属外表,使其看起来很有科技感。
如今 4 年过去了,有的规划已初步建成,有的还在如火如荼的建设中,建设者正在一一将规划图变成一座座楼宇,呈现在人们面前。
虽然成都不比北上广深,却是一个可以承载我梦想的地方。在这里,我可以安居乐业,重要的是能守护心中的她。
]]>本文的发布方案中,Git 仓库只是托管 md 文件,通过 Webhook 通知服务器拉取 md 文件,然后执行构建静态文件操作,完成一个发布过程。
我的写作环境为 Typora(Win10),博客发布在阿里云的 ECS(CentOS)上,文章托管在 GitHub。
随着时间成本的增高,只能利用碎片时间来进行写作。因此,我的写作场景变成了这样:
之前(包括 Hexo 推荐)的发布方案,都是先本地编写 MarkDown 源文件,然后本地构建静态文件,最后同步静态文件到服务器。发布流程图如下:
显而易见,若继续使用之前的发布方案,那么每当更换写作场地时都需要安装 Hexo 环境,写作场地和时间都受到限制,不满足需求。
问题主要是,本地受制于构建静态文件时需要的 Hexo 环境,那么是否可以将构建静态文件操作放到服务器端?
首先,看下新方案的发布流程图:
如流程图所示,整个发布系统共涉及到 3 个环境,分别为本地(写作)、Git 仓库(托管 md 源文件)、服务器(Web 服务)环境。在服务器环境构建静态文件,因此只需要在服务器端安装 Hexo 环境。
一个完整的发布流程包含 3 个部分:
采用按分支开发策略,当写作完成后,只需要 push 修改到对应分支即可。只要有 MarkDown 编辑器,以及任何文本编辑器,甚至 马克飞象 都可以随时随地写作。
当然,你可能说还需要 Git 环境呀?好吧,如果你是一名合格的 Coder,竟然没有 Git,你知道该干嘛了!再说没有 Git 环境,还可以通过 GitHub 来完成写作。
采用 master 发布策略,当需要发布时,需要将对应开发分支 merge 到 master 分支,然后 push master
分支,即可实现发布。
这里使用到 Webhook 机制,触发代码更新并部署操作。
当流程 ① 和 ② 结束后,Git 仓库都会向服务器发起一次 HTTP 请求,记录如下:
当收到构建请求后,执行构建操作。构建流程图如下:
首先检查当前变更分支,只有为 master 分支时,执行 pull 操作拉取 md 文件更新,然后再执行 hexo g
完成静态文件的构建。
这里直接使用 webhook-cli 工具,只需简单配置即可使用。
新增 hook.json
配置文件,内容如下:
[ |
其中,execute-command
为部署脚本路径,secret
为 webhook 参数加密密钥。
部署脚本 execute-command
的内容如下:
|
新发布方案与之前方案的区别是:前者只需本地编写 md 文件,博客服务器构建静态文件;后者本地编写 md 文件后,需要本地构建静态文件,然后博客服务器只同步静态文件。
当然,有很多办法可以解决当前问题,比如可以使用 持续集成。本文只是提供一个发布思路,在项目的生产环境中,我们也很容易应用上这种发布思路,开发出自己的发布系统。
相关文章 »
netstat -ntu | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -10 |
在安装环境或者软件时,我们常常需要知道所在操作系统的版本信息,这里列举几种查看内核和发行版本信息的方法,更多见 查看 Linux 系统版本。
uname -a |
cat /proc/version |
lsb_release -a |
cat /etc/redhat-release |
在遇到内存容量瓶颈时,我们就可以尝试启用 Swap 分区。使用文件(还可以磁盘分区)作为 Swap 分区时,具体步骤如下:
1、 创建 Swap 分区的文件
bs*count为文件大小 |
2、 格式化为交换分区文件
mkswap /root/swapfile |
3、 启用交换分区
swapon /root/swapfile |
4、 开机自启用 Swap 分区
在/etc/fstab
文件中添加如下内容:
/root/swapfile swap swap defaults 0 0 |
最后,查看系统的 Swap 分区信息:
free -h |
以下两种需求:
这些,都可以通过将用户加入 sudoers 来解决,当然情况 2 也可以使用echo "passwd"|sudo -S cmd
,从标准输入读取密码。
sudoers 配置文件为/etc/sudoers
,sudo 命令操作权限配置内容如下:
# 授权用户/组 主机名=(允许转换至的用户) NOPASSWD:命令动作 |
授权格式 说明:
,
号分割;因此,我的用户为fhb
,授权步骤如下:
# 1. 执行visudo命令,操作的文件就是/etc/sudoers |
然后,使用sudo service ssh restart
命令测试 OK。
有时候我们使用 yum 安装的软件,由于配置向后兼容性等问题,我们并不希望这些软件(filebeat 和 logstash)在使用update
时,被不经意间被自动更新。这时,可以使用如下方法解决:
通过-x
或--exclude
参数指定需要排除的包名称,多个包名称使用空格分隔。例如:
--exclude同样 |
在 yum 配置文件/etc/yum.conf
中,追加exclude
配置项。例如:
# 需排序的包名称 |
再次使用yum update
命令,就不会自动更新指定的软件包了。
yum update |
攻击者可能会通过端口扫面工具,而得出目标服务器监听的端口号,从而进行攻击。通过关闭 Linux 服务器的 ICMP 协议服务或者防火墙拦截 ICMP 协议包,可以达到禁用 ping 的目的。
a. 关闭ICMP服务
echo "1" >/proc/sys/net/ipv4/icmp_echo_ignore_all |
b. 防火墙拦截
iptables -A INPUT -p icmp -j DROP |
检查禁 ping 是否成功:
ping www.fanhaobai.com |
默认情况下,SSH 监听 22 端口,这也使得攻击者可以轻松扫描到目标服务器是否运行 SSH 服务。所以建议将 SSH 端口号更改为大于 1024。
在文件/etc/ssh/sshd_config
中,增加如下配置:
Port 22 # 保留22默认端口,防止端口配置失败,无法连接SSH |
重启 SSH 服务:
service sshd restart |
通过新端口 10086 连接 SSH,如果连接成功再删除默认端口 22 配置。
如果查看发现 10086 已被 sshd 监听,而仍然无法连接 SSH,则需添加防火墙规则:
-dport指操作端口号 |
使用密码登录 SSH,每次登录都需要频繁输入密码,所以比较麻烦,使用 SSH 的公钥登录,可以免去输入密码的步骤。
1、在本地主机上生成自己的公钥
ssh-keygen |
执行命令后出现一系列提示,直接回车即可。会在$HOME/.ssh
目录生成公钥和私钥文件,其中id_rsa.pub
为你的公钥,id_rsa
为你的私钥。
2、配置公钥到远程主机
远程主机将用户的公钥保存在$HOME/.ssh/authorized_keys文件
,所以这里需要将上步生成的 公钥 文件id_rsa.pub
的内容 追加 到authorized_keys
文件中。
如果authorized_keys
文件不存在,创建即可:
mkdir ~/.ssh |
配置 sshd 服务,配置文件为/etc/ssh/sshd_config
,将下面内容关闭注释。
RSAAuthentication yes |
然后,重启 sshd 服务。
service sshd restart |
3、免密登录测试
这里通过配置 识别名 ,连接时只需指定连接识别名即可,简单方便。
在$HOME/.ssh
目录下创建config
文件,并作如下配置:
Host fhb |
使用识别名连接 SSH 登录远程主机,出现如下内容表示公钥登录成功。
ssh fhb |
在某些情况下,需要强制踢出系统其他登录用户,比如遇到非法用户登录。查询当前登陆用户:
当前用户 |
剔除非法登陆用户:
kill -9 4755 |
更多详细说明,见 Linux 强制踢出其他登录用户。
在可以使用 ssh 情况下,为了能进行线上调试,我们可以使用 ssh 隧道建立端口映射。
例如,线上远程目标机器 ip:10.1.1.123、端口:3303;映射到本地 33031 端口。命令如下:
[主机ip]:[端口]:[主机ip]:[远程目标机器端口] [远程目标机器ip] |
该命令操作后,只能通过
127.0.0.1
访问。若想全网段访问,需要将第一个主机 ip 更改为0.0.0.0
,同时需要在/etc/ssh/sshd_config
增加GatewayPorts yes
的配置项。
在调试程序时,我们会遇到一些系统层面的错误问题,一般都不易发现,这时可以使用 strace 来跟踪系统调用的过程,方便快速定位和解决问题。
$ strace crontab.sh |
更多详细说明,见 错误调试。
在脚本或者代码中,有时候需要在控制终端输出醒目的提示信息,以便引起我们的关注。其实,在 Linux 终端下很容易就能搞定,如下:
实现的源代码,内容为:
echo -e "\033[1;30m Hello World. \033[0m [高亮]" |
\033
是 Esc 键对应的 ASCII 码(27=/033=0x1B),表示后面的内容具有特殊含义,类似表述有^[
以及/e
,而\033[0m
表示清除格式控制。
输出格式的规则,可表示为\033[特殊格式;前景色;背景色m
,主要分为 颜色 和 格式 两类规则。
主要包括 前景色 和 背景色,前景色范围为30~39
,背景色范围为40~49
(前景色对应颜色值 +10)。前景色颜色代码表如下:
表达式 | 格式 |
---|---|
\033[0m | 关闭所有属性 |
\033[1m | 高亮度 |
\033[4m | 下划线 |
\033[5m | 闪烁 |
\033[7m | 反显 |
\033[8m | 消隐 |
更新 »
既然公司对自如客这么阔,那对我们员工也得够意思,所以年底我们共准备了 3 个活动。
1、针对 自如客 的服务费减免活动;
2、针对 自如客 的 1000 万现金礼包;
3、25 万的 员工 红包活动;
散币活动 2 和 3 是通过微信红包形式进行,想散币就散吧,可微信告诉我们,想散币还得交税(>﹏<)。员工红包来说,25 万要交掉 10 多万税,此时心疼我的钱。好了,下面开始说点正事。
说到红包,我们肯定会想到红包拆分和抢红包两个场景。红包拆分是指将指定金额拆分为指定数目红包的过程,即是用来确定每个红包的金额数;而抢红包就是典型的高并发场景,需要避免红包超发的情况。
拆分方式
1、实时拆分
实时拆分,指的是在抢红包时实时计算每个红包的金额,以实现红包的拆分过程,对系统性能和拆分算法要求较高,例如拆分过程要一直保证后续待拆分红包的金额不能为空,不容易做到拆分红包的金额服从正态分布规律。
2、预先生成
预先生成,指的是在红包开抢之前已经完成了红包的拆分,抢红包时只是依次取出拆分好的红包金额,对拆分算法要求较低,可以拆分出随机性很好的红包金额,通常需要结合队列使用。
拆分算法
我并没有找到业界的通用算法,但红包拆分算法应该是拆分金额要看起来随机,最好能够服从正态分布,可以参考 微信 和 @lcode 提供的红包拆分算法。
微信拆分算法的优点是算法较简单,拆分效率高,同时,由于该算法天然的特性,可以保证后续红包金额一定不为空,特别适合实时拆分场景,但缺点是会导致大额红包较大概率地在拆分的最后出现。 @lcode 拆分算法的优点是拆分金额基本符合正态分布,适合随机性要求较高的拆分场景。
我们这次的业务对红包金额的随机性要求不高,但是对系统可靠性要求较高,所以我们选用了预算生成方式,使用 二倍均值法 的红包拆分算法,作为我们的红包拆分方案。
采用预算生成方式,我们预先生成红包并放入 Redis 的 List 中,当抢红包时只是 Pop List 即可,具体实现将在 抢红包 部分介绍。
拆分算法可以描述为:假设剩余拆分金额为 M,剩余待拆分红包个数为 N,红包最小金额为 1 元,红包最小单位为元,那么定义当前红包的金额为:
$$m = rand(1, floor(M/N*2))$$
其中,floor 表示向下取整,rand(min, max) 表示从 [min, max] 区间随机一个值。$M/N \ast 2$ 表示剩余待拆分金额平均金额的 2 倍,因为 N >= 2,所以 $M/N \ast 2 <= M$,表示一定能保证后续红包能拆分到金额。
代码实现为:
for ($i = 0; $i < $N - 1; $i++) { |
值得一提的是,我们为了保证红包金额差异尽量小,先将总金额平均拆分成 N+1 份,将第 N+1 份红包按照上述的红包拆分算法拆分成 N 份,这 N 份红包加上之前的平均金额才作为最终的红包金额。
限流
1、前端限流
前端限制用户在 n 秒之内只能提交一次请求,虽然这种方式只能挡住小白,不过这是 99% 的用户哟,所以也必须得做。
2、后端限流
常用的后端限流方法有 漏桶算法 和 令牌桶算法。漏桶算法 主要目的是控制请求数据注入的速率,如果此时漏桶溢出,后续的请求数据会被丢弃。而 令牌桶算法 是以一个恒定的速度往桶里放入令牌,而如果请求数据需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌时,这些请求才被丢弃,令牌桶算法的一个好处是可以方便地改变应用接受请求的速率。
防超发
1、库存加锁
可以通过加锁的方式解决资源抢占问题,但是加锁会增加系统开销,大流量下更容易拖垮系统,不过可以尝试一下基于版本号的乐观锁。
2、通过高速队列串行化请求
之所会出现超发问题,是因为并发时会出现多个进程同时获取同一资源的现象,如果使用高速队列将并行请求串行化,那么问题就不存在了。高速队列可以使用 Redis 缓存服务器来实现,当然光使用队列还不够,必要保证整个流程调用链要短、要快,否则队列会积压严重,甚至会拖垮整个服务。
在限流方面,由于我们预估的请求量还在系统承受范围,所以没有考虑引入后端限流方案。我们的抢红包系统流程图如下:
我们将抢红包拆分为 红包占有(流程①,同步) 和 红包发放 (流程②,异步)这两个过程,首先采用高速队列串行化请求,红包发放逻辑由一组 Worker 异步去完成。高速队列只是完成红包占有的过程,实现库存的控制,Worker 则处理耗时较长的红包发放过程。
当然,在实际应用中,红包占用过程还需要加上一些前置规则校验,比如用户是否已经领取过,领取次数是否已经达到上限等?红包占有流程图如下:
其中,red::list
为 List 结构,存放预先生成的红包金额(流程①中的红包队列);red::task
也为 List 结构,红包异步发放队列(流程②中的任务队列);red::draw
为 Hash 结构,存放红包领取记录,field
为用户的 openid,value
为序列化的红包信息;red::draw_count:u:openid
为 k-v 结构,用户领取红包计数器。
下面,我将以以下 3 个问题为中心,来说说我们设计出的抢红包系统。
1、怎么保证不超发
我们需要关注的是红包占有过程,从红包占有流程图可看出,这个过程是很多 Key 操作的组合,那怎么保证原子性?可以使用 Redis 事务,但我们选用了 Lua 方案,一方面是因为首先要保证性能,而 Lua 脚本嵌入 Redis 执行不存在性能瓶颈,另一方面 Lua 脚本执行时本身就是原子性的,满足需求。
红包占有的 Lua 脚本实现如下:
-- 领取人的openid为xxxxxxxxxxx |
需要注意 Lua 脚本执行过程并不是事务的,脚本中的操作命令在执行时是有先后顺序的,当某个操作执行失败时不会回滚已经执行成功的操作,它的原子性是通过单线程模型实现。
2、怎么提高系统响应速度
如红包占有流程图所示,当用户发起抢红包请求时,若有红包则直接完成红包占有操作,同步告知用户是否抢到红包,这个过程要求快速响应。
但由于微信红包支付属于第三方调用,若抢到红包后同步调用红包支付,系统调用链又长又慢,所以红包占有和红包发放异步拆分是必然。拆分后,红包占有只需操作 Redis,响应性能已不是问题。
3、怎么提高系统处理能力
从上述分析可知,目前系统的压力都会集中在红包发放这个环节,因为用户抢到红包时,我们只是同步告知用户已抢到红包,然后异步去发放红包,因此用户并不会立即收到红包(受红包发放 Worker 处理能力和微信服务压力制约)。若红包发放的 Worker 处理能力较弱,那么红包发放的延迟就会很高,体验较差。
如抢红包流程图中所示,我们采用一组 Worker 去消费任务队列,并调用红包支付 API,以及数据持久化操作(后续对账)。尽管红包发放调用链又长又慢,但是注意到这些 Worker 是 无状态 的,所以可以通过增加 Worker 数量,以横向扩展提高系统的处理能力。
4、怎么保证数据一致性
其实,红包发放延时我们可以做到用户无感知,但是若红包发放(流程②)失败了,已经告知用户抢到红包,但是却木有发,估计他杀人的心都有了。根据 CAP 原理,我们无法同时满足数据一致性、数据可用性、分区耐受性,通常只需做到数据最终一致性。
为了达到数据最终一致性,我们就引入了重试机制,生成一个全局唯一的外部订单号,当某单红包发放失败,就会放回任务队列,使得有机会进行发放重试,当然这一切都需要 API 做幂等处理。
这里必须将 Worker 可靠性单独说,因为它实在太重要了。Worker 的实现如下:
$maxTask = 1000; |
这里使用 LPOP 命令获取任务,所以使用了 while 结构,并且无任务时需要等待,可以用阻塞命令 BLPOP 来改进。
由于 Worker 需要常驻内存运行,难免会出现异常退出的情况(也有主动退出), 所以需要保持 Worker 一直处于运行状态。我们使用进程管理工具 Supervisor 来监控 Worker 的运行状态,同时管理 Worker 的数量,当任务队列出现堆积时,增加 Worker 数量即可。Supervisor 的监控后台如下:
公司员工都用唯一一个系统号 emp_code(自增字段)标识,登录成功后返回 emp_code,系统后续所有交互流程都基于 emp_code,分享出去的红包也会携带 emp_code,为了保护员工敏感信息和防止恶意碰撞攻击,我们不能直接将 emp_code 暴露给前端,需要借助一个 token(无规律)的中间者来完成交互。
1、储存映射关系,时时查询
预先生成一个随机串 token,然后跟 emp_code 绑定,每次请求都根据 token 时时查询 emp_code。优点是可以定期更新,相对安全,缺点是性能不高。
2、建立映射关系函数,实时计算
建立一个映射关系函数,如 hash 散列或者加密解密算法,能够根据 emp_code 生成一个无规律的字符串 token,并且要能够根据 token 反映射出 emp_code。优点是需要存储介质存储关系,性能较高,缺点是很难做到定期失效并更新。
由于我们的红包活动只进行几天,所以我们选用了方案 2。对 emp_code 做了 hashids 散列算法,暴露的只是一串无规律的散列字符串。
hashids 是一个开源且轻量的唯一 id 生成器,支持 Java、PHP、C/C++、Python 等主流语言,PHP 想使用 hashids,只需composer require hashids/hashids
命令安装即可。
然后,如下方式使用:
use Hashids\Hashids; |
需要说明的是,其中salt
是非常重要的散列加密盐串,6
表示散列值最小长度,abcde...7890
为散列字典,太长影响效率,太短不安全。由于默认的散列字典比较长,decode 效率并不高,所以这里移除了大写字母部分。
语音点赞就是用户以语音的形式助力好友,核心技术其实是语音识别,而我们一般都会使用第三方语音识别服务。
1、客户端调用第三方服务识别
客户端直接调用第三方语音识别服务,如微信提供了 JS-SDK 的语音识别 API ,返回识别的语音文本的信息,并且已经经过语义化。优点是识别较快,且不许关注语音存储问题,缺点是不安全,识别结果提交到服务端之前可能被恶意篡改。
2、服务端调用第三方服务识别
先将录制的语音上传至存储平台,然后服务端调用第三方语音识别服务,第三方语音识别服务去获取语音信息并识别,返回识别的语音文本的信息。优点是识别结果较安全,缺点是系统交互较多,识别效率不高。
我们业务场景的特殊性,存在用户可助力次数的限制,所以无需担心恶意刷赞的情况,因此可以选用方案 1,语音识别的交互流程如下:
此时,整个语音识别流程如下:
当然中国文字博大精深,语音识别的文本在匹配时,需要考虑容错处理,可以将文本转化为拼音,然后匹配拼音,或者设置一个匹配百分比,达到匹配值则认为语音口令正确。
需要注意的是,微信只提供 3 天的语音存储服务,若语音播放周期较长,则要考虑实现语音的存储。
我们使用了线上公账号进行红包发放测试,为了让线上公众号能够授权到测试环境,在线上的微信授权回调地址新增一个参数,将带有to=feature
参数的请求引流到测试环境,其他线上流量还是保持不变,匹配规则如下:
# Nginx不支持if嵌套,所以就这样变通实现 |
由于本次活动力度较大,预估流量会比以往增加不少(不能再出现机房带宽打满的情况了,不然 >﹏<),静态页面占流量的很大一部分,所以静态页面在发布时都会放置一份在 CDN 上,这样回源的流量就很小了。
尽管做了很多准备,还是无法确保万无一失,我们在每个关键节点都增加了开关,一点出现异常,通过配置中心可以人工介入做降级处理。
]]>为了获得 Elasticsearch 更好的体验,我们需要获得 Elastic 的使用授权,安装颁发的永久 License 证书。
首先,前往 registration 地址注册,稍后我们会收到 License 的下载地址:
接着,点击邮件中的 地址 下载 License 文件,并另存为fan-haobai-dbc3f18c-f87e-40e4-9a1d-f496e58a591e-v5.json
:
然后,通过 Elasticsearch 的 API 更新 License:
文件名前有@符号 |
通过 Kibana 查看新的证书信息:
Input 插件指定了 Logstash 事件的输入源,已经支持 beats、kafka、redis 等源的输入。
例如,配置 Beats 源为输入,且端口为 5044:
input { |
Filter 插件主要功能是数据过滤和格式化,通过简洁的表达式就可以完成数据的处理。
以下这些配置信息,为插件共有配置:
配置项 | 类型 | 描述 |
---|---|---|
add_field | hash | 添加字段 |
add_tag | array | 添加标签 |
remove_field | array | 删除字段 |
remove_tag | array | 删除标签 |
Drop 插件用来过滤掉无价值的数据,例如过滤掉静态文件日志信息:
if [url] =~ "\.(jpg|jpeg|gif|png|bmp|swf|fla|flv|mp3|ico|js|css|woff)" { |
我们可以用 Date 插件来格式化时间字段。
例如,将 time 字段值格式化为dd/MMM/YYYY:H:m:s Z
形式:
date { match => [ "[time]", "dd/MMM/YYYY:H:m:s Z" ] } |
Mutate 插件用来对字段进行 rename、replace 、merge 以及字段值 convert、split、join 操作。
例如,将字段@timestamp
重命名(rename 或 replace)为 read_timestamp:
mutate { rename => { "@timestamp" => "read_timestamp" } } |
以下是对字段值的操作,使用频率较高。
例如,将 response_code 字段值转换为整型:
mutate { convert => { "fieldname" => "integer" } } |
例如,将经纬度坐标用数组表示:
mutate { split => { "location" => "," } } |
例如,将经纬度坐标合并:
mutate { join => { "location" => "," } } |
Kv 插件能够对 key=value 格式的字符进行格式化或过滤处理,这里只对 field_split 项配置进行说明,更多配置见 Kv Filter Configuration Options。
例如,获取形如?name=cat&type=2
GET 请求的参数:
kv { field_split => "&?" } |
处理后,将会获取到以下 2 个参数:
name: cat
type: 2
Json 插件当然是用来解析 Json 字符串,而 Json_encode 插件是对字段编码为 Json 字符串。例如,Nginx 日志为 Json 格式,则:
json { source => "message" } |
Grok 插件可以根据指定的表达式 结构化 文本数据,表达式需形如%{SYNTAX:SEMANTIC}
格式,SYNTAX 指定字段值类型,可以为 IP、WORD、DATA、NUMBER 等。
例如,形如55.3.244.1 GET /index.html 15824 0.043
的请求日志,则对应的表达式应为%{IP:client} %{WORD:method} %{WORD:request} %{NUMBER:bytes} %{NUMBER:duration}
,配置如下:
grok { |
经过 Grok 过滤后,输出为:
client: 55.3.244.1
method: GET
request: /index.html
bytes: 15824
duration: 0.043
我们可以使用 Grok Debug 在线调试 Grok 表达式,常用 Nginx、MySQL、Redis 日志的 Grok 表达式见 Configuration Examples 部分。
useragent 插件用来解析用户客户端信息,geoip 插件可以根据 IP 地址解析出用户所在的地址位置,配置较简单,这里不做说明。
Output 插件配置 Logstash 输出对象,可以为 elasticsearch、email、file 等介质。
例如,配置过滤后存储在 Elasticsearch 中:
output { |
当然,Output 插件不只是可以将过滤数据输出到一种介质,还可以同时指定多种介质。
实现基于 Nginx 日志进行过滤处理,并且通过 useragent 和 geoip 插件获取用户客户端和地理位置信息。详细配置如下:
input { |
相对应的 Filebeat 的配置见 filebeat.yml 部分。
Logstash 在推送数据至 Elasticsearch 时,默认会自动创建索引,但有时候我们需要定制化索引信息,Logstash 创建的索引就不符合我们的要求,此时就可以使用索引模板来解决。
创建一个名为logstash
的索引模板,并指定该索引模板的匹配模式,作为 Logstash 推送日志时索引的模板。
PUT _template/logstash |
其中 index_patterns 为匹配模式,表示含有 access 和 error 的索引才会使用该模板。mappings 为字段映射规则,可以配置更多的字段映射规则,已配置字段根据索引模板规则映射,未配置字段则动态映射。
Logstash 推送数据到 Elasticsearch 时,可以通过以下几种方式指定字段存储类型。
grok { |
其中 IP、WORD、NUMBER 分别会映射为 Elasticsearch 的 IP、String、Number 类型。
通过 Mutate 过滤插件的 convert 配置项,可以转换字段值类型。
mutate { convert => { "fieldname" => "integer" } } |
若想要根据用户 IP 地址解析后的地理位置信息,得出访问用户的地理分布情况,就需要在 Elasticsearch 中将用户地理坐标存储为 geo_point 类型,而 Logstash 并不能自动完成这个步骤,我们可以在索引模板中指定 location 字段的类型为 geo_point。
Elasticsearch 待存储的地理位置数据,格式如下:
{"geoip": { |
索引模板的 Mappings 部分,应设置为:
{"mappings": { |
日志平台会产生大量的索引文件,这样不但会占用磁盘空间,而且还会导致检索性能降低,对于那些已经失效的日志文档,应该定期对其清理。
最简单的办法就是给每个索引设定 TTLs(过期时间),在索引模板中定义失效时间为 7 天:
PUT /_template/logstash |
索引的 TTLs 特性已经从 Elasticsearch 5+ 版本移除,故不推荐使用该方式。
例如,日志中时间格式形如"2016-12-24T17:36:14.000Z
,则清理 7 天前日志的查询条件为:
{ |
上述查询中,@timestamp
指定查询字段,format
指定时间的 格式 为date_time
,now-7d
表示当前时间往前推移 7 天的时间。
配置定期清理过期日志的任务:
0 0 * * * /usr/bin/curl -H'Content-Type:application/json' -d'query' -XPOST "host/*/_delete_by_query?pretty" > path.log |
其中,query
为待清理日志的查询条件,path.log
为日志文件路径。
该方式只是删除了过期的日志文档,并不会删除过期的索引信息,适用于对特定索引下的日志文档进行定期清理的场景。
我们部署日志收集时,通常会以日、月的形式归档建立索引,所以清理过期日志,只需清理过期的索引。
这里通过GET /_cat/indices
和DELETE /index?pretty
这 2 个 API 完成过期索引的清理,清理脚本如下:
|
配置定时任务:
0 0 * * * /usr/local/elk/elasticsearch/bin/delete-index.sh >> /usr/local/elk/elasticsearch/logs/delete-index.log 2>&1 |
该方式通过自定义脚本方式,可以较灵活的配置所需清理的过期索引,使用起来简洁轻便,但若 Elasticsearch 采用集群方式部署,那么该方式就不是很灵活了。
当遇到清理过期索引比较复杂的场景时,就可以使用官方提供的管理工具 Curator。其不仅可以进行复杂场景的索引管理,还可以进行快照管理,而实现这一切,只需要配置 YAML 格式的配置文件。
这里使用 yum 安装,先配置 yum 源。在/etc/yum.repos.d/
目录下创建名为curator.repo
的文件,内容如下:
[curator-5] |
使用 yum 命令安装:
rpm --import https://packages.elastic.co/GPG-KEY-elasticsearch |
创建名为/etc/curator/curator.yml
的配置文件,主要用来配置 Elasticsearch 服务的相关信息:
client: |
其中,需要配置 hosts、port、http_auth 这 3 个配置项。
例如,待清理索引的格式形如test-2017.11.16
,需清理 7 天过期的索引。创建名为delete-index.yml
的 配置 文件,内容如下:
actions: |
Curator 支持配置多个任务,其中 action 为任务动作,filters 为管道过滤器,filtertype 为过滤器的过滤类型,支持多种过滤类型。
测试删除过期索引:
删除前 |
配置每天执行任务:
0 0 * * * /usr/bin/curator --config /etc/curator/curator.yml /etc/curator/delete-index.yml |
该方式不但直接通过配置即可方便实现过期索引的清理,而且可以在复杂场景轻松地管理索引、快照等,故推荐该方式。
上述一切准备步骤做好后,我们就可以利用 Kibana 对大量的日志数据进行报表分析,进而实现应用监控和流量分析。
选择 Kibana 的 ”Managemant >> Kibana >> Index Patterns” 项 ,创建一个名为nginx-www-access*
的索引模式,并设为默认索引,如图:
选择 Kibana 的 ”Visualize” 项,创建一个数据图表,Kibana 已经支持了丰富的图标类型,这里选择 Line 类型图表制作一个用户访问量的图表。
图表的 Metrics(指标) 和 Buckets(桶)属性,Metrics 用来表示 PV 和 UV,而 Buckets 则是时间维度,UV 需要根据 location 去重后统计。
图表的 Metrics 部分,如下图:
图表的 Buckets 部分,如下图:
最后,生成的用户访问量图表如文章起始所示。
当我们创建了各种指标的数据图表后,就可以将这些数据图表组合成一个实时监控面板。选择 Kibana 的 ”Dashboard” 项,创建一个监控面板,并添加所需监控指标的数据图表,拖拽调整各图表到合适位置并保存,一个实时监控面板就呈现在眼前了。
下面是我针对主站 Blog 健康监控和流量分析做出的实时 数据报表 展示,基本上满足了实时监控要求。
当 Logstash 运行一段时间后,你可能会发现日志中出现大量的 OutOfMemory 错误,并且服务器 CPU 处于 100% 状态。产生原因是因为 Logstash 堆栈溢出,进而要频繁进行 GC 操作导致。
尽管在 安装 过程中调整了 Logstash 内存大小,这个由于服务器硬件限制导致的问题还是没法根本解决,但是可以规避问题嘛。很简单,这种堆栈溢出只会长期运行出现,所以只需要定期重启 Logstash 即可。定时任务为:
0 */12 * * * /sbin/service logstash restart |
相关文章 »
ELK 需要 JAVA 8 以上的运行环境,若未安装则按如下步骤安装:
查看是否安装 |
在文件/etc/profile
配置环境变量:
指向安装目录,其中1.8.0.151需与版本号保持一致 |
执行source /etc/profile
命令,使配置环境生效。
由于后续采用 yum 安装,所以需要下载并安装 GPG-KEY:
rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch |
yum 命令会安装最新的版本,若需安装较旧的版本,请先从 官方地址 下载对应的旧版本 rpm 包,然后使用
rpm -ivh
命令安装。
通过 官方地址 下载选择最新版本,然后解压:
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.1.1.tar.gz |
启动前,需要修改配置文件jvm.options
中 JVM 大小,否则可能会内存溢出,导致启动失败。
vim config/jvm.options |
由于 Elasticsearch 新版本不允许以 root 身份启动,因此先创建 elk 用户。这里使用 service 服务方式管理 Elasticsearch,修改启动用户和安装目录。
useradd elk |
设置开机启动服务,启动 Elasticsearch,其默认监听 9200 端口。
开启服务 |
最后,安装使用到的插件:
cd /usr/local/elk/elasticsearch |
安装 x-pack 插件后,对 Elasticsearch 的操作都需要授权,默认用户名为 elastic,默认密码为 changeme。
首先,在/etc/yum.repos.d
目录下创建名为kibana.repo
的 yum 源文件:
[kibana-5.x] |
使用 yum 命令安装:
yum install -y kibana |
修改配置文件kibana.yml
以下配置项:
mkdir -p /usr/local/elk/kibana/config |
安装常用插件,例如 x-pack:
bin/kibana-plugin install x-pack |
Kibana 运行时 NodeJs 默认会最大分配 1G 内存,可以在启动时增加max-old-space-size
参数,以限制其运行内存大小:
vim bin/kibana |
修改 init 启动脚本,并启动 Kibana:
vim /etc/init.d/kibana |
配置 Web 服务后,访问 elk.fanhaobai.com 就可以看到 Kibana 强大又绚丽的界面。
安装 x-pack 插件后,访问 Kibana 同样需要授权,且任何 Elasticsearch 的用户名和密码对都可被认证通过。
首先,在/etc/yum.repos.d
目录下创建logstash.repo
文件:
[logstash-5.x] |
使用 yum 安装 Logstash,并测试:
安装logstash 5.x |
修改配置文件路径:
mv /etc/logstash /usr/local/elk/logstash/config |
修改 JVM 内存大小,防止出现内存溢出异常:
vim config/jvm.options |
生成并修改 init 启动脚本:
bin/system-install /etc/logstash/startup.options sysv |
安装 x-pack 插件,基本状态信息的监控:
bin/logstash-plugin install x-pack |
Logstash 主配置文件为config/logstash.yml
,配置如下:
path.data: /var/lib/logstash |
创建一个简单的管道(inputs → filters → outputs),配置文件为conf.d/filebeat.conf
。日志过滤处理后,直接推送到 Elasticsearch,在 output 部分需配置 Elasticsearch 的用户名和密码。
input { |
完整配置见 配置示例 部分,更多配置示例见 Logstash Configuration Examples。
service logstash start |
由于同 Elasticsearch 使用一个源,所以直接使用 yum 安装:
安装filebeat 5.6.6 |
修改 init 启动脚本:
vim /etc/init.d/filebeat |
配置启动服务:
chkconfig --add filebeat |
创建 Filebeat 配置文件filebeat.yml
,开启 nginx 日志模块采集 access 日志信息:
filebeat.modules: |
service filebeat start |
Filebeat 启动后,会侦测待采集文件内容是否有增加或更新,并实时推送数据到 Logstash。
因为 Filebeat、Logstash 有些配置并不向后兼容,更新升级后可能导致服务不可用,所以这里在
/etc/yum.conf
增加exclude=filebeat logstash
配置项,禁用yum update
的自动更新。
Filebeat 推送到 Logstash 过滤后,Elasticsearch 存储的数据格式为:
{ |
在 Kibana 中呈现效果为:
相关文章 »
很早前,我们的应用都已经接入了 CAT,能够在线实时查看应用访问量、异常的调用情况等应用性能指标,同时也打通了各平台的调用链路,基本满足应用的性能监控要求。
由于我们应用业务日志并没有推送到 CAT,所以当线上出现问题时,传统方式查看业务日志,排查问题比较困难,搭建业务日志集中平台迫在眉睫。经过调研,我们选择了 Elastic 提供的 ELK 日志解决方案,查看 在线演示。
原因主要有两点:
ELK 指的是一套解决方案,是 Elasticsearch、Logstash 和 Kibana 三种软件产品的首字母缩写,Beats 是 ELK 协议栈的新成员。
其中,目前 Beats 家族根据功能划分,主要包括 4 种:
在该日志平台系统中,就使用了 Filebeat 作为日志文件收集工具,Filebeat 可以很方便地收集 Nginx、Mysql、Redis、Syslog 等应用的日志文件。
ELK 集中式日志平台,总体上来说,部署在应用服务器上的数据采集器,近实时收集日志数据推送到日志过滤节点的 Logstash,然后 Logstash 再推送格式化的日志数据到 Elasticsearch 存储,Kibana 通过 Elasticsearch 集中检索日志并可视化。
当然,ELK 集中日志平台也是经过一次次演变,才变成最终的样子。
最开始的架构中,由 Logstash 承担数据采集器和过滤功能,并部署在应用服务器。由于 Logstash 对大量日志进行过滤操作,会消耗应用系统的部分性能,带来不合理的资源分配问题;另一方面,过滤日志的配置,分布在每台应用服务器,不便于集中式配置管理。
使用该架构,引入 Logstash-forwarder 作为数据采集,Logstash 和应用服务器分离,应用服务器只做数据采集,数据过滤统一在日志平台服务器,解决了之前存在的问题。但是 Logstash-forwarder 和 Logstash 间通信必须由 SSL 加密传输,部署麻烦且系统性能并没有显著提升;另一方面,Logstash-forwarder 的定位并不是数据采集插件,系统不易扩展。
该架构,基于 Logstash-forwarder 架构,将 Logstash-forwarder 替换为 Beats。由于 Beats 的系统性能开销更小,所以应用服务器性能开销可以忽略不计;另一方面,Beats 可以作为数据采集插件形式工作,可以按需启用 Beats 下不同功能的插件,更灵活,扩展性更强。例如,应用服务器只启用 Filebeat,则只收集日志文件数据,如果某天需要收集系统性能数据时,再启用 Metricbeat 即可,并不需要太多的修改和配置。
这种 ELK+Beats 的架构,已经满足大部分应用场景了,但当业务系统庞大,日志数据量较大、较实时时,业务系统就和日志系统耦合在一起了。
该架构,引入消息队列,均衡了网络传输,从而降低了网络闭塞,尤其是丢失数据的可能性;另一方面,这样可以系统解耦,具有更好的灵活性和扩展性。
比较成熟的 ELK+Beats 架构,因其扩展性很强,是集中式日志平台的首选方案。在实际部署时,是否引入消息队列,根据业务系统量来确定,早期也可以不引入消息队列,简单部署,后续需要扩展再接入消息队列。
相关文章 »
为了能在更多的时间配置更多的房子,我要不断的优化物流从仓库 A 到房间 G 的路径或者仓库 B 到房间 E 的距离,请写出一种算法给你任意图中两点,计算出两点之间的最短距离。
注:A B C D E F G H 都可能是仓库或者房间,点与点之间是距离。
该题是求解无向图单源点的最短路径,经常采用 Dijkstra 算法求解,是按路径长度递增的次序产生最短路径。
Dijkstra 算法是运用了最短路径的最优子结构性质,最优子结构性质描述为:P(i,j) = {$v_i$,…,$v_k$,…,$v_s$,$v_j$} 是从顶点 i 到 j 的最短路径,顶点 k 和 s 是这条路径上的一个中间顶点,那么 P(k,s) 必定也是从 k 到 s 的最短路径。
由于 P(i,j) = {$v_i$,…,$v_k$,…,$v_s$,$v_j$} 是从顶点 i 到 j 的最短路径,则有 P(i,j) = P(i,k) + P(k,s) + P(k,j)。若 P(k,s) 不是从顶点 k 到 s 的最短路径,那么必定存在另一条从顶点 k 到 s 的最短路径 P’(k,s),故 P’(i,j) = P(i,k) + P’(k,s) + P(k,j) < P(i,j),与题目相矛盾,因此 P(k,s) 是从顶点 k 到 s 的最短路径。
根据最短路径的最优子结构性质,Dijkstra 提出了以最短路径长度递增,逐次生成最短路径的算法。譬如对于源顶点 $v_0$,首先选择其直接相邻的顶点中最短路径的顶点$v_i$,那么可得从 $v_0$ 到达 $v_j$ 顶点的最短距离 $D[j]=min(D[j], D[j] + matrix[i][j])$($matrix[i][j]$ 为从顶点 $v_i$ 到 $v_j$ 的直接距离)。
假设存在图 G={V,E},V 为所有顶点集合,源顶点为 $v_0$,U={$v_0$} 表示求得终点路径的集合,D[i] 为顶点 $v_0$ 到 $v_i$ 的最短距离,P[i] 为顶点 $v_0$ 到 $v_i$ 最短路径上的顶点。
算法描述为:
1)从 V-U 中选择使 D[i] 值最小的顶点 $v_i$,将 $v_i$ 加入到 U 中;
2)更新 $v_i$ 与任一顶点 $v_j$ 的最短距离,即 $D[j]=min(D[j], D[i]+matrix[i][j])$;
3)直到 U=V,便求得从顶点 $v_0$ 到图中任一一点的最短路径;
例如,求 CG 最短路径,算法过程可图示为:
源顶点 $v_0$ = C,顶点与索引关系为 A→H = 0→7,初始时:
将顶点 C 包含至 U 中:
更新顶点 C 至任一节点的距离:
再选择不在 U 中的最短路径顶点 A,则将 A 包含至 U 中:
更新顶点 A 至任一节点的距离:
继续选择不在 U 中的最短路径顶点 B,则将 B 包含至 U 中:
更新顶点 B 至任一节点的距离:
以此类推,直到遍历结束:
因此,CG 的最短距离为 33,最短路径为 C-B-E-F-G。
实现的代码如下,并将一一详细说明。
define('MAX', 9999999999); |
Dijkstra 算法求解:
public function dijkstra() |
接收标准输入处理并输出结果:
//图 |
本问题是求无向图源点的最短路径,时间复杂度为 $O(n^2)$,若求解有向图源点的最短路径,只需将相邻顶点的逆向路径置为 ∞,即修改初始图的矩阵。不得不说的是,比求单源点最短路径更加复杂的求某一对顶点的最短路径问题,也可以以每一个顶点为源点使用 Dijkstra 算法求解,但是有更加简洁的 Floyd 算法。
相关文章 »
组委会准备了一些小游戏来获得这些礼物,其中有一个游戏是这样的:组委会让小朋友围成一个圈。然后随机制定一个数 e,让编号为 0 的小朋友开始报数。每次喊道 e-1 的小朋友直接出列,淘达出局。从本次喊道 e-1 的下一个小朋友开始,继续从 0 报数…e-1 淘汰出局…一直这样进行…最后进行到最后一个小朋友,这位可以拿到”熊帅”亲笔签名的”木木”毛绒玩具。(注:小朋友的编号是从 0 到 n-1 )
示例:
输入:n=1314 e=520
输出:796
输入:n=88888 e=1018
输出:69148
该题是一个 约瑟夫环 问题(猴子选大王),这里运用数学知识找出递推关系式。
假设,总共有 n 个人,数到 k(n >= k)的人被杀掉,幸存者的位置为 $p_n$(为了便于理解,编号从 1 开始)。
易知,初始位置为 k 的人会被第一个杀掉。此时,经过重新排序之后,问题变成了 n-1 个人的情形。幸存者的位置为 $p_{n-1}$。如果能够找到从 $p_{n}$ 到 $p_{n-1}$ 的递推关系,那么问题就解决了。
重新编号后,上一轮相对这一轮每个人位置关系映射为:
1 -> n-k+1
2 -> n-k+2
…
k-1 -> n-1
k+1 -> 1
…
$p_n$ -> $p_{n-1}$
…
n-1 -> n-k+1
n -> n-k
这样,我们就得到一个递推关系式:$p_n = (p_{n-1} + k)$ % $n$,初始条件 $p_1 = 1$(1 个人时幸存者为自己),当然该提递推公式同样适用于 n < k 的情况。
约瑟夫环递推实现:
function josephus($n, $e) |
接收标准输入处理并输出结果:
$input = str_replace(' ', '&', $input); |
由于本题只是需要求出最终的幸存者编号,所以可以直接使用递推公式求解,算法时间复杂度为 $O(n)$。若需要模拟整个游戏过程,则需要使用 链表 模拟实现。
相关文章 »
假设师傅每天工作 8 个小时,给定一天 n 个订单,每个订单其占用时间长为 $T_i$,挣取价值为 $V_i$,现请您为师傅安排订单,并保证师傅挣取价值最大。
输入格式
输入 n 组数据,每组以逗号分隔,并且每一个订单的编号、时长、挣取价值以空格分隔
输出格式
输出争取价值和订单编号,订单编号按照价值由大到小排序,争取价值相同,则按照每小时平均争取价值由大到小排序
示例:
输入:[MV10001 2 100,MV10008 2 30,MV10003 1 200,MV10009 6 500,MV10010 3 400]
输出:730 MV10010 MV10003 MV10001 MV10008
输入:[M10001 2 100,M10002 3 210,M10003 3 300,M10004 2 150,M10005 1 70,M10006 2 220,M10007 1 10,M10008 3 30,M10009 3 200,M10010 2 400]
输出:990 M10010 M10003 M10006 M10005
由于本题每个订单每天只被安排一次,是典型地采用 动态规划 求解的 01 背包问题。
动态规划过程:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
动态规划原理:动态规划与分治法类似,都是把原问题拆分成不同规模相同特征的小问题,通过寻找特定的递推关系,先解决一个个小问题,最终达到解决原问题的效果。
假设,师傅挣取价值最大时的订单为 $x_1$,$x_2$,$x_3$,…,$x_i$(其中 $x_i$ 取 1 或 0,表示第 i 个订单被安排或者不安排),$v_i$ 表示第 i 个订单的价值,$w_i$ 表示第 i 个订单的耗时时长,$wv(i,j)$ 表示安排了第 i 个订单,师傅总耗时为 j 时的最大价值。
可得订单价值和耗时的关系图:
i | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
w(i) | 2 | 2 | 1 | 6 | 3 |
v(i) | 100 | 30 | 200 | 500 | 400 |
因此,可得 动态方程:
$$wv(i,j) = \begin{cases}
wv(i-1,j)(j < w(i)) \
max(wx(i-1,j),wv(i-1,j-w(i))+v(i))(j \geq w(i))
\end{cases}$$
说明:$j<w(i)$ 表示订单不被安排,$j \geq w(i)$ 表示订单被安排。
可以确定边界条件 $wx(0,j) = wx(i, 0) = 0$,$wx(0,j)$ 表示一个订单都没安排,再怎么耗时价值都为 0,$wx(i,0)$ 表示没有耗时,安排多少订单价值都为 0。
求解过程,可以填表来进行模拟:
尽管 求解 过程已经求出了最大价值,但是并没有得出哪些订单被安排了,也就是没有得出解的组成部分。
但是在求解的过程中不难发现,寻解方程满足如下定义:
$$x(i) = \begin{cases}
wv(i,j) = wv(i-1,j) \
wv(i,j) \neq wv(i-1,j)
\end{cases}$$
从表格右下到左上为寻解方向,寻解过程如下:
实现的代码如下,并将一一详细说明。
class Knapsack |
动态求解过程:
public function pd() |
寻解过程:
public function canPut() |
按照订单价值降序获取订单信息(若订单价值相同则按单位时间平均价值降序排列):
public function getGoods() |
接收标准输入处理并输出结果:
$arr = explode(',', $input); |
该题使用动态规划求解,算法的时间复杂度为 $O(nc)$,当然也可以采用其他方式求解。例如先将订单按照价值排序,然后依次尝试进行安排订单,直至剩余耗时不能再被安排订单。
有关动态规划的其他典型应用,请参考 常见的动态规划问题分析与求解 一文。
相关文章 »
例如二维数组为:
9 9 9 9
3 0 0 9
7 8 2 6
时,答案是中间的 0,0 位置可以存储 2(因为其外面最低是 2)个单位的水,因此答案为 2 + 2 = 4。
示例:
输入:[1 1 1 1,1 0 0 1,1 1 1 1]
输出:2
输入:[12 11 12 0 13,12 9 8 12 12,13 10 0 3 15,19 4 4 7 15,19 4 3 0 15,12 13 10 15 13]
输出:58
这道题是所有题中困惑我时间最长的题,一开始思维禁锢在想直接通过找到每块砖的四周有效最低砖高度 $H_{min}$,然后这块砖所剩的水为 $w[i][j] = H_{min}+h[i][j]$($h[i][j]$ 为砖的高度,i 和 j 为砖的位置坐标),因此蓄水池能蓄下的水为 $\sum_{i=1}^n\sum_{j=1}^n w[i][j]$。经过一番尝试,发现寻找某块砖四周最低有效砖逻辑比较复杂,且不易理解,又尝试过使用回溯算法寻找出池子中的所有连通图,但是也未有果。
最后,发现基础平台一位同学的实现思路很清晰,我认为他的实现是最合适的,所以研究了一下。该实现中机智地采用逆向思维,首先往池子注满水(最高砖的高度),然后再通过条件判定每块砖是否需要进行漏水,一直到没有砖需要进行漏水操作。
实现思路如下:
算法流程图示如下:
实现的代码如下,并将一一详细说明。
|
注水操作:
public function addWater() |
漏水操作:
public function removeWater() |
漏水条件实现如下:
public function canRemove($row, $col) |
持续漏水操作:
public function run() |
求和砖的盛水量:
public function collect() |
接收标准输入处理并输出结果:
$filter = function ($value) { |
Twitter 之前曾经出过类似蓄水池的笔试题,只不过本题是立体水池(二维数组),Twitter 蓄水池笔试题是平面水池(一维数组),解题复杂度也就降低了,当然 Twitter 蓄水池笔试题也可以采用本题的思想来实现,但是时间复杂度为 $O(n^2)$,采用 我的Twitter技术面试失败了 的实现时间复杂度为 $O(n)$。
实现思路如下:
具体实现,请直接参考 CuGBabyBeaR 文章。
本题的蓄水池问题,如果理解了问题本质并逆向思维,将寻找某块砖四周最低有效砖高度(寻找有效砖涉及到边界扩散)转化为判断某块砖是否需要漏水条件,那么问题就简化很多了,那后续编码也就很容易实现了,本文算法的时间复杂度为 $O(n^3)$。
相关文章 »
所有题目中第 4 题蓄水池问题,是困惑我时间比较长的,其他题目比较容易看出考察点,这里我给出了 7 道题目自己的 实现方式,仅作为解题参考,若你有更好的思路欢迎讨论交流。
本章只叙述前 3 道相对简单的题目,后续题目及解题思路将在 王者编程大赛系列 中列出。
由于采用标准输入进行输入,为了防止输入多余的字符,影响程序执行结果,所以有必要对输入进行过滤处理。
//等待输入并过滤 |
活动“司庆大放送,一元即租房”,司庆当日,对于签约入住的客户,住满 30 天,返还(首月租金 -1 元)额度的租金卡。租金卡的面额遵循了类似人民币的固定面额(1000 元、500 元、100 元、50 元、20 元、10 元、5 元、1 元),请实现一个算法,给客户返还的租金卡张数是最少的。
示例:
输入(租金卡金额):54
输出:5
输入(租金卡金额):9879
输出:20
该问题其实就是,对租金卡的金额对题目中所列出的租金卡面额按照从大到小的顺序做商,一直到余数为 0。
function getCards($rent) { |
2016 年自如将品质管理中心升级为安全与品质管理中心,并开通了自如举报邮箱。请看下边的算式
1 2 3 4 5 6 7 8 9 = 110(举报邮箱前缀数字);
为了使等式成立,需要在数字间填入加号或者减号(可以不填,但不能填入其它符号)。之间没有填入符号的数字组合成一个数,例如:12 + 34 + 56 + 7 - 8 + 9 就是一种合格的填法。
请大家帮忙计算出多少个可能组合吧,至少 3 个以上算有效。
示例:
输入:[1 2 3 4 5 6 7 8 9]
输出:12 + 34 + 56 + 7 - 8 + 9
该题考查点是排列组合问题,待连接的数字已经有序,所以只需要确定相邻两个数字的连接符即可。假设待连接数字的长度为 n,那么问题可以描述为,将空格、+、- 这 3 种连接符插入到 n-1 个相邻待连接数字之间的位置,所以共有 3^n-1 种情况,然后判断每种情况的计算结果是否为等式右边的数字。
为了获取 3 种连接符组成的 3^n-1 种组合情况,这里巧妙地运用 3 进制运算 来实现。
算法执行流程:
实现代码如下,并将一一详细说明。
|
3 进制运算的实现,注意需要对高位进行补 0 的操作,其中 number 为 3^n-1(组合情况)。
public function ternary($number) |
对 3 种连接符组成 3^n-1 种组合,根据等式成立情况进行取舍:
public function run() |
接收标准输入并输出结果:
//输入:[1 2 3 4 5 6 7 8 9] |
给定一个所有元素为非负的数组,将数组中的所有数字连接起来,求最大的那个数。
示例:
输入:4,94,9,14,1
输出:9944141
输入:121,89,98,15,4,3451
输出:98894345115121
这道题是我司的笔试题目之一,我之前写的《求非负数组元素组成的最大字符串》文章,已经有过实现过程的描述。当然这道题可能有很多种实现方式,但是我认为最合适的实现还是采用排序的方式,容易理解,实现也简洁。
这里使用冒泡排序来进行说明,每一趟找出待排序元素的最小值,算法执行流程如下:
定义比较规则,ab 和 ba 组合后的数字进行值大小的比较:
function cmp($a, $b) { |
接收输入并输出结果:
function array_form_max_str(array $Arr) { |
这 3 道题算是热身吧,第 2 题巧妙运用 3 进制运算来模拟排列情况,都相对较容易,只是实现方式你是否会在意优雅而已。虽然简单,但是也不建议一上来就开始编码,首先要想清楚解题思路,然后编码实现即可。
相关文章 »
原文:http://wiki.phpboy.net/doku.php?id=2017-07:55-异步_并发_协程原理.md
Linux 操作系统在设计上将虚拟空间划分为用户空间和内核空间,两者做了隔离是相互独立的,用户空间给应用程序使用,内核空间给内核使用。
内核具有最高权限,可以访问受保护的内存空间,可以访问底层的硬件设备。而这些是应用程序所不具备的,但应用程序可以通过调用内核提供的接口来间接访问或操作。所谓的常见的 IO 模型就是基于应用程序和内核之间的交互所提出来的。以一次网络 IO 请求过程中的 read 操作为例,请求数据会先拷贝到系统内核的缓冲区(内核空间),再从操作系统的内核缓冲区拷贝到应用程序的地址空间(用户空间)。而从内核空间将数据拷贝到用户空间过程中,就会经历两个阶段:
也正因为有了这两个阶段,才提出了各种网络 I/O 模型。
同步(Synchronised)和异步(Asynchronized)的概念描述的是应用程序与内核的交互方式,同步是指应用程序发起 I/O 请求后需要等待或者轮询内核 I/O 操作完成后才能继续执行;而异步是指应用程序发起 I/O 请求后仍继续执行,当内核 I/O 操作完成后会通知应用程序,或者调用应用程序注册的回调函数。
阻塞和非阻塞的概念描述的是应用程序调用内核 IO 操作的方式,阻塞是指 I/O 操作需要彻底完成后才返回到用户空间;而非阻塞是指 I/O 操作被调用后立即返回给用户一个状态值,无需等到 I/O 操作彻底完成。
常见的网络I/O模型大概有四种:
多路 I/O 复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗)。** IO 多路复用是异步阻塞的。**
并发,在操作系统中,是指 一个时间段 中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
并发和并行的区别:
多线程或多进程是并行的基本条件,但单线程也可用协程做到并发。通常情况下,用多进程来实现分布式和负载平衡,减轻单进程垃圾回收压力;用多线程抢夺更多的处理器资源;用协程来提高处理器时间片利用率。现代系统中,多核 CPU 可以同时运行多个不同的进程或者线程。所以并发程序可以是并行的,也可以不是。
在了解协程前先了解一些概念:
在现代计算机结构中,先后提出过两种线程模型:用户级线程(user-level threads)和内核级线程(kernel-level threads)。所谓用户级线程是指,应用程序在操作系统提供的单个控制流的基础上,通过在某些控制点(比如系统调用)上分离出一些虚拟的控制流,从而模拟多个控制流的行为。由于应用程序对指令流的控制能力相对较弱,所以,用户级线程之间的切换往往受线程本身行为以及线程控制点选择的影响,线程是否能公平地获得处理器时间取决于这些线程的代码特征。而且,支持用户级线程的应用程序代码很难做到跨平台移植,以及对于多线程模型的透明。用户级线程模型的优势是线程切换效率高,因为它不涉及系统内核模式和用户模式之间的切换;另一个好处是应用程序可以采用适合自己特点的线程选择算法,可以根据应用程序的逻辑来定义线程的优先级,当线程数量很大时,这一优势尤为明显。但是,这同样会增加应用程序代码的复杂性。有一些软件包(如 POSIXThreads 或 Pthreads 库)可以减轻程序员的负担。
内核级线程往往指操作系统提供的线程语义,由于操作系统对指令流有完全的控制能力,甚至可以通过硬件中断来强迫一个进程或线程暂停执行,以便把处理器时间移交给其他的进程或线程,所以,内核级线程有可能应用各种算法来分配处理器时间。线程可以有优先级,高优先级的线程被优先执行,它们可以抢占正在执行的低优先级线程。在支持线程语义的操作系统中,处理器的时间通常是按线程而非进程来分配,因此,系统有必要维护一个全局的线程表,在线程表中记录每个线程的寄存器、状态以及其他一些信息。然后,系统在适当的时候挂起一个正在执行的线程,选择一个新的线程在当前处理器上继续执行。这里“适当的时候”可以有多种可能,比如:当一个线程执行某些系统调用时,例如像 sleep 这样的放弃执行权的系统函数,或者像 wait 或 select 这样的阻塞函数;硬中断(interrupt)或异常(exception);线程终止时,等等。由于这些时间点的执行代码可能分布在操作系统的不同位置,所以,在现代操作系统中,线程调度(thread scheduling)往往比较复杂,其代码通常分布在内核模块的各处。
内核级线程的好处是,应用程序无须考虑是否要在适当的时候把控制权交给其他的线程,不必担心自己霸占处理器而导致其他线程得不到处理器时间。应用线程只要按照正常的指令流来实现自己的逻辑即可,内核会妥善地处理好线程之间共享处理器的资源分配问题。然而,这种对应用程序的便利也是有代价的,即,所有的线程切换都是在内核模式下完成的,因此,对于在用户模式下运行的线程来说,一个线程被切换出去,以及下次轮到它的时候再被切换进来,要涉及两次模式切换:从用户模式切换到内核模式,再从内核模式切换回用户模式。在 Intel 的处理器上,这种模式切换大致需要几百个甚至上千个处理器指令周期。但是,随着处理器的硬件速度不断加快,模式切换的开销相对于现代操作系统的线程调度周期(通常几十毫秒)的比例正在减小,所以,这部分开销是完全可以接受的。
除了线程切换的开销是一个考虑因素以外,线程的创建和删除也是一个重要的考虑指标。当线程的数量较多时,这部分开销是相当可观的。虽然线程的创建和删除比起进程要轻量得多,但是,在一个进程内建立起一个线程的执行环境,例如,分配线程本身的数据结构和它的调用栈,完成这些数据结构的初始化工作,以及完成与系统环境相关的一些初始化工作,这些负担是不可避免的。另外,当线程数量较多时,伴随而来的线程切换开销也必然随之增加。所以,当应用程序或系统进程需要的线程数量可能比较多时,通常可采用线程池技术作为一种优化措施,以降低创建和删除线程以及线程频繁切换而带来的开销。
在支持内核级线程的系统环境中,进程可以容纳多个线程,这导致了多线程程序设计(multithreaded programming)模型。由于多个线程在同一个进程环境中,它们共享了几乎所有的资源,所以,线程之间的通信要方便和高效得多,这往往是进程间通信(IPC,Inter-Process Communication)所无法比拟的,但是,这种便利性也很容易使线程之间因同步不正确而导致数据被破坏,而且,这种错误存在不确定性,因而相对来说难以发现和调试。
许多协同式多任务操作系统,也可以看成协程运行系统。说到协同式多任务系统,一个常见的误区是认为协同式调度比抢占式调度“低级”,因为我们所熟悉的桌面操作系统,都是从协同式调度(如 Windows 3.2, Mac OS 9 等)过渡到抢占式多任务系统的。实际上,调度方式并无高下,完全取决于应用场景。抢占式系统允许操作系统剥夺进程执行权限,抢占控制流,因而天然适合服务器和图形操作系统,因为调度器可以优先保证对用户交互和网络事件的快速响应。当年 Windows 95 刚刚推出的时候,抢占式多任务就被作为一大买点大加宣传。协同式调度则等到进程时间片用完或系统调用时转移执行权限,因此适合实时或分时等等对运行时间有保障的系统。
另外,抢占式系统依赖于 CPU 的硬件支持。 因为调度器需要“剥夺”进程的执行权,就意味着调度器需要运行在比普通进程高的权限上,否则任何“流氓(rogue)”进程都可以去剥夺其他进程了。只有 CPU 支持了执行权限后,抢占式调度才成为可能。x86 系统从 80386 处理器开始引入 Ring 机制支持执行权限,这也是为何 Windows 95 和 Linux 其实只能运行在 80386 之后的 x86 处理器上的原因。而协同式多任务适用于那些没有处理器权限支持的场景,这些场景包含资源受限的嵌入式系统和实时系统。在这些系统中,程序均以协程的方式运行。调度器负责控制流的让出和恢复。通过协程的模型,无需硬件支持,我们就可以在一个“简陋”的处理器上实现一个多任务的系统。我们见到的许多智能设备,如运动手环,基于硬件限制,都是采用协同调度的架构。
“协程”(Coroutine)概念最早由 Melvin Conway 于 1958 年提出。协程可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换。相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低。总的来说,协程为协同任务提供了一种运行时抽象,这种抽象非常适合于协同多任务调度和数据流处理。在现代操作系统和编程语言中,因为用户态线程切换代价比内核态线程小,协程成为了一种轻量级的多任务模型。
从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制,迭代器常被用来实现协程,所以大部分的语言实现的协程中都有 yield 关键字,比如 Python、PHP、Lua。但也有特殊比如 Go 就使用的是通道来通信。
有趣的是协程的历史其实要早于线程。
WIKI 的解释:
Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations. Coroutines are well-suited for implementing more familiar program components such as cooperative tasks, exceptions, event loop, iterators, infinite lists and pipes.
** 不同模型下用户空间与内核空间的关系:**
注:协程可以理解为上图中的用户级线程模型。
C 标准库里的函数 setjmp 和 longjmp 可以用来实现一种协程。
Go 语言是原生支持语言级并发的,这个并发的最小逻辑单元就是 goroutine。goroutine 就是 Go 语言提供的一种用户态线程,当然这种用户态线程是跑在内核级线程之上的。当我们创建了很多的 goroutine,并且它们都是跑在同一个内核线程之上的时候,就需要一个 调度器(scheduler)来维护这些 goroutine,确保所有的 goroutine 都使用 CPU,并且是尽可能公平的使用 CPU 资源。Go 的 scheduler 比较复杂,它实现了 M:N 的模式。M:N 模式指的是多个 goroutine 在多个内核线程上跑,Go 的 scheduler 可参考>>。goroutine 让 Go 低成本地具有了高并发运算能力。另外 Go 协程是通过通道(channel)来通信的。
注意:goroutine 的实现并不完全是传统意义上的协程。在协程阻塞的时候(CPU 计算或者文件 IO 等),多个 goroutine 会变成多线程的方式执行。
func main() { |
Python 协程基于 Generator。Python 实现的 grep 例子:
def grep(pattern): |
Lua 中的协同是一协作的多线程,每一个协同等同于一个线程,yield-resume 可以实现在线程中切换。然而与真正的多线程不同的是,协同是非抢占式的。当程序运行到 yield 的时候,使用协程将上下文环境记录住,然后将程序操作权归还到主函数,当主函数调用 resume 的时候,会重新唤起协程,读取 yield 记录的上下文。这样形成了程序语言级别的多协程操作。
co = coroutine.create( -- 创建coroutine |
PHP 5.5 一个比较好的新功能是加入了对迭代生成器和协程的支持。PHP 协程也是基于 Generator,Generator 可以视为一种“可中断”的函数,而 yield 构成了一系列的“中断点”。PHP 协程没有 resume 关键字,而是“在使用的时候唤起”协程。
function xrange($start, $end, $step = 1) { |
Swoole 在 2.0 开始内置协程(Coroutine)的能力,提供了具备协程能力 IO 接口(统一在命名空间Swoole\Coroutine*)。基于 setjmp、longjmp 实现,在进行协程切换时会自动保存 Zend VM 的内存状态(主要是 EG 全局内存和 vm stack)。
由于 Swoole 是在底层封装了协程,所以对比传统的 PHP 层协程框架,开发者不需要使用 yield 关键词来标识一个协程 IO 操作,所以不再需要对 yield 的语义进行深入理解以及对每一级的调用都修改为 yield。
协程与异步:协程并不是说替换异步,协程一样可以利用异步实现高并发。
协程与并发:协程要利用多核优势就需要比如通过调度器来实现多协程在多线程上运行,这时也就具有了并行的特性。如果多协程运行在单线程或单进程上也就只能说具有并发特性。
相关文章 »
QConf 是奇虎 360 广泛使用的配置管理服务,现已开源 QConf Source Code,欢迎大家关注使用。本文从设计初衷,架构实现,使用情况及相关产品比较四个方面进行介绍。
在分布式环境中,出于负载、容错等种种原因,几乎所有的服务都需要在不同的机器节点上部署多个实例。同时,业务项目中总少不了各种类型的配置文件。这种情况下,有时仅仅是一个配置内容的修改,便需要重新进行代码提交 git,打包,分发上线的流程。当部署的机器有很多时,分发上线本身也是一个很繁杂的工作。而配置文件的修改频率又远远大于代码本身。追本溯源,我们认为所有的这些麻烦是由于我们对配置和代码在管理和发布过程中不加区分造成的。配置本身源于代码,是为了提高代码的灵活性而提取出来的一些经常变化的或需要定制的内容,而正是配置的这种天生的变化特征给我们带了很大的麻烦。因此,我们开发了分布式配置管理系统 QConf,并依托 QConf 在 360 内部提供了一整套配置管理服务,QConf 致力于将配置内容从代码中完全分离出来,及时可靠高效地提供配置访问和更新服务。
为了让大家对之后的内容有个直观的认识,先来介绍一下如果需要在自己的项目中使用 QConf 应该怎么做:
cmake |
需要说明的是,使用 QConf 后已经没有所谓的配置文件的概念,你要做的就是在需要的地方获取正确的内容,QConf 认为,这才是你真正想要的。
了解了 QConf 的设计初衷和使用方式,相信大家已经对 QConf 有一个整体的认识并且对其实现有了大概的猜想。在介绍架构之前,还需要申明一下 QConf 对配置信息的定位,因为这个定位直接决定了其结构设计和组件选择。
进入主题,开始介绍 QConf 的架构实现:
上图展示的是 QConf 的基本结构,从角色上划分主要包括 QConf 客户端,QConf 服务端和 QConf 管理端。
QConf 使用 ZooKeeper 集群作为服务端提供服务。众所周知,ZooKeeper 是一套分布式应用程序协调服务,根据上面提到的对配置内容的定位,我们认为可以将单条配置内容直接存储在 ZooKeeper 的一个 ZNode 上,并利用 ZooKeeper 的 Watch 监听功能实现配置变化时对客户端的及时通知。按照 ZooKeeper 的设计目标,其只提供最基础的功能,包括顺序一致,原子性,单一系统镜像,可靠性和及时性。另外 Zookeeper 还有如下特点:
关于 Zookeeper,更多见 https://zookeeper.apache.org/
但在接口方面,ZooKeeper 本身只提供了非常基本的操作,并且其客户端接口原始,所以我们需要在 QConf 的客户端部分 解决如下问题:
下面来看下 QConf 客户端的架构:
可以看到 QConf 客户端主要有:agent、各种语言接口、连接他们的消息队列和共享内存。在 QConf 中,配置以 key-value 的形式存在,业务进程给出 key 获得对应 value,这与传统的配置文件方式是一致的。
下面通过两个主要场景的数据流动来说明他们各自的功能和角色:
通过上面的说明,可以看出 QConf 的整体结构和流程非常简单。QConf 中各个组件或线程之间仅通过有限的中间数据结构通信,耦合性非常小,各自只负责自己的本职工作和一亩三分地,而不感知整体结构。下面通过几个点来详细介绍:
管理端是业务修改配置的页面入口,利用数据库提供一些如批量导入,权限管理,版本控制等上层功能。由于公司内的一些业务耦合和需求定制,当前开源的 QConf 管理端这边提供了一个简易的页面,和一套下层的 c++ 接口,如下图:
之后计划进一步完善以及跟社区合作提供更友好的界面。
QConf 的结构及实现大概就介绍到这,接下来…
QConf 除了存储配置的基本功能外,还在公司内提供了一套简单的服务发现功能,该功能允许业务在 QConf 上配置一组服务,QConf 会监控其服务的存活。当业务进程调用获取服务的接口时,会根据用户需求,返回全部可用服务,或某一可用服务。不同于普通配置:
需要明确的是,目前 Monitor 事实上仅仅是通过查看服务端口的存活来判断的,在实际生产环境中,该功能多与实际服务提供者的监控结合,由服务提供者的监控调用 QConf 的相应接口实现服务的上下线。
目前 360 内部已经广泛的使用 QConf。覆盖云盘、大流程、系统部、dba、图搜、影视、地图、硬件、手机卫士、广告、好搜等大部分业务。部署国内外共 51 几个机房,客户端机器超两万台,稳定运行两年。
使用的方式主要包括:
QConf 因为其对配置信息的定位,使得整个结构非常简单,容易部署和使用。在 Github 可以找到完整代码,QConf Source Code 欢迎关注。
相关文章 »
]]>原文:https://github.com/pangudashu/php7-internal/blob/master/1/fpm.md
FPM(FastCGI Process Manager)是 PHP FastCGI 运行模式的一个进程管理器,从它的定义可以看出,FPM 的核心功能是进程管理,那么它用来管理什么进程呢?这个问题就需要从 FastCGI 说起了。
FastCGI 是 Web 服务器(如:Nginx、Apache)和处理程序之间的一种通信协议,它是与 Http 类似的一种应用层通信协议,注意:它只是一种协议!
前面曾一再强调,PHP 只是一个脚本解析器,你可以把它理解为一个普通的函数,输入是 PHP 脚本。输出是执行结果,假如我们想用 PHP 代替 shell,在命令行中执行一个文件,那么就可以写一个程序来嵌入 PHP 解析器,这就是 cli 模式,这种模式下 PHP 就是普通的一个命令工具。接着我们又想:能不能让 PHP 处理 http 请求呢?这时就涉及到了网络处理,PHP 需要接收请求、解析协议,然后处理完成返回请求。在网络应用场景下,PHP 并没有像 Golang 那样实现 http 网络库,而是实现了 FastCGI 协议,然后与 web 服务器配合实现了 http 的处理,web 服务器来处理 http 请求,然后将解析的结果再通过 FastCGI 协议转发给处理程序,处理程序处理完成后将结果返回给 web 服务器,web 服务器再返回给用户,如下图所示。
PHP 实现了 FastCGI 协议的解析,但是并没有具体实现网络处理,一般的处理模型:多进程、多线程,多进程模型通常是主进程只负责管理子进程,而基本的网络事件由各个子进程处理,nginx、fpm 就是这种模式;另一种多线程模型与多进程类似,只是它是线程粒度,通常会由主线程监听、接收请求,然后交由子线程处理,memcached 就是这种模式,有的也是采用多进程那种模式:主线程只负责管理子线程不处理网络事件,各个子线程监听、接收、处理请求,memcached 使用 udp 协议时采用的是这种模式。
概括来说,fpm 的实现就是创建一个 master 进程,在 master 进程中创建并监听 socket,然后 fork 出多个子进程,这些子进程各自 accept 请求,子进程的处理非常简单,它在启动后阻塞在 accept 上,有请求到达后开始读取请求数据,读取完成后开始处理然后再返回,在这期间是不会接收其它请求的,也就是说 fpm 的子进程同时只能响应一个请求,只有把这个请求处理完成后才会 accept 下一个请求,这一点与 nginx 的事件驱动有很大的区别,nginx 的子进程通过 epoll 管理套接字,如果一个请求数据还未发送完成则会处理下一个请求,即一个进程会同时连接多个请求,它是非阻塞的模型,只处理活跃的套接字。
fpm 的 master 进程与 worker 进程之间不会直接进行通信,master 通过共享内存获取 worker 进程的信息,比如 worker 进程当前状态、已处理请求数等,当 master 进程要杀掉一个 worker 进程时则通过发送信号的方式通知 worker 进程。
fpm 可以同时监听多个端口,每个端口对应一个 worker pool,而每个 pool 下对应多个 worker 进程,类似 nginx 中 server 概念。
在 php-fpm.conf 中通过[pool name]
声明一个 worker pool:
[web1] |
启动 fpm 后查看进程:
ps -aux|grep fpm |
具体实现上 worker pool 通过fpm_worker_pool_s
这个结构表示,多个 worker pool 组成一个单链表:
struct fpm_worker_pool_s { |
接下来看下 fpm 的启动流程,从main()
函数开始:
//sapi/fpm/fpm/fpm_main.c |
fpm_init()
主要有以下几个关键操作:
__(1) fpm_conf_init_main():__
解析 php-fpm.conf 配置文件,分配 worker pool 内存结构并保存到全局变量中:fpm_worker_all_pools,各 worker pool 配置解析到fpm_worker_pool_s->config
中。
__(2)fpm_scoreboard_init_main():__
分配用于记录 worker 进程运行信息的共享内存,按照 worker pool 的最大 worker 进程数分配,每个 worker pool 分配一个fpm_scoreboard_s
结构,pool 下对应的每个 worker 进程分配一个fpm_scoreboard_proc_s
结构,各结构的对应关系如下图。
__(3)fpm_signals_init_main():__
static int sp[2]; |
这里会通过socketpair()
创建一个管道,这个管道并不是用于 master 与 worker 进程通信的,它只在 master 进程中使用,具体用途在稍后介绍 event 事件处理时再作说明。另外设置 master 的信号处理 handler,当 master 收到 SIGTERM、SIGINT、SIGUSR1、SIGUSR2、SIGCHLD、SIGQUIT 这些信号时将调用sig_handler()
处理:
static void sig_handler(int signo) |
__(4)fpm_sockets_init_main()__
创建每个 worker pool 的 socket 套接字。
__(5)fpm_event_init_main():__
启动 master 的事件管理,fpm 实现了一个事件管理器用于管理 IO、定时事件,其中 IO 事件通过 kqueue、epoll、poll、select 等管理,定时事件就是定时器,一定时间后触发某个事件。
在fpm_init()
初始化完成后接下来就是最关键的fpm_run()
操作了,此环节将 fork 子进程,启动进程管理器,另外 master 进程将不会再返回,只有各 worker 进程会返回,也就是说fpm_run()
之后的操作均是 worker 进程的。
int fpm_run(int *max_requests) |
在 fork 后 worker 进程返回了监听的套接字继续 main() 后面的处理,而 master 将永远阻塞在fpm_event_loop()
,接下来分别介绍 master、worker 进程的后续操作。
fpm_run()
执行后将 fork 出 worker 进程,worker 进程返回main()
中继续向下执行,后面的流程就是 worker 进程不断 accept 请求,然后执行 PHP 脚本并返回。整体流程如下:
int main(int argc, char *argv[]) |
worker 进程一次请求的处理被划分为 5 个阶段:
worker 处理到各个阶段时将会把当前阶段更新到fpm_scoreboard_proc_s->request_stage
,master 进程正是通过这个标识判断 worker 进程是否空闲的。
这一节我们来看下 master 是如何管理 worker 进程的,首先介绍下三种不同的进程管理方式:
pm.max_children
配置 fork 出相应数量的 worker 进程,即 worker 进程数是固定不变的;pm.start_servers
初始化一定数量的 worker,运行期间如果 master 发现空闲 worker 数低于pm.min_spare_servers
配置数(表示请求比较多,worker 处理不过来了)则会 fork worker 进程,但总的 worker 数不能超过pm.max_children
,如果 master 发现空闲 worker 数超过了pm.max_spare_servers
(表示闲着的 worker 太多了)则会杀掉一些 worker,避免占用过多资源,master 通过这 4 个值来控制 worker 数;pm.max_children
,处理完成后 worker 进程不会立即退出,当空闲时间超过pm.process_idle_timeout
后再退出;前面介绍到在fpm_run()
中 master 进程将进入fpm_event_loop()
:
void fpm_event_loop(int err) |
这就是 master 整体的处理,其进程管理主要依赖注册的几个事件,接下来我们详细分析下这几个事件的功能。
(1)sp[1]管道可读事件:
在fpm_init()
阶段 master 曾创建了一个全双工的管道:sp,然后在这里创建了一个 sp[0] 可读的事件,当 sp[0] 可读时将交由fpm_got_signal()
处理,向 sp[1] 写数据时 sp[0] 才会可读,那么什么时机会向 sp[1] 写数据呢?前面已经提到了:当 master 收到注册的那几种信号时会写入 sp[1] 端,这个时候将触发 sp[0] 可读事件。
这个事件是 master 用于处理信号的,我们根据 master 注册的信号逐个看下不同用途:
具体处理逻辑在fpm_got_signal()
函数中,这里不再罗列。
(2)fpm_pctl_perform_idle_server_maintenance_heartbeat():
这是进程管理实现的主要事件,master 启动了一个定时器,每隔 1s 触发一次,主要用于 dynamic、ondemand 模式下的 worker 管理,master 会定时检查各 worker pool 的 worker 进程数,通过此定时器实现 worker 数量的控制,处理逻辑如下:
static void fpm_pctl_perform_idle_server_maintenance(struct timeval *now) |
(3)fpm_pctl_heartbeat():
这个事件是用于限制 worker 处理单个请求最大耗时的,php-fpm.conf
中有一个request_terminate_timeout
的配置项,如果 worker 处理一个请求的总时长超过了这个值那么 master 将会向此 worker 进程发送kill -TERM
信号杀掉 worker 进程,此配置单位为秒,默认值为 0 表示关闭此机制,另外 fpm 打印的 slow log 也是在这里完成的。
static void fpm_pctl_check_request_timeout(struct timeval *now) |
除了上面这几个事件外还有一个没有提到,那就是 ondemand 模式下 master 监听的新请求到达的事件,因为 ondemand 模式下 fpm 启动时是不会预创建 worker 的,有请求时才会生成子进程,所以请求到达时需要通知 master 进程,这个事件是在fpm_children_create_initial()
时注册的,事件处理函数为fpm_pctl_on_socket_accept()
,具体逻辑这里不再展开,比较容易理解。
到目前为止我们已经把 fpm 的核心实现介绍完了,事实上 fpm 的实现还是比较简单的。
]]>Supervisor 官方 提供的安装方式较多,这里采用 pip 方式安装。
yum install python-pip |
通过 pip 安装 Supervisor:
pip install supervisor |
安装 Supervisor 后,会出现 supervisorctl 和 supervisord 两个程序,其中 supervisorctl 为服务监控终端,而 supervisord 才是所有监控服务的大脑。查看 supervisord 是否安装成功:
supervisord -v |
将 supervisord 配置成开机启动服务,下载官方 init 脚本。
修改关键路径配置:
PIDFILE=/var/run/supervisord.pid |
移到该文件到/etc/init.d
目录下,并重命名为 supervisor,添加可执行权限:
chmod 777 /etc/init.d/supervisor |
配置成开机启动服务:
chkconfig --add supervisor |
Supervisord 安装后,需要使用如下命令生成配置文件。
mkdir /etc/supervisor |
supervisord.conf
的主配置部分说明:
[unix_http_server] |
这部分我们不需要做太多的配置修改,如果需要开启 WEB 终端监控,则需要配置并开启 inet_http_server 项。
Supervisor 需管理的进程服务配置,示例如下:
[program:work] ; 服务名,例如work |
通常将每个进程的配置信息配置成独立文件,并通过 include 模块包含,这样方便修改和管理配置文件。
配置完成后,启动 supervisord 守护服务:
supervisord -c /etc/supervisor/supervisord.conf |
常用的命令参数说明:
查看 supervisord 启动情况:
ps -ef | grep "supervisor" |
Supervisor 提供了多种监控服务的方式,包括 supervisorctl 命令行终端、Web 端、XML_RPC 接口多种方式。
直接使用 supervisorctl 即可在命令行终端查看所有服务的情况,如下:
supervisorctl |
supervisorctl 常用命令列表如下;
在配置中开启 inet_http_server 后,即可通过 Web 界面便捷地监控进程服务了。
]]>今天一大早就升级 IOS 11 尝鲜,并习惯性打开 APP,点着点着就发现 通勤找房 功能异常。
Charles 抓包查看接口信息:
URL v7/commute/search.json |
由于该新上线功能已经接入网关,所以响应内容是密文。使用解密工具解密后:
{ |
很明显,出现了接口签名错误。然后查看该功能模块的另一个配置接口并无异常,未接入网关的其他接口也无异常,且该接口在 IOS 11 以下版本并无异常。
到公司后,首先跟 APP 端使用 IOS 11 版本在测试环境复现该问题,发现测试环境也存在一样的问题,于是 APP 端打开调试 Log,同时在服务端抓取该次调试请求参数并对比,果不出意外,签名的 sign 值不一致。
sign=00aca6ddf61da553e1d3a152d2531241&city_code=110000&zoom=2&transport=transit&clng=116.53516158527775&minute=45&uid=0&max_lat=40.050779703285322&clat=40.038686258547742&min_lng=116.52039350058239&imei=b08572622e0b803bd72298d223febd10f782e348&min_lat=40.024114254242676×tamp=1506155272&max_lng=116.54241877617007 |
首先,我们怀疑可能是 md5 加密方式问题,所以将相同的请求参数串和盐加密,两端对比发现是一致的。然后开始怀疑是网关加密解密导致 sign 不一致,同样 APP 打印传入网关的参数与服务端请求参数对比,发现也是保持一致。不过有了意外发现,APP 计算 sign 时参数和传入网关参数存在浮点数精度不一致问题。最后 APP 排查到是由于在 IOS 11 中使用了某个 JSON 方法,导致浮点数精度前后不一致。
解决办法是,服务端针对该版本取消接口签名校验,APP 端下个版本进行修复。
PM 说有一个类似于抢购的小需求,我们第一反应就想到是典型的防止库存超卖场景,于是理所因当地选用了 Redis 方案。只要保证是原子操作,即可防止库存超卖,自然想到使用 Incr/Decr 这类原子操作。
查看 PHP 的 Redis 扩展关于 Incr 方法的说明:
/** |
可见,Incr 方法返回的是 key 操作后的新值,即 ++1 后的值,于是我们写出了如下代码:
$num = $redis->incr($key); |
不知道你有没有闻到这段代码的坏味道,在大部分情况下会如你所想地运行,但是特殊场景下会 出现判断失效 的逻辑问题,例如:
1、key 由于某些原因失效了;
2、Incr 操作失败了,不会抛异常并返回 false;
上述两种情况,都会导致$num < $max
条件成立,进而导致更严重的逻辑问题,最终超卖。
我们就抢购开始后就遇到了上述的第二种情况,下面描述整个过程。先通过 Cat 监控平台观察到访问量急剧上升,开始担心应用服务坑不住,随后日志平台报警 Incr 操作存在异常几率,再然后就出现超卖情况,紧急情况只能关闭业务开关。是什么原因导致判断条件成立?
通过日志定位到 Incr 操作问题,便 Telnet 连接到线上 Redis 服务,发现了异常情况:
查看值 |
可以看出来,该连接的机器目前处于从机状态,不可写操作,所以 Incr 操作返回 false,同时 PHP 不同类型比较会存在隐式转化,所以false < $num
恒成立,导致计数器失效。而这一切又是由于 Redis 高可用不完善,当主从切换后,VIP 未能成功漂移,这部分是运维的锅,研发代码不够健壮,这锅同样要背 >﹏<。
首先,修改代码使其更加健壮,增加计数器容错处理:
$num = $redis->incr($key); |
然后,切换 Redis 源到高可用集群(Codis),测试并重新上线,第二日的抢购已经正常,看着 Cat 上流量逐渐平稳,心里也踏实了。
这个事故后,该系统也由二级系统升级为一级系统,并将制定一些弱类型语言规范,如这件事的惨痛教训:
另外,如果使用 Redis 支持业务,必须考虑 Redis 的读/写操作量,以便选择合适并高可用的集群。这里由于产品前期提需时就指出是一个小需求,所以没去顾及到这些。好吧,貌似不能把锅甩给产品,代码写健壮才是王道。
]]>lua-nginx-module 依赖于 LuaJIT 和 ngx_devel_kit。LuaJIT 需要安装,ngx_devel_kit 只需下载源码包,在 Nginx 编译时指定 ngx_devel_kit 目录。
首先确保系统已安装如下依赖库。
yum install readline-devel pcre-devel openssl-devel gcc |
首先,安装 LuaJIT 环境,如下所示:
wget http://luajit.org/download/LuaJIT-2.0.5.tar.gz |
设置 LuaJIT 有关的环境变量。
export LUAJIT_LIB=/usr/local/lib |
下载 ngx_devel_kit 源码包,如下:
wget https://github.com/simpl/ngx_devel_kit/archive/v0.3.0.tar.gz |
接下来,下载 Lua 模块 lua-nginx-module 源码包,为 Nginx 编译作准备。
wget https://github.com/openresty/lua-nginx-module/archive/v0.10.10.tar.gz |
Nginx 1.9 版本后可以动态加载模块,但这里由于版本太低只能重新编译安装 Nginx。下载 Nginx 源码包并解压:
wget http://nginx.org/download/nginx-1.13.5.tar.gz |
编译并重新安装 Nginx:
cd nginx-1.13.5 |
现在只需配置 Nginx,即可嵌入 Lua 脚本。首先,在 http 部分配置 Lua 模块和第三方库路径:
# 第三方库(cjson)地址luajit-2.0/lib |
接着,配置一个 Lua 脚本服务:
# hello world测试 |
测试安装和配置是否正常:
service nginx test |
lua-nginx-module 模块中已经为 Lua 提供了丰富的 Nginx 调用 API,每个 API 都有各自的作用环境,详细描述见 Nginx API for Lua。这里只列举基本 API 的使用 。
先配一个 Lua 脚本服务,配置文件如下:
location ~ /lua_api { |
可以通过ngx.var.var_name
形式获取或设置 Nginx 变量值,例如 request_uri、host、request 等。
-- ngx.say打印内容 |
该方法会以表的形式返回当前请求的头信息。查看请求的头信息:
ngx.say('Host : ', ngx.req.get_headers().host, '<br>') |
当然,通过 ngx.req.set_header() 也可以设置头信息。
ngx.req.set_header("Content-Type", "text/html") |
该方法以表形式返回当前请求的所有 GET 参数。查看请求 query 为?name=fhb
的 GET 参数:
ngx.say('name : ', ngx.req.get_uri_args().name, '<br>') |
同样,可以通过 ngx.req.set_uri_args() 设置请求的所有 GET 参数。
ngx.req.set_uri_args({name='fhb'}) --{name='fhb'}可以为query形式name=fhb |
该方法以表形式返回当前请求的所有 POST 参数,POST 数据必须是 application/x-www-form-urlencoded 类型。查看请求curl --data 'name=fhb' localhost/lua_api
的 POST 参数:
--必须先读取body体 |
通过 ngx.req.get_body_data() 方法可以获取未解析的请求 body 体内容字符串。
获取请求的大写字母形式的请求方式,通过 ngx.req.set_method() 可以设置请求方式。例如:
ngx.say(ngx.req.get_method()) |
通过ngx.header.header_name
的形式获取或设置响应头信息。如下:
ngx.say(ngx.header.content_type) |
ngx.print() 方法会填充指定内容到响应 body 中。如下所示:
ngx.print(ngx.header.content_type) |
如上述使用,ngx.say() 方法同 ngx.print() 方法,只是会在后追加一个换行符。
以某个状态码返回响应内容,状态码常量对应关系见 HTTP status constants 部分,也支持数字形式的状态码。
ngx.exit(403) |
重定向当前请求到新的 url,响应状态码可选列表为 301、302(默认)、303、307。
ngx.redirect('http://www.fanhaobai.com') |
该方法提供了正则表达式匹配方法。请求?name=fhb&age=24
匹配 GET 参数中的数字:
local m, err = ngx.re.match(ngx.req.set_uri_args, "[0-9]+") |
通过该方法可以将内容写入 Nginx 日志文件,日志文件级别需同 log 级别一致。
它们都是字符串编码方式。ngx.md5() 可以对字符串进行 md5 加密处理,而 ngx.encode_base64() 是对字符串 base64 编码, ngx.decode_base64() 为 base64 解码。
上面讲述了怎么在 Lua 中调用 Nginx 的 API 来扩展或定制 Nginx 的功能,那么编写好的 Lua 脚本怎么在 Nginx 中得到执行呢?其实,Nginx 是通过模块指令形式在其 11 个处理阶段做插入式处理,指令覆盖 http、server、server if、location、location if 这几个范围。
这里只列举基本的 Lua 模块指令,更多信息参考 Directives 部分。
指令 | 所在阶段 | 使用范围 | 说明 |
---|---|---|---|
init_by_lua init_by_lua_file | 加载配置文件 | http | 可以用于初始化全局配置 |
set_by_lua set_by_lua_file | rewrite | server location location if | 复杂逻辑的变量赋值,注意是阻塞的 |
rewrite_by_lua rewrite_by_lua_file | rewrite | http server location location if | 实现复杂逻辑的转发或重定向 |
content_by_lua content_by_lua_file | content | location location if | 处理请求并输出响应 |
header_filter_by_lua header_filter_by_lua_file | 响应头信息过滤 | http server location location if | 设置响应头信息 |
body_filter_by_lua body_filter_by_lua_file | 输出过滤 | http server location location if | 对输出进行过滤或修改 |
注意到,每个指令都会有*_lua
和*_lua_file
两个指令,*_lua
指令后为 Lua 代码块,而*_lua_file
指令后为 Lua 脚本文件路径。下面将只对*_lua
指令进行说明。
该指令会在 Nginx 的 Master 进程加载配置时执行,所以可以完成 Lua 模块初始化工作,Worker 进程同样会继承这些。
nginx.conf
配置文件中的 http 部分添加如下代码:
-- 所有worker共享的全局变量 |
init.lua
初始化脚本为:
local cjson = require 'cjson' |
我们直接使用 set 指令很难实现很复杂的变量赋值逻辑,而 set_by_lua 模块指令就可以解决这个问题。
nginx.conf
配置文件 location 部分内容为:
location /lua { |
set.lua
脚本内容为:
local uri_args = ngx.req.get_uri_args() |
上述赋值逻辑,请求 query 为?a=10&b=2
时响应内容为 12。
可以实现内部 URL 重写或者外部重定向。nginx.conf
配置如下:
location /lua { |
rewrite.lua
脚本内容:
if ngx.req.get_uri_args()["type"] == "app" then |
用于访问权限控制。例如,只允许带有身份标识用户访问,nginx.conf
配置为:
location /lua { |
access.lua
脚本内容为:
if ngx.req.get_uri_args()["token"] == "fanhb" then |
该指令在 Lua 调用 Nginx 部分已经使用过了,用于输出响应内容。
使用 Lua 模块对本站的 ES 服务做受信操作控制,即非受信 IP 只能查询操作。nginx.conf
配置如下:
location / { |
在 Nginx 配置文件的 location 部分配置 Lua 脚本基本参数,并配置 Lua 模块指令:
default_type "text/html"; |
Lua 脚本实现频率控制逻辑,使用 Redis 对单位时间内的访问次数做缓存,key 为访问 uri 拼接 token 后的 md5 值。具体内容如下:
local redis = require "resty.redis" |
相关文章 »
我们先看一个抢购场景下 商品库存 的问题,用 PHP 可简单实现为:
$key = 'number:string'; |
这段代码其实存在问题,高并发时会出现库存超卖的情况,因为上述操作在 Redis 中不是原子操作,会导致库存逻辑的判断失效。尽管可以通过优化代码来解决问题,比如使用 Decr 原子操作命令、或者使用 锁 的方式,但这里使用 Lua 脚本来解决。
local key = 'number:string' |
这段脚本代码虽然是 Lua 语言编写( 进入Lua的世界),但是其实就是 PHP 版本的翻译版。那为什么这样,Lua 脚本就能解决库存问题了呢?
Redis 中嵌入 Lua 脚本,所具有的几个特性为:
Redis 提供了 EVAL(直接执行脚本) 和 EVALSHA(执行 SHA1 值的脚本) 这两个命令,可以使用内置的 Lua 解析器执行 Lua 脚本。语法格式为:
参数说明:
EVAL 命令的使用示例:
> EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second |
每次使用 EVAL 命令都会传递需执行的 Lua 脚本内容,这样增加了宽带的浪费。Redis 内部会永久保存被运行在脚本缓存中,所以使用 EVALSHA(建议使用) 命令就可以根据脚本 SHA1 值执行对应的 Lua 脚本。
> SCRIPT LOAD "return 'hello'" |
Redis 中执行 Lua 脚本都是以原子方式执行,所以是原子操作。另外,redis-cli 命令行客户端支持直接使用
--eval lua_file
参数执行 Lua 脚本。
Redis 中有关脚本的命令除了 EVAL 和 EVALSHA 外,其他常用命令 如下:
命令 | 描述 |
---|---|
SCRIPT EXISTS script [script …] | 查看脚本是是否保存在缓存中 |
SCRIPT FLUSH | 从缓存中移除所有脚本 |
SCRIPT KILL | 杀死当前运行的脚本 |
SCRIPT LOAD script | 将脚本添加到缓存中,不立即执行 返回脚本SHA1值 |
由于 Redis 和 Lua 都有各自定义的数据类型,所以在使用执行完 Lua 脚本后,会存在一个数据类型转换的过程。
Lua 到 Redis 类型转换与 Redis 到 Lua 类型转换相同部分关系:
Lua 类型 | Redis 返回类型 | 说明 |
---|---|---|
number | integer | 浮点数会转换为整数 3.333–>3 |
string | bulk | |
table(array) | multi bulk | |
boolean false | nil |
> EVAL "return 3.333" 0 |
需要注意的是,从 Lua 转化为 Redis 类型比 Redis 转化为 Lua 类型多了一条 额外 规则:
Lua 类型 | Redis 返回类型 | 说明 |
---|---|---|
boolean true | integer | 返回整型 1 |
> EVAL "return true" 0 |
总而言之,类型转换的原则 是将一个 Redis 值转换成 Lua 值,之后再将转换所得的 Lua 值转换回 Redis 值,那么这个转换所得的 Redis 值应该和最初时的 Redis 值一样。
为了防止不必要的数据泄漏进 Lua 环境, Redis 脚本不允许创建全局变量。
-- 定义全局函数 |
执行redis-cli --eval function.lua
命令,会抛出尝试定义全局变量的错误:
(error) ERR Error running script (call to f_0a602c93c4a2064f8dc648c402aa27d68b69514f): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'f' |
Redis 创建了用于与 Lua 环境协作的组件—— 伪客户端,它负责执行 Lua 脚本中的 Redis 命令。
在 Redis 内置的 Lua 解析器中,调用 redis.call() 和 redis.pcall() 函数执行 Redis 的命令。它们除了处理错误的行为不一样外,其他行为都保持一致。调用 格式:
> EVAL "return redis.call('SET', 'name', 'fhb')" 0 |
在 Lua 脚本中,可以通过调用 redis.log() 函数来写 Redis 日志。格式为:
redis.log(loglevel, message)
loglevel 参数可以是 redis.LOG_DEBUG、redis.LOG_VERBOSE、redis.LOG_NOTICE、redis.LOG_WARNING 的任意值。
查看redis.conf
日志配置信息:
logleval必须一致才会记录 |
Lua 写 Redis 日志示例:
> EVAL "redis.log(redis.LOG_NOTICE, 'I am fhb')" 0 |
通过 Lua 实现一个针对用户的 API 访问速率控制,Lua 代码如下:
local key = "rate.limit:string:" .. KEYS[1] |
KEYS[1] 可以用 API 的 URI + 用户 uid 组成,ARGV[1] 为单位时间限制访问的次数,ARGV[2] 为限制的单位时间。
这个例子演示通过 Lua 实现批量 HGETALL,当然也可以使用 管道 实现。
-- KEYS为uid数组 |
虽然使用 Lua 脚本给我们带来了许多便利,但是需要注意几个使用事项:
{}
标记的 hash tag 方式解决。相关文章 »
Lua 作为通用型脚本语言,有 8 种基本数据类型:
类型 | 说明 | 示例 |
---|---|---|
nil | 只有一种值 nil 标识和别的任何值的差异 | nil |
boolean | 两种值 false 和 true | false |
number | 实数(双精度浮点数) | 520 |
string | 字符串,不区分单双引号 | “fhb” ‘fhb’ |
function | 函数 | function haha() return 1 end |
userdata | 将任意 C 数据保存在 Lua 变量 | |
thread | 区别独立的执行线程 用来实现协程 | |
table | 表,实现了一个关联数组 唯一一种数据结构 | {1, 2, 3} |
使用库函数 type() 可以返回一个变量或标量的类型。有关数据类型需要说明的是:
Lua 中有三类变量:全局变量、局部变量、还有 table 的域。任何变量除非显式的以 local 修饰词定义为局部变量,否则都被定义为全局变量,局部变量作用范围为函数或者代码块内。说明,在变量的首次赋值之前,变量的值均为 nil。
-- 行注释 |
Lua 中用到的名字(标识符)可以是任何非数字开头的字母、数字、下划线组成的字符串,同大多数语言保持一致。
下面这些是保留的关键字,不能用作名字:
大部分的流程控制关键字将在 流程控制 部分说明。
大部分运算操作符将在 表达式 部分进行说明。
Lua 的一个执行单元叫做 chunk(语句组),一个语句组就是一串语句段,而 block(语句块)是一列语句段。
do block end |
下面将介绍 Lua 的主要流程控制语句。
Lua 中同样是用 if 语句作为条件流程控制语句,else if 或者 else 子句可以省略。
-- exp为条件表达式,block为条件语句 |
控制结构中的条件表达式可以返回任何值。 false 和 nil 都被认为是假,所有其它值都被认为是真。另外 Lua 中并没有提供 switch 子句,我们除了使用冗长的 if 子句外,怎么实现其他语言中的 switch 功能呢?
-- 利用表实现 |
Lua 支持 for、while、repeat 这三种循环子句。
while 子句结构定义为:
-- 结束条件为:循环条件==false |
for 子句结构定义为:
-- 结束条件为:变量<=循环结束值 |
另外,for 结合 in 关键字可以遍历 table 类型的数据,如下:
local names = {'fhb', 'lw', 'lbf'} |
repeat 子句只有循环条件为 true 时,才退出循环。跟通常使用习惯相反,因此使用较少。其结构定义为:
-- 结束条件为:循环条件==true |
return 和 break 关键字都可以用来退出语句组,但 return 关键字可以用来退出函数和代码块,包括循环语句,而 break 关键字只能退出循环语句。
在 Lua 中由多个操作符和操作数组成一个表达式。
Lua 允许多重赋值。 因此,赋值的语法定义是等号左边是一系列变量, 而等号右边是一系列的表达式。 两边的元素都用逗号间。如果右值比需要的更多,多余的值就被忽略,如果右值的数量不够, 将会被扩展若干个 nil。
-- 变量简单赋值 |
Lua 支持常见的数学运算操作符,见下表:
操作符 | 含义 | 示例 |
---|---|---|
+ - | 加减运算 | 10 - 5 |
* / | 乘除运算 | 10 * 5 |
% | 取模运算 | 10 % 5 |
^ | 求幂运算 | 4^(-0.5) |
- | 取负运算 | -0.5 |
需要指出的是,string 类型进行数学运算操作时,会隐式转化为 number 类型。
return '12' / 6 -- 返回2 |
Lua 中的比较操作符有见下表:
操作符 | 含义 | 示例 |
---|---|---|
== | 等于,为严格判断 | “1” == 1 结果为 false |
~= | 不等于 等价于==操作的反值 | “1”~=1 结果为 true |
< <= | 小于或小于等于 | 1<=2 |
> >= | 大于或大于等于 | 2>=1 |
比较运算的结果一定是 boolean 类型。如果操作数都是数字,那么就直接做数字比较,如果操作数都是字符串,就用字符串比较的方式进行,否则,无法进行比较运算。
Lua 中的逻辑操作符有 and、or 以及 not,一样把 false 和 nil 都作为假, 而其它值都当作真。
操作符 | 含义 | 示例 |
---|---|---|
and | 与 | 10 and 20 |
or | 或 | 10 or 20 |
not | 取非 | not false |
取反操作 not 总是返回 false 或 true 中的一个。 and 和 or 都遵循短路规则,也就是说 and 操作符在第一个操作数为 false 或 nil 时,返回这第一个操作数, 否则,and 返回第二个参数; or 操作符在第一个操作数不为 nil 和 false 时,返回这第一个操作数,否则返回第二个操作数。
10 and 20 --> 20 |
Lua 中还有两种特别的操作符,分别为字符串连接操作符(..)和取长度操作符(#)。
特别说明:
'1' .. 2 --> '12' |
Lua 中操作符的优先级见下表,从低到高优先级顺序:
运算符优先级通常是这样,但是可以用括号来改变运算次序。
在 Lua 中,函数是和字符串、数值和表并列的基本数据结构, 属于第一类对象( first-class-object),可以和数值等其他类型一样赋给变量以及作为参数传递,同样可以作为返回值接收(闭包)。
函数在 Lua 中定义也很简单,基本结构为:
-- arg为参数列表 |
可以用 local 关键字来修饰函数,表示局部函数。
local function foo(n) |
在 Lua 中有一个概念,函数与所有类型值一样都是匿名的,即它们都没有名称。当讨论一个函数名时,实际上是在讨论一个持有某函数的变量:
function f(x) return -x end |
Lua 中函数实参有两种传递方式,但大部分情况会进行值传递。
当实参值为非 table 类型时,会采用值传递。几个传参规则如下:
...
表示-- 定义两个函数 |
当函数为变长参数时,函数内使用...
来获取变长参数,Lua 5.0 后...
替换为名 arg 的隐含局部变量。
function f(...) |
当实参为 table 类型时,传递的只是实参的引用而已。
local function f(arg) |
Lua 函数允许返回多个值,中间用逗号隔开。函数返回值接收规则:
function f1() return "a" end |
Lua 中除了我们自定义函数外,已经实现了部分功能函数,见 标准函数库。
Lua 中最特别的数据类型就是表(table),可以用来实现数组、Hash、对象,全局变量也使用表来管理。
-- array |
说明:当表表示数组时,索引从 1 开始。
元表(metatable)中的键名称为事件,值称为元方法,它用来定义原始值在特定操作下的行为。可通过 getmetatable() 来获取任一事件的元方法,同样可以通过 setmetatable() 覆盖任一事件的元方法。Lua 支持的表事件:
元方法 | 事件 |
---|---|
__add(table, value) __sub(table, value) | + 和 - 操作 |
__mul(table, value) __div(table, value) | * 和 / 操作 |
__mod(table, value) __pow(table, value) | % 和 ^ 操作 |
__concat(table, value) | .. 操作 |
__len(table) | # 操作 |
__eq(table, value) __lt(table, value) __le(table, value) | == 、<、<= 操作 |
__index(table, index) __newindex(table, index) | 取和赋值下标操作 |
__call(table, …) | 调用一个值 |
__tostring(table) | 调用 tostring() 时 |
覆盖这些元方法,即可实现重载运算符操作。例如重载 tostring 事件:
local hash = { x = 2, y = 3 } |
Lua 是面向过程语言,使得可以简单易学。轻量级的特性,使得以脚本方式轻易地嵌入别的程序中,例如 PHP、JAVA、Redis、Nginx 等语言或应用。当然,Lua 也可以通过表实现面向对象编程。
相关文章 »
说明:开发和调试环境为本地 Docker 中的 LNMP,IDE 环境为本地 Win10 下的 PhpStorm。这种情况下 Xdebug 属于远程调试模式,IDE 和本地 IP 为 192.168.1.101,Docker 中 LNMP 容器 IP 为 172.17.0.2。
在 Docker 中安装并配置完 Xdebug ,并设置 PhpStorm 中对应的 Debug 参数后,但是 Debug 并不能正常工作。
此时,php.ini
中 Xdebug 配置如下:
xdebug.idekey = phpstorm |
开始收集问题详细表述。首先,观察到 PhpStorm 的 Debug 控制台出现状态:
Waiting for incoming connection with ide key *** |
然后查看 Xdebug 调试日志xdebug.log
,存在如下错误:
I: Checking remote connect back address. |
查看这些问题表述,基本上可以定位为 Xdebug 和 PhpStorm 之间的 网络通信 问题,接下来一步步定位具体问题。
Win 下执行 netstat -ant
命令:
协议 本地地址 外部地址 状态 卸载状态 |
端口 9001 监听正常,然后在容器中使用 telnet 尝试同本地 9001 端口建立 TCP 连接:
telnet 192.168.1.101 9001 |
说明容器同本地 9001 建立 TCP 连接正常,但是 Xdebug 为什么会报连接失败呢?此时,至少可以排除不会是因为 PhpStorm 端配置的问题。
回过头来看看 Xdebug 的错误日志,注意观察到失败时的连接信息:
I: Remote address found, connecting to 172.17.0.1:9001. |
此时,在容器中使用 tcpdump 截获的数据包如下:
tcpdump -nnA port 9001 |
可以确定的是, Xdebug 是向 IP 为 172.17.0.1 且端口为 9001 的目标机器尝试建立 TCP 连接,而非正确的 192.168.1.101 本地 IP。到底发生了什么?
首先,为了搞懂 Xdebug 和 PhpStorm 的交互过程,查了 官方手册 得知,Xdebug 工作在远程调试模式时,有两种工作方式:
1、IDE 所在机器 IP 确定/单人开发
图中,由于 IDE 的 IP 和监听端口都已知,所以 Xdebug 端可以很明确知道 DBGP 交互时 IDE 目标机器信息,所以 Xdebug 只需配置 xdebug.remote_host、xdebug.remote_port 即可。
2、IDE 所在机器 IP 未知/团队开发
由于 IDE 的 IP 未知或者 IDE 存在多个 ,那么 Xdebug 无法提前预知 DBGP 交互时的目标 IP,所以不能直接配置 xdebug.remote_host 项(remote_port 项可以确定),必须设置 xdebug.remote_connect_back 为 On 标识(会忽略 xdebug.remote_host 项)。这时,Xdebug 会优先获取 HTTP_X_FORWARDED_FOR 和 REMOTE_ADDR 中的一个值作为通信时 IDE 端的目标 IP,通过Xdebug.log
记录可以确认。
I: Checking remote connect back address. |
接下来,可以知道 Xdebug 端是工作在远程调试的模式 2 上,Xdebug 会通过 HTTP_X_FORWARDED_FOR 和 REMOTE_ADDR 项获取目标机 IP。Docker 启动容器时已经做了 80 端口映射,忽略宿主机同 Docker 容器复杂的数据包转发规则,先截取容器 80 端口数据包:
tcpdump -nnA port 80 |
可以看出,数据包的源地址为 172.17.0.1,并非真正的源地址 192.168.1.101,HTTP 请求头中也无 HTTP_X_FORWARDED_FOR 项。
说明:172.17.0.1 实际为 Docker 创建的虚拟网桥 docker0 的地址 ,也是所有容器的默认网关。Docker 网络通信方式默认为 Bridge 模式,通信时宿主机会对数据包进行 SNAT 转换,进而源地址变为 docker0,那么,怎么在 Docker 里获取客户端真正 IP 呢?。
最后,可以确定由于 HTTP_X_FORWARDED_FOR 未定义,因此 Xdebug 会取 REMOTE_ADDR 为 IDE 的 IP,同时由于 Docker 特殊的网络转发规则,导致 REMOTE_ADDR 变更为网关 IP,所以 Xdebug 同 PhpStorm 进行 DBGP 交互会失败。
由于 Docker 容器里获取真正客户端 IP 比较复杂,这里使用 Xdebug 的 远程模式 1 明确 IDE 端 IP 来规避源 IP 被修改的情况,最终解决 Xdebug 调试问题。
模式 1 的 Xdebug 主要配置为:
//并没有xdebug.remote_connect_back项 |
重启 php-fpm,使用php --ri xdebug
确定无误,使用 PhpStorm 重新进行调试。
再次在容器中 tcpdump 抓取 9001 端口数据包:
# 连接的源地址已经正确 |
再次使用 PhpStorm 的 REST Client 断点调试 API 时, Debug 控制台如下:
所以,使用 Xdebug 进行远程调试时,需要选择合适的调试模式,在 Docker 下建议使用远程模式 1。
并不是每个 Xdebug 版本都适配 PHP 每个版本,可以直接使用 官方工具,选择合适的 Xdebug 版本。
如上图,在使用 PhpStorm 时进行远程调试时,需要配置本地文件和远端文件的目录映射关系,这样 IDE 才能根据 Xdebug 传递的当前执行文件路径与本地文件做匹配,实现断点调试和单步调试等。
]]>Redis 的管道实质就是命令打包批量执行,多次网络交互减少到单次。使用管道和不使用管道时的交互过程如下:
我们使用 nc 命令来直观感受下 Redis 管道的使用过程:
安装nc命令 |
因此,只要通过管道进行命令打包后,Redis 就可以批量返回命令的执行结果了。
首先,构造示例需要的 Hash 用户数据:
$keyPrex = 'user:hash:u:'; |
然后,查看导入 Redis 中的数据:
127.0.0.1:6379> keys user:hash:u:* |
在某个社交活动中,通过一系列筛选逻辑后取得种子用户 uid,然后用这些 uid 去 Hash 获取用户的信息。这种情况下你会怎么来处理呢?
一般情况下,在数据量较小时,我们会直接使用 HGETALL 命令遍历地获取用户数据。
$start = nowTime(); |
执行所用时间:39ms
因为通过 uid 批量获取用户数据,各个命令并没有依赖关系,所以可以使用 Redis 的管道来优化查询。
$start = nowTime(); |
使用管道后,执行时间显著地减少为:6ms。使用 tcpdump 抓取打包后的命令如下:
10:45:03.029049 IP localhost.58176 > localhost.6379: Flags [P.], seq 2255478840:2255479211, ack 3144685411, win 342, options [nop,nop,TS val 17640474 ecr 17640474], length 371 |
在批量操作(查询和写入)数据时,我们应尽量避免多次跟 Redis 的网络交互。这时,可以使用管道实现,也可以 Redis 内嵌 Lua 脚本实现。需要注意的是:
在批量获取数据时,尽管使用 Redis 的管道性能会显著提升,但是使用管道时 Redis 会缓存之前命令的结果,最后一并输出给终端,因此所打包的命令不宜太多,否则内存使用会很严重。
]]>机机是个好动又好学的孩子,某一天机机到北海公园游玩,肚肚饿了于是打开手机地图,搜索北海公园附近的餐馆,并选了其中一家用餐。饭饱之后机机开始反思了,地图如何根据自己所在位置查询来查询附近餐馆的呢?
苦思冥想了半天,机机想出了个方法:计算所在位置 P 与北京所有餐馆的距离,然后返回距离 <=1000m 的餐馆。但是,机机发现北京的餐馆何其多啊,这样计算不得了,既然知道经纬度了,那它应该知道自己在西城区,那应该计算所在位置 P 与西城区所有餐馆的距离,机机运用了递归的思想。想到了西城区也很多餐馆啊,应该计算所在位置 P 与所在街道所有餐馆的距离,这样计算量又小了,效率也提升了。
机机的计算思想很朴素,就是通过过滤的方法来减小参与计算的餐馆数目,从某种角度上讲,机机在使用索引技术。
一提到索引,大家脑子里马上浮现出 B 树索引,因为大量的数据库(如 MySQL、Oracle、PostgreSQL 等)都在使用 B 树。B 树索引本质上是对索引字段进行排序,然后通过类似二分查找的方法进行快速查找,即它要求索引的字段是可排序的,一般而言可排序的是一维字段,比如时间、年龄、薪水等等。但是对于空间上的一个点(包括经度和纬度),如何排序呢?又如何索引呢?
思想:如果能通过某种方法将二维的点数据转换成一维的数据,那样不就可以继续使用 B 树索引了。目前很火的 GeoHash 算法就是运用了上述思想,下面我们就开始 GeoHash 之旅吧。
首先来点感性认识下 GeoHash,这里 提供了在地图上显示 GeoHash 编码的功能。
比如下图展示了北京 9 个区域的 GeoHash 字符串,分别是 WX4ER,WX4G2、WX4G3 等等,每一个字符串代表了某一矩形区域。也就是说,这个矩形区域内所有的点(经纬度坐标)都共享相同的 GeoHash 字符串,这样既可以保护隐私(只表示大概区域位置而不是具体的点),又比较容易做缓存。
如图所示,5 位的编码能表示 10 平方千米范围的矩形区域,而 6 位编码能表示更精细的区域(约 0.34 平方千米)。
如下两个图所示,一个在城区,一个在郊区,城区的 GeoHash 字符串之间比较相似,郊区的字符串之间也比较相似,而城区和郊区的 GeoHash 字符串相似程度要低些。
通过上面的介绍我们知道了 GeoHash 就是一种将经纬度转换成字符串的方法,并且使得在大部分情况下,字符串前缀匹配越多的距离越近,回到我们的案例,根据所在位置查询来查询附近餐馆时,只需要将所在位置经纬度转换成 GeoHash 字符串,并与各个餐馆的 GeoHash 字符串进行前缀匹配,匹配越多的距离越近。
下面以北海公园为例介绍 GeoHash 算法的计算步骤
地球纬度区间是 [-90,90], 北海公园的纬度是 39.928167,可以通过下面算法对纬度 39.928167 进行 逼近编码:
1)将区间 [-90,90] 进行二分为 [-90,0), [0,90] 的左右区间,可以确定 39.928167 属于右区间 [0,90],给标记为 1;
2)接着将区间 [0,90] 进行二分为 [0,45),[45,90],可以确定 39.928167 属于左区间 [0,45),给标记为 0;
3)递归上述过程 39.928167 总是属于某个区间 [a,b]。随着每次迭代区间 [a,b] 总在缩小,并越来越逼近 39.928167;
可以概括为:如果给定的纬度 x(39.928167)属于左区间,则记录 0,如果属于右区间则记录 1,这样随着算法的进行会产生一个序列 1011100,序列的长度跟给定的区间划分次数有关。
纬度 39.928167 编码过程如下:
bit | min | mid | max |
---|---|---|---|
1 | -90.000 | 0.000 | 90.000 |
0 | 0.000 | 45.000 | 90.000 |
1 | 0.000 | 22.500 | 45.000 |
1 | 22.500 | 33.750 | 45.000 |
1 | 33.7500 | 39.375 | 45.000 |
0 | 39.375 | 42.188 | 45.000 |
0 | 39.375 | 40.7815 | 42.188 |
0 | 39.375 | 40.07825 | 40.7815 |
1 | 39.375 | 39.726625 | 40.07825 |
1 | 39.726625 | 39.9024375 | 40.07825 |
同理,地球经度区间是 [-180,180],可以对经度 116.389550 进行编码,编码过程如下:
bit | min | mid | max |
---|---|---|---|
1 | -180 | 0.000 | 180 |
1 | 0.000 | 90 | 180 |
0 | 90 | 135 | 180 |
1 | 90 | 112.5 | 135 |
0 | 112.5 | 123.75 | 135 |
0 | 112.5 | 118.125 | 123.75 |
1 | 112.5 | 115.3125 | 118.125 |
0 | 115.3125 | 116.71875 | 118.125 |
1 | 115.3125 | 116.015625 | 116.71875 |
1 | 116.015625 | 116.3671875 | 116.71875 |
通过上述计算,纬度产生的编码为 10111 00011,经度产生的编码为 11010 01011。偶数位放经度,奇数位放纬度,把 2 串编码组合生成新串:11100 11101 00100 01111。
最后使用用 0-9、b-z(去掉 a, i, l, o)的 32 个字符进行 Base32 编码,首先将 11100 11101 00100 01111 转成十进制,对应着 28、29、4、15,十进制对应的编码就是 wx4g。同理,将编码转换成经纬度的解码算法与之相反,具体不再赘述。
Base32 编码规则如下:
下表为摘自 维基百科 的 Base32 编码长度与精度,也可以作为经纬度距离粗略换算标准:
可以看出,当 Geohash 使用 base32编码长度为 8 时,精度在 19 米左右,而当编码长度为 9 时,精度在 2 米左右,编码长度需要根据数据情况进行选择。
上文讲了 GeoHash 的计算步骤,仅仅说明是什么而没有说明为什么?为什么分别给经度和维度编码?为什么需要将经纬度两串编码交叉组合成一串编码?本节试图回答这一问题。
如图所示,我们将二进制编码的结果填写到空间中,当将空间划分为四块时候,编码的顺序分别是左下角 00,左上角 01,右下脚 10,右上角 11,也就是类似于 Z 的曲线,当我们递归的将各个块分解成更小的子块时,编码的顺序是自相似的(分形),每一个子快也形成 Z 曲线,这种类型的曲线被称为 Peano 空间填充曲线。
这种类型的空间填充曲线的优点是将二维空间转换成一维曲线(事实上是分形维),对大部分而言,编码相似的距离也相近, 但 Peano 空间填充曲线最大的缺点就是 突变性,有些编码相邻但距离却相差很远,比如 0111 与 1000,编码是相邻的,但距离相差很大。
除 Peano 空间填充曲线外,还有很多空间填充曲线,如图所示,其中效果公认较好是 Hilbert 空间填充曲线,相较于 Peano 曲线而言,Hilbert 曲线没有较大的突变。为什么 GeoHash 不选择 Hilbert 空间填充曲线呢?可能是 Peano 曲线思路以及计算上比较简单吧,事实上,Peano 曲线就是一种四叉树线性编码方式。
解决的思路很简单,我们查询时,除了使用定位点的 GeoHash 编码进行匹配外,还使用周围 8 个区域的 GeoHash 编码,这样可以避免这个问题。
Geohash 只是空间索引的一种方式,特别适合点数据,而对线、面数据采用 R 树索引更有优势。
]]>本文示例的房源数据,见这里,检索同样使用 Elasticsearch 的 DSL 对比 SQL 来说明。
aggs 子句聚合是 Elasticsearch 常规的聚合实现方式。
先理解这两个基本概念:
名称 | 描述 |
---|---|
桶(Buckets) | 满足特定条件的文档的集合 |
指标(Metrics) | 对桶内的文档进行统计计算 |
每个聚合都是 一个或者多个桶和零个或者多个指标 的组合,聚合可能只有一个桶,可能只有一个指标,或者可能两个都有。例如这个 SQL:
SELECT COUNT(field_name) FROM table GROUP BY field_name |
其中COUNT(field_name)
相当于指标,GROUP BY field_name
相当于桶。桶在概念上类似于 SQL 的分组(GROUP BY),而指标则类似于 COUNT() 、 SUM() 、 MAX() 等统计方法。
桶和指标的可用取值列表:
分类 | 操作符 | 描述 |
---|---|---|
桶 | terms | 按精确值划分桶 |
指标 | sum | 桶内对该字段值求总数 |
指标 | min | 桶内对该字段值求最小值 |
指标 | max | 桶内对该字段值求最大值 |
指标 | avg | 桶内对该字段值求平均数 |
指标 | cardinality(基数) | 桶内对该字段不同值的数量(distinct 值) |
Elasticsearch 聚合 DSL 描述如下:
"aggs" : { |
其中,aggs_name 表示聚合结果返回的字段名,operate 表示桶或指标的操作符名,field_name 为需要进行聚合的字段。
-- SQL描述 |
Elasticsearch 聚合为:
{ |
聚合结果如下:
{ |
可见,此时聚合的结果有且只有分组后文档的 数量,只适合做一些分组后文档数的统计。
-- SQL描述 |
使用 cardinality 指标统计:
{ |
上述的简单聚合,虽然可以统计桶内的文档数量,但是没法实现组内的其他指标统计,比如小区内的最低房源价格,这时就可以给桶添加一个 min 指标。
-- SQL描述 |
添加 min 指标后为:
{ |
结果为:
"buckets": [ |
当然桶与桶之间也可以进行嵌套,这样就能满足复杂的聚合场景了。
例如,统计每个商圈的房源价格分布情况:
-- SQL描述 |
桶聚合实现如下:
{ |
聚合结果如下:
{ |
通常情况下,聚合只返回了统计的一些指标,当需要获取聚合后每组的文档信息(小区的名字和坐标等)时,该怎么处理呢?这时,使用 top_hits 子句就可以实现。
例如,获取西二旗每个小区最便宜的房源信息:
{ |
其中,size 为组内返回的文档个数,sort 表示组内文档的排序规则,_source 指定组内文档返回的字段。
聚合后的房源信息:
{ |
从 Elasticsearch 5.0 之后,增加了一个新特性 field collapsing(字段折叠),字段折叠就是特定字段进行合并并去重,然后返回结果集,该功也能实现 agg top_hits 的聚合效果。
例如, 增加文档信息 部分的获取西二旗每个小区最便宜的房源信息,可以实现为:
{ |
检索结果如下:
{ |
Field collapsing 和 agg top_hits 区别:field collapsing 的结果是够精确,同时速度较快,更支持分页功能。
Elasticsearch 同样也支持了空间位置检索,即可以通过地理坐标点进行过滤检索。
由于地理坐标点不能被动态映射自动检测,需要显式声明对应字段类型为 geo-point,如下:
PUT /rooms //索引名 |
当需检索字段类型设置成 geo_point 后,推送的经纬度信息的形式可以是字符串、数组或者对象,如下:
形式 | 符号 | 示例 |
---|---|---|
字符串 | “lat,lon” | “40.060937,116.315943” |
对象 | lat 和 lon | { “lat”:40.060937, “lon”:116.315943 } |
数组 | [lon, lat] | [116.315943, 40.060937] |
特别需要注意数组形式时 lon 与 lat 的前后位置,不然就果断踩坑了。
然后,推送含有经纬度的数据:
POST /rooms/room/ |
Elasticsearch 中支持 4 种地理坐标点过滤器,如下表:
名称 | 描述 |
---|---|
geo_distance | 找出与指定位置在给定距离内的点 |
geo_distance_range | 找出与指定点距离在最小距离和最大距离之间的点 |
geo_bounding_box | 找出落在指定矩形框中的点 |
geo_polygon | 找出落在多边形中的点,将不说明 |
例如,查找西二旗地铁站 4km 的房源信息:
{ |
LBS 检索的结果为:
{ |
本文讲述了使用 Elasticsearch 进行 聚合 和 LBS 检索,尽管文中只是以示例形式进行说明,会存在很多不全面的地方,还是希望对你我学习 Elasticsearch 能有所帮助。
相关文章 »
15 位身份证编码规则为:DDDDDD YYMMDD XXS
各组成部分说明:
部分名 | 描述 |
---|---|
DDDDDD | 6 位地区编码 |
YYMMDD | 出生年月。年份用 2 位表示 |
XXS | 顺序码。 其中 S 为性别识别码,奇数为男,偶数为女 |
例如某个 15 位 ID 为:513701930509101。
18 位身份证较 15 位身份证,出生年月改变为 8 位,并引入了校验位。编码规则为:DDDDDD YYYYMMDD XXX Y
各组成部分说明:
部分名 | 描述 |
---|---|
DDDDDD | 6 位地区编码 |
YYYYMMDD | 出生年月。年份用 4 位表示 |
XXX | 顺序码。奇数为男,偶数为女 |
Y | 校验位。前 17 位值计算而得 |
校验位 Y 取值范围为 [1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2],其采用加权方式校验,校验规则为:p = mod(∑(Ai×Wi), 11)
参数说明:
例如某个 18 位 ID 位:513701199305091010,校验位后续计算得出。
通过分析身份证的 编码规则,我们就可以得出身份证的校验规则,这里使用正则表达式去进行匹配。
15 位身份证DDDDDD YYMMDD XXS
的每部分的正则匹配表达式为:
部分名 | 正则表达式 |
---|---|
DDDDDD | [1-9]\d{5} |
YYMMDD | (\d{2})(0[1-9]|(1[0-2]))(([0-2][1-9])|([1-2]0)|31) |
XXS | \d{3} |
由此可得 15 位身份证证正则匹配表达式为:
'^[1-9]\d{5}\d{2}(0[1-9]|(1[0-2]))(([0-2][1-9])|([1-2]0)|31)\d{3}$' |
PHP 中校验为:
const ID_15_PREG = '/^[1-9]\d{7}(0[1-9]|1[0-2])([0-2][1-9]|[1-2]0|31)\d{3}$/'; |
同理,18 位身份证DDDDDD YYYYMMDD XXX Y
的每部分的正则匹配表达式为:
部分名 | 正则表达式 |
---|---|
DDDDDD | [1-9]\d{5} |
YYYYMMDD | ([1-9]\d{3})(0[1-9]|(1[0-2]))(([0-2][1-9])|([1-2]0)|31) |
XXX | \d{3} |
Y | \d |
由此可得 18 位身份证证正则匹配表达式为:
'^[1-9]\d{5}([1-9]\d{3})((0[1-9]|(1[0-2]))(([0-2][1-9])|([1-2]0)|31)\d{3}\d|[Xx]$' |
根据校验位校验规则,实现 校验位 的编码:
public static function getCheckBit($id) |
所以,PHP 中校验逻辑为:
const ID_18_PREG = '/^[1-9]\d{5}[1-9]\d{3}(0[1-9]|1[0-2])([0-2][1-9]|[1-2]0|31)(\d{4}|\d{3}[Xx])$/'; |
在金融等某些特殊行业,需要将 15 位身份证号码格式化为 18 位。由于 15 位身份证颁发年份都是 19** 年,所以在转化为 18 位时补充出生年份时直接添加 19 即可。
转化步骤:
15 位身份证转化为 18 位的代码如下:
public static function format18($id) |
转化示例结果:
//15位---------------------18位 |
要实现 API 的版本控制,常见的方法就是引入版本号。本文结合 Yii 2 来进行演示。
传递 API 的版本号大致有两种方式:
http://api.map.baidu.com/direction/v2/transit
;Accept: application/json; version=1
;这两种方式都存在不足,第 1 种版本号跟资源不相关,所以违背 Restful 风格,第 2 种接口版本信息又不够直观。Yii 2 中混合了这两种方法实现了主版本号和小版本号,如下:
之所以使用大小版本号是为了更好地分离代码。当小迭代或者 bug 修复时,更新小版本号,大的需求变更或者一次开发周期中迭代次数较多则更新大版本号。
每个版本代码放置于一个独立的目录下,由于项目中每个 API 同时存在多个版本,如果都是独立的多份代码,相邻版本之间逻辑大致相同,所以代码冗余较高,另外存在需要修改多份代码的情况,不易维护。
另一种方式是通过调整代码结构,新版本 继承 上一个版本,通过 重写 来更好地进行功能迭代和升级,同时也能版本兼容。
根据大版本号分离成模块后,项目目录结构如下:
api/ |
在编码时,尽量将每个版本公有逻辑提出到 common 下,只将版本特有逻辑放置于对应版本下。
公有部分,包括公有逻辑,数据源 model 等,放置于 controllers、models 部分。
BaseAction 用户处理小版本路由,后续的 Action 都继承自此。
namespace api\controllers; |
namespace api\models; |
namespace api\models\logics; |
v1 版本为初始版本,大部分逻辑只需继承自公有逻辑common/RoomLogic.php
。
namespace api\modules\v1\controllers; |
namespace api\modules\v1\controllers\room; |
namespace api\modules\v1\models\logics; |
v1 版结果如下:
//请求信息 |
v2 版逻辑对 v1 版进行了扩展,比如返回小区房源的最低价、小区房源总数等。
namespace api\modules\v2\controllers; |
namespace api\modules\v2\controllers\room; |
namespace api\modules\v2\models\logics; |
v2 版结果如下:
//请求信息 |
如果某一天,数据源需要从 solr 切换到 es,那么只需改写共有RoomLogic.php
并保持数据结构不变,老版本数据也就切换为 es 了。
RoomModel 修改:
namespace api\models; |
RoomLogic 部分修改 countRoomByResblock 方法:
namespace api\models\logics; |
v3 版结果如下:
v3: |
老版本 v2 版结果如下;
v2: |
本文叙述的方式,虽然多个版本时代码不会冗余,但是每个版本之间会有较强的依赖关系,并没有做到应用解耦。实际中还需根据业务场景选择合适的版本处理方案,本文仅提供一种实现思路。
]]>转自 伯乐专栏 玻璃猫
本文的灵感来源于京东金融数据部张洪雨同学的项目经历,感谢这位大神的技术分享。
为满足用户标签的统计需求,小灰利用 MySQL 设计了如下的表结构,每一个维度的标签都对应着 MySQL 表的一列:
要想统计所有 90 后的程序员该怎么做呢?
用一条求交集的 SQL 语句即可:
SELECT COUNT(DISTINCT name) AS 用户数 FROM table WHERE age = '90后' AND occupation = '程序员' |
要想统计所有使用苹果手机或者 00 后的用户总合该怎么做?用一条求并集的 SQL 语句即可:
SELECT COUNT(DISTINCT name) AS 用户数 FROM table WHERE phone = '苹果' OR age = '00后' |
两个月之后——
———————————————
1.给定长度是 10 的 bitmap,每一个 bit 位分别对应着从 0 到 9 的 10 个整型数。此时 bitmap 的所有位都是 0。
2.把整型数 4 存入 bitmap,对应存储的位置就是下标为 4 的位置,将此 bit 置为 1。
3.把整型数 2 存入 bitmap,对应存储的位置就是下标为 2 的位置,将此 bit 置为 1。
4.把整型数 1 存入 bitmap,对应存储的位置就是下标为 1 的位置,将此 bit 置为 1。
5.把整型数 3 存入 bitmap,对应存储的位置就是下标为 3 的位置,将此 bit 置为 1。
要问此时 bitmap 里存储了哪些元素?显然是 4,3,2,1,一目了然。
bitmap 不仅方便查询,还可以去除掉重复的整型数。
1.建立用户名和用户 ID 的映射。
2.让每一个标签存储包含此标签的所有用户 ID,每一个标签都是一个独立的 bitmap。
3.这样,实现用户的去重和查询统计,就变得一目了然。
1.如何查找使用苹果手机的程序员用户?
2.如何查找所有男性或者00后的用户?
说明:该项目最初的技术选型并非 MySQL,而是内存数据库 hana。本文为了便于理解,把最初的存储方案写成了 MySQL 数据库。
漫画算法系列 »
]]>想要详细地了解 Solr 查询语法,可参考 官方wiki。
用于示例的数据,我已经推送到了 Solr ,见这里。数据 Core 为 rooms,数据格式形如:
[{ |
通过向 Solr 集群 GET 请求/solr/core-name/select?query
形式的查询 API 完成查询,其中 core-name 为查询的 Core 名称。查询语句 query 由以下基本元素项组成,按使用频率先后排序:
名称 | 描述 | 示例 |
---|---|---|
wt | 响应结果的格式 | json |
fl | 指定结果集的字段 | *(所有字段) |
fq | 过滤查询 | id : 0119df79-68d9-4cd9-ba07 |
start | 指定结果集起始返回的行数,默认 0 | 0 |
rows | 指定结果集返回的行数,默认 10 | 15 |
sort | 结果集的排序规则 | price+asc |
defType | 设置查询解析器名称 | dismax |
timeAllowed | 查询超时时间 |
wt 设置结果集格式,支持 json、xml、csv、php、ruby、pthyon,序列化的结果集,常使用 json 格式。
fl 指定返回的字段,多指使用“空格”和“,”号分割,但只支持设置了stored=true
的字段。*
表示返回全部字段,一般情况不需要返回文档的全部字段。
字段别名:使用displayName:fieldName
形式指定字段的别名,例如:
fl=id,sales_price:price,name |
函数:fl 还支持使用 Solr 内置函数,例如根据单价算总价:
fl=id,total:product(size,price) |
fq 过滤查询条件,可充分利用 cache,所以可以利用 fq 提高检索性能。
sort 指定结果集的排序规则,格式为<fieldName>+<sort>
,支持 asc 和 desc 两种排序规则。例如按照价格倒序排列:
sort=price+desc |
也可以多字段排序,价格和面积排序:
sort=price+asc,size+desc |
查询字符串 q 由以下元素项组成,字段条件形如fieldName:value
格式:
名称 | 描述 | 示例 |
---|---|---|
q | 查询字符串 | *:* |
q.op | 表达式之间的关系操作符 | AND/OR |
df | 查询被索引的字段 | id:0119df79-68d9-4cd9-ba07 |
以上元素项的默认值由solrconfig.xml
配置文件定义。通常查询时设置q=*:*
,然后通过 fq 过滤条件来完成查询,通过缓存提高查询性能。
Solr 的模糊查询使用占位符来描述查询规则,如下:
符号 | 描述 | 示例 |
---|---|---|
? | 匹配单个字符 | te?t 会检索到 test 和 text |
* | 匹配零个或多个字符 | tes* 会检索到 tes、test 等 |
查询小区名称中包含“嘉”的房源信息:
-- SQL表述 |
Solr 的模糊查询为:
fq=resblockName:*嘉* |
单精确值查询是最简单的查询,类似于 SQL 中 = 操作符。查询小区 id 为 1111027377528 的房源信息:
-- SQL表述 |
Solr 中查询为:
fq=resblockId:1111027377528 |
多精确值查询是单精确值查询的扩展,格式为(value1 value2 ...)
,功能类似于 SQL 的 IN 操作符。查询小区 id 为 1111027377528 或者 1111047349969 的房源信息:
-- SQL表述 |
Solr 中查询为:
fq=resblockId:(1111027377528 1111047349969) |
范围查询是查询指定范围的值(数字和时间),格式为[value1 TO value2]
,类似于 SQL 的 BETWEEN 操作符。查询价格在 [2000, 3000] 的房源信息:
-- SQL表述 |
Solr 中范围查询为:
fq=price:[2000 TO 3000] |
几个特殊的范围查询:
条件 | 表达式 | 示例 |
---|---|---|
>= | [value TO *] | price:[2000 TO *] 价格 >=2000 |
<= | [* TO value] | price:[* TO 2000] 价格 <=2000 |
将基本查询结合布尔查询,就可以实现大部分复杂的检索场景。布尔查询支持以下几种布尔操作:
操作逻辑 | 操作符 | 描述 |
---|---|---|
AND | && + | 逻辑与关系 |
OR | 逻辑或关系 | |
NOT | ! - | 逻辑取反关系 |
查询北京市价格区间在 [2000, 3000] 或者上海市价格区间在 [1500, 2000] 的房源信息:
-- SQL表述 |
转换为逻辑与布尔查询:
fq=(cityCode:110000 && price:[2000 TO 3000])||(cityCode:310000 && price:[1500 TO 2000]) |
在实际中分组查询比较常见,当然 Solr 也支持分组查询。分组查询语句由以下基本元素项组成(常用部分):
名称 | 类型 | 描述 |
---|---|---|
group | boolean | 是否进行分组查询 |
group.field | string | 按该字段值进行分组 |
group.limit | integer | 每组元素集大小,默认为 1 |
group.offset | integer | 每组元素起始行数 |
group.sort | string | 组内元素排序规则,asc 和 desc |
查询西二旗内价格最便宜小区的房源信息:
-- SQL表述 |
Group 分组查询为:
q=*:*&fq=bizcircleCode:611100314&group=true&group.field=resblockId&group.limit=1&group.sort=size+desc |
结果为:
"groups": [ |
在大多数情况下,Group 分组已经能满足我们的需求,但是如果待分组字段为多值,Group 分组已经无能为力了,这时使用 Facet 就能轻松解决。
Solr 的 Facet 语句由以下基本元素构成(常用):
名称 | 类型 | 描述 |
---|---|---|
facet | boolean | 是否进行 facet 查询 |
facet.field | string | 按该字段值进行 facet |
facet.limit | integer | 每组元素集大小,默认为 1 |
facet.offset | integer | 每组元素起始行数 |
facet.sort | string | 结果集排序规则,asc 和 desc |
facet.mincount | integer | 每组元素最小数量 |
例如,统计每个商圈的房源分布情况并倒序排列,由于 bizcircleCode 字段为多值,Facet 查询为:
//此时不需要文档信息,故rows=0 |
结果如下:
"facet_fields": { |
Solr 的 geofilt 过滤器可以实现 LBS 检索,但要在schema.xml
配置中将需检索字段的字段类型设置为solr.LatLonType
类型。geofilt 过滤器参数列表如下:
名称 | 描述 | 示例 |
---|---|---|
d | 检索距离,单位 km | 2 |
pt | 检索中心点坐标,格式:lat,lon | 40.074203,116.315445 |
sfield | 检索的索引字段 | location |
示例中的 location 字段,值为 “40.074203,116.315445”,类型配置为:
<fieldType name="location" class="solr.LatLonType" subFieldSuffix="_coordinate"/> |
则检索坐标点40.074203,116.315445
附近 2 公里的房源信息:
q=*:*&fq={!geofilt}&spatial=true&pt=40.074203,116.315445&sfield=location&d=2 |
Solr 提供一些函数以实现逻辑或数学运算。其中常用 数学运算 函数列表如下:
函数名 | 描述 | 示例 |
---|---|---|
abs | 求绝对值 | abs(-5) |
max | 返回最大值 | max(1, 2, 3) |
min | 返回最小值 | min(1, 2, 3) |
pow | 返回指数运算的结果 | pow(2, 2) |
sqrt | 开方运算的结果 | sqrt(100) |
product | 乘积 | product(1, 2, 3) |
sub | 差 | sub(3, 2) |
sum | 和 | sum(1, 2, 3) |
div | 商 | div(4, 2) |
log | 10 的对数 | log(10) |
常用的 逻辑运算 函数:
函数名 | 描述 | 示例 |
---|---|---|
def | 定义字段默认值 | def(price, 0) |
if | if(test,value1,value2) test?value1:value2 | |
exists | 字段是否存在 |
这些函数可以使用在返回值或者查询条件上。例如返回每个房源的每平方米价格信息:
q=*:*&fl=*,avgPrice:div(price, size) |
PHP 可以使用 solarium 客户端,实现 Solr 数据源的检索,详细使用说明 见这里。
solarium 客户端需要配置 Solr 的基本信息。如下:
//config.php |
solarium 提供的查询方法较丰富,整理后如下表所示:
方法 | 所属对象 | 描述 |
---|---|---|
createSelect | client | 创建查询 query 对象 |
select | client | 执行查询,返回 result 对象 |
setQuery | query | 添加 query 条件 |
setStart | query | 设置结果集起始行 |
setRows | query | 设置结果集行数 |
setFields | query | 设置返回的字段 |
addSort | query | 结果集排序规则 |
createFilterQuery | query | 创建 filter query 对象 |
查询北京市的所有房源信息,如下:
$client = new Solarium\Client($solr); |
solarium 提供的分组查询方法如下表所示(常用):
方法 | 所属对象 | 描述 |
---|---|---|
getGrouping | query | 创建分组 group 对象 |
addQuery | group | 添加分组 query |
setSort | group | 设置分组排序规则 |
setLimit | group | 设置分组数量 |
getGrouping | result | 获取分组信息 |
获取西二旗每个小区的房源分布信息,如下:
$client = new Solarium\Client($solr); |
solarium 提供的 Facet 查询方法,如下表(常用):
方法 | 所属对象 | 描述 |
---|---|---|
getFacetSet | query | 创建分组 facet 对象 |
createFacetField | facet | 创建 facet 字段 |
setField | facet | facet 分组字段 |
setLimit | facet | 设置 facet 分组大小 |
获取北京市每个商圈的房源分布信息,如下:
$client = new Solarium\Client($solr); |
到这里,Solr 系列就整理完毕了,未涉及的部分后续接触时再补充。这两天利用休息时间充电,自己在 Solr 方面的技能也算是上了一个台阶了。
相关文章 »
Solr的使用 系列的重点应是 Solr 的检索,如果需要可以直接传送到 Sorl检索 部分。
由于该 Solr 平台只供学习使用,所以直接采用 Docker 方式部署,这样能避免一些复杂的依赖环境导致的问题。
从 Hub 拉取 Solr 官方镜像 到本地,这里只选择 5.5 版本:
docker pull solr:5.5 |
Docker 需要通过挂载宿主机目录的方式持久化数据,先创建供挂载目录(注意目录读写权限):
mkdir -p /home/docker/solr |
启动 容器,挂载数据目录,隐射监听端口:
docker --name solr -p 127.0.0.1:8983:8983 -v /home/docker/solr:/opt/solr/server/solr -d solr:5.5 |
我们往往需要修改容器的一些默认参数(Solr 的配置),需要我们登入容器:
docker exec -it solr /bin/bash |
注:由于容器中 /opt/solr/server/solr 会默认存在一些 Solr 启动的必须配置文件,直接将空目录挂载到该目录,会导致容器启动失败。可以先将目录挂载到 /opt/solr/mydata 目录,启动容器后
cp /opt/solr/server/solr/* /opt/solr/mydata/
,获得这些配置文件后,重新以上述地址挂载启动容器即可。
Solr 容器启动成功后,配置 Web 服务器到 8983 端口,访问后看到 Solr Admin 页面,就表示安装成功了。
向 Solr 里推送数据,需要先建立 Core(核),然后在 Core 上创建或更新 Document(文档)。
Core 默认路径为/opt/solr/server/solr
。有两种方式新建 Core,方式一 是使用命令:
bin/solr create_core -c books |
方式二:在 Admin 面板点击 “Core Admin >> Add Core”,填写 name、instanceDir、dataDir、config、schema(文档的字段类型描述) 信息即可。由于 config 和 schema 配置可由模板生成,所以我偏向于使用命令方式创建。
注:方式一和方式二,其实都是通过
/solr/admin/cores?action=CREATE
这个 API 来完成创建任务。
新建 Core 后,可选中 books 核,点击 “Files”,这里列举出后面需要使用的 2 个配置文件:
pwd |
Document 存放着数据记录,新建 Document 后就可以使用 Solr 检索了。这里需存入 book 的数据格式(例如 json)如下:
{ |
如果没有配置字段映射类型推送数据时,Solr 会自动根据字段值设置字段的映射类型,并保存在core-name/conf/managed-schema
文件,但是有时结果并不是我们想要的,所以配置文档的字段类型描述很有必要。
从上述 book 的数据可得,各个 Document 的字段类型关系:
字段名 | 类型 | 是否只被索引 |
---|---|---|
id | string | √ |
cat | strings | |
name | string | |
author | strings | |
price | tdouble |
字段类型通过文件schema.xml
描述,需放置于 Core 的 conf 目录,文档格式可以参考managed-schema
文件,基本要素大致为:
|
注:如果后续更新 schema.xml 配置后,需要对 Core 进行 Reload 操作,否则检索时字段类型可能未变更。可以点击 “Core Admin >> Reload” 操作。
Solr 支持的数据源类型较多,为 xml、json、csv 等格式。
方式一:使用 post 命令:
post工具 |
books.json
是以 json 格式描述的一些 book,可以批量推送数据。
方式二:在 Admin 面板点击 “books >> Documents”,在 Document(s) 一栏中输入一个 book 信息,并点击 “ Submit Document” 即可。成功右侧会返回:
Status: success |
也可以通过 检索,可以查看数据推送是否成功,检索结果为:
"docs": [ |
注:方式一和二其实是殊途同归,都是 POST 请求
solr/books/update?wt=json
这个 API。
删除 Document 其实也是 update 操作,同样有两种方式。
先使用 xml 格式构建需要删除 Document 的条件描述del-book.xml
,如删除 id 为 978-0641723445 的 book 信息:
<delete> |
方式一:同样使用 post 命令:
bin/post -c books server/solr/data/del-book.xml |
方式二:在 Admin 面板点击 “books >> Documents”,Document Type 项选择 xml,然后在 Document(s) 一栏中输入需要删除 book 的条件描述(del-book.xml 内容),并点击 “ Submit Document” 即可。
重新检索,可以发现 id 为 978-0641723445 的 book 信息已经被成功删除。
注:方式一和二都是 POST 请求
solr/books/update?wt=json
这个 API。
本文仅仅叙述了 Solr 的 Docker 单节点部署和简单的数据推送实现,由于个人能力和时间限制,并未涉及到其生成环境的应用环节。后续的一篇文章将会记录 Solr 的检索语法和 PHP 作为客户端调用 Solr 服务的一种方案。
相关文章 »
Elasticsearch 支持 RESTful API 方式检索,查询结果以 JSON 格式响应,文中示例数据见 这里。有关 Elasticsearch 详细使用说明,见 官方文档。
检索 url 中需包含 索引名,_search
为查询关键字。例如 http://es.fanhaobai.com/rooms/_search 的 rooms 为索引名,此时表示无任何条件检索,检索结果为:
GET /rooms/_search |
注:Elasticsearch 官方偏向于使用 GET 方式(能更好描述信息检索的行为),GET 方式可以携带请求体,但是由于不被广泛支持,所以 Elasticsearch 也支持 POST 请求。后续查询语言使用 POST 方式。
当我们确定了需要检索文档的 url 后,就可以使用查询语法进行检索,Elasticsearch 支持以下 Query string(查询字符串)和 DSL(结构化)2 种检索语句。
我们可以直接在 get 请求时的 url 后追加q=
查询参数,这种方法常被称作 query string 搜索,因为我们像传递 url 参数一样去传递查询语句。例如查询小区 id 为 1111027374551 的房源信息:
GET /rooms/_search?q=resblockId:1111027374551 |
虽然查询字符串便于查询特定的搜索,但是它也有局限性。
DSL 查询以 JSON 请求体的形式出现,它允许构建更加复杂、强大的查询。DSL 方式查询上述 query string 查询条件则为:
POST /rooms/_search |
term 语句为过滤类型之一,后面再进行说明。使用 DSL 语句查询支持 filter(过滤器)、match(全文检索)等复杂检索场景。
Elasticsearch 支持为 2 种检索行为,它们都是使用 DSL 语句来表达检索条件,分别为 query (结构化查询)和 filter(结构化搜索)。
说明:后续将使用 SQL 对比 DSL 语法进行搜索条件示例。
结构化查询支持全文检索,会对检索结果进行相关性计算。使用结构化查询,需要传递 query 参数:
{ "query": your_query } |
注:后续查询中不再列出 query 参数,只列出 your_query(查询内容)。
match_all 查询简单的匹配所有文档。在没有指定查询方式时,它是默认的查询。查询所有房源信息:
-- SQL表述 |
match_all 查询为:
{ "match_all": {}} |
match 查询为全文搜索,类似于 SQL 的 LIKE 查询。查询小区名中包含“嘉”的房源信息:
-- SQL表述 |
match 查询为:
{ "match": { "resblockName": "嘉" }} |
multi_match 查询可以在多个字段上执行相同的 match 查询:
{ |
range 查询能检索出那些落在指定区间内的文档,类似于 SQL 的 BETWEEN 操作。range 查询被允许的操作符有:
操作符 | 操作关系 |
---|---|
gt | 大于 |
gte | 大于等于 |
lt | 小于 |
lte | 小于等于 |
查询价格在 (2000, 2500] 的房源信息:
-- SQL表述 |
range 查询为:
{ |
term 查询用于精确值匹配,可能是数字、时间、布尔。例如查询房屋 id 为 1087599828743 的房源信息:
-- SQL表述 |
term 查询为:
{ "term": { "houseId": 1087599828743 }} |
terms 查询同 term 查询,但它允许指定多值进行匹配,类似于 SQL 的 IN 操作。例如查询房屋 id 为 1087599828743 或者 1087817932342 的房源信息:
-- SQL表述 |
terms 查询为:
{ "terms": { "houseId": [ 1087599828743, 1087817932342 ] }} |
term 查询和 terms 查询都不分析输入的文本, 不会进行相关性计算。
exists 查询和 missing 查询被用于查找那些指定字段中有值和无值的文档,类似于 SQL 中的 IS NOT NULL 和 IS NULL 查询。查询价格有效的房源信息:
-- SQL表述 |
exists 查询为:
{ "exists": { "field": "price" }} |
我们时常需要将多个条件的结构进行逻辑与和或操作,等同于 SQL 的 AND 和 OR,这时就应该使用 bool 子句合并多子句结果。 共有 3 种 bool 查询,分别为 must(AND)、must_not(NOT)、should(OR)。
操作符 | 描述 |
---|---|
must | AND 关系,必须 匹配这些条件才能检索出来 |
must_not | NOT 关系,必须不 匹配这些条件才能检索出来 |
should | OR 关系,至少匹配一条 条件才能检索出来 |
filter | 必须 匹配,不参与评分 |
查询小区中包含“嘉”字或者房屋 id 为 1087599828743 的房源信息:
-- SQL表述 |
bool 查询为:
{ |
使用 filter 语句来使得其子句不参与评分过程,减少评分可以有效地优化性能。重写前面的例子:
{ |
bool 查询可以相互的进行嵌套,已完成非常复杂的查询条件。
constant_score 查询将一个不变的常量评分应用于所有匹配的文档。它被经常用于你只需要执行一个 filter(过滤器)而没有其它查询(评分查询)的情况下。
{ |
结构化搜索的查询适合确定值数据(数字、日期、时间),这些类型数据都有明确的格式。结构化搜索结果始终是是或非,结构化搜索不关心文档的相关性或分数,它只是简单的包含或排除文档,由于结构化搜索使用到过滤器,在查询时需要传递 filter 参数,由于 DSL 语法查询必须以 query 开始,所以 filter 需要放置在 query 里,因此结构化查询的结构为:
{ |
注:后续搜索中不再列出 query 参数,只列出 your_filters(过滤内容)。
结构化搜索一样存在很多过滤器 term、terms、range、exists、missing、bool,我们在结构化查询中都已经接触过了。
最为常用的 term 搜索用于查询精确值,可以用它处理数字(number)、布尔值(boolean)、日期(date)以及文本(text)。查询小区 id 为 1111027377528 的房源信息:
-- SQL表述 |
term 搜索为:
{ "term": { "resblockId": "1111027377528" }} |
类似XHDK-A-1293-#fJ3
这样的文本直接使用 term 查询时,可能无法获取到期望的结果。是因为 Elasticsearch 在建立索引时,会将该数据分析成 xhdk、a、1293、#fj3 字样,这并不是我们期望的,可以通过指定 not_analyzed 告诉 Elasticsearch 在建立索引时无需分析该字段值。
terms 搜索使用方式和 term 基本一致,而 terms 是搜索字段多值的情况。查询商圈 code 为 18335711 或者 611100314 的房源信息:
-- SQL表述 |
terms搜索为:
{ "terms": { "bizcircleCode": [ "18335711", "611100314" ] }} |
在进行范围过滤查询时使用 range 搜索,支持数字、字母、日期的范围查询。查询面积在 [15, 25] 平米之间的房源信息:
-- SQL表述 |
range 搜索为:
{ |
range 搜索使用在日期上:
{ |
exists 和 missing 搜索是针对某些字段值存在和缺失的查询。查询房屋面积存在的房源列表:
-- SQL表述 |
exists 搜索为:
{"exists": { "field": "size" }} |
missing 搜索刚好和 exists 搜索相反,但语法一致。
bool 过滤器是为了解决过滤多个值或字段的问题,它可以接受多个其他过滤器作为子过滤器,并将这些过滤器结合成各式各样的逻辑组合。
bool 过滤器的组成部分,同 bool 查询一致:
{ |
类似于如下 SQL 查询条件:
SELECT * FROM rooms WHERE (bizcircleCode = 18335711 AND price BETWEEN 2000 AND 2500) OR (bizcircleCode = 611100314 AND price >= 2500) |
使用 bool 过滤器实现为:
{ |
区别:结构化查询会进行相关性计算,因此不会缓存检索结果;而结构化搜索会缓存搜索结果,因此具有较高的检索效率,在不需要全文搜索或者其它任何需要影响相关性得分的查询中建议只使用结构化搜索。当然,结构化查询和结构化搜索可以配合使用。
该部分较复杂,已单独使用文章进行说明,见 Elasticsearch检索 — 聚合和LBS 部分。
某些时候可能不需要返回文档的全部字段,这时就可以使用 _source 子句指定返回需要的字段。只返回需要的房源信息字段:
{ |
排序是使用比较多的推荐方式,在 Elasticsearch 中,默认会按照相关性进行排序,相关性得分由一个浮点数进行表示,并在搜索结果中通过_score
参数返回(未参与相关性评分时分数为 1), 默认是按_score
降序排序。
sort 方式有 desc、asc 两种。将房源查询结果按照价格升序排列:
{ |
当存在多级排序的场景时,结果首先按第一个条件排序,仅当结果集的第一个 sort 值完全相同时才会按照第二个条件进行排序,以此类推。
{ |
当字段值为 多值 及 字段多指排序,Elasticsearch 会对于数字或日期类型将多值字段转为单值。转化有 min 、max 、avg、 sum 这 4 种模式。
例如,将房源查询结果按照商圈 code 升序排列:
{ |
和 SQL 使用 LIMIT 关键字返回单 page 结果的方法相同,Elasticsearch 接受 from(初始结果数量)和 size(应该返回结果数量) 参数:
{ |
在实际应用中,查询可能变得非常的复杂,理解起来就有点困难了。不过可以使用validate-query
API来验证查询合法性。
GET /room/_validate/query |
合法的 query 返回信息:
{ |
别的业务线已经投入 Elasticsearch 使用有段时间了,找房业务线正由 Solr 切换为 Elasticsearch,各个系统有一个探索和磨合的过程。当然,Elasticsearch 我们已经服务化了,对 DSL 语法也进行了一些简化,同时支持了定制化业务。另外,使用 elasticsearch-sql 插件可以让 Elasticsearch 也支持 SQL 操作。
相关文章 »
从 Charles 的 官方网站 下载最新的安装包,下载晚完成安装即可。
Charles 是付费软件,当然免费状态也可以使用。可以使用如下信息完成注册:
Registered Name: https://zhile.io |
如果注册失败,可以尝试 这种方法。
由于 Charles 是通过将自己设置成代理服务器来完成封包截取的,所以第一步是需要将 Charles 设置成系统的代理服务器。
启动 Charles 后,菜单中的 “Proxy” -> “Windos Proxy(或者Mac OS X Proxy)”, 来将 Charles 设置成系统代理。如下所示:
配置后,就可以在界面中看到截取的网络请求。但是,Chrome 和 Firefox 浏览器默认并不使用系统的代理服务器设置, 所以需要将 Chrome 和 Firefox 设置成使用系统的代理服务器,或者直接设置成地址127.0.0.1:8888
。
如果 Chrome 已安装了 Host Switch Plus 插件,则需要暂时关闭。
一般情况下,我们只需要监听指定服务器上发送的请求,可以使用如下办法解决:
方式1:在主界面 “Sequence” -> “Filter” 栏位置输入需要过滤的关键字即可。例如输入fanhaobai
,则过滤输出只包含 fanhaobai 信息的请求。
方式2:在 Charles 的菜单栏选择 “Proxy” -> ”Recording Settings”,并选择 Include 栏,添加一条永久过滤规则,主要填入需要截取网站的协议、主机地址、端口号。
方式3:右击需要过滤的网络请求,选择 “Focus” 选项即可。
方式 1 和方式 3 可以快速地过滤临时性网络请求,使用方式 2 过滤永久性网络请求。
Charles 除了可以截取本地的网络包,作为代理服务器后,同样可以截取移动设备的网络请求包。
截取移动设备网络包时,需要先将 Charles 的代理功能打开。在 Charles 的菜单栏上选择 “Proxy” -> ”Proxy Settings”,填入默认代理端口 8888,且勾选 “Enable transparent HTTP proxying” 就完成了设置。如下图所示:
首先,通过 Charles 的顶部菜单的 “Help” -> ”Local IP Address” 获取本地电脑的 IP 地址,例如我的本机电脑为192.168.1.102
。
在 iPhone 的 ”设置“ -> ”无线局域网“ 中,对当前局域网连接设置 HTTP 代理(端口默认为 8888),如下图:
设置完成后,打开 iPhone 的任意程序,在 Charles 就可以弹出连接确认窗口,点击 ”Allow” 即可。
在 Android 上操作同 iPhone,只是某些系统设置方式不一致而已。
如果需要截取并分析 Https 协议信息,需要安装 Charles 的 CA 证书。
点击 Charles 的顶部菜单,选择 “Help” -> “SSL Proxying” -> “Install Charles Root Certificate”,即可完成证书的安装。如下图所示:
建议将证书安装在 ”受信任的根证书颁发机构“ 存储区。
特别说明,即使安装完证书后,Charles 默认是不会截取 Https 网络通讯的信息。对于需要截取分析站点 Https 请求,可以右击请求记录,选择 SSL proxy 即可,如图所示:
如果在 iPhone 或 Android 机器上截取 Https 协议的通讯内容,需要手机上安装相应的证书。点击 Charles 的顶部菜单,选择 “Help” -> “SSL Proxying” -> “Install Charles Root Certificate on a Mobile Device or Remote Browser”,然后按照 Charles 的提示的安装教程安装即可。如下图所示:
在上述 截取移动设备网络包 为手机设置好代理后,手机浏览器中访问地址http://chls.pro/ssl
,即可打开证书安装的界面。安装完证书后,就可以截取手机上的 Https 通讯内容了。注意,同样需要在要截取的网络请求上右击,选择 SSL proxy 菜单项。
如果 SSL proxy 后出现如下错误:
可将证书设置为信任即可,例如 iPhone 下 “设置” -> “通用” -> “关于本机” -> “证书信任设置” 下:
在做 App 开发调试时,经常需要模拟慢请求或者高延迟网络,以测试应用在网络异常情况变现是否正常,而这使用 Charles 就轻松帮我们完成。
在 Charles 的菜单上,选择 “Proxy” -> ”Throttle Setting” 项,在弹出的窗口中,可以勾选上 “Enable Throttling”,并且可以设置 Throttle Preset 的类型。如下图所示:
当然可以通过 “Only for selected hosts” 项,只模拟指定站点的慢请求。
有时为了调试服务端的接口,我们需要反复尝试不同参数的网络请求。Charles 可以方便地提供网络请求的修改和重发功能。只需在该网络请求上点击右键,选择 “Compose”,即可创建一个可编辑的网络请求。
我们可以修改该请求的任何信息,包括 URL 地址、端口、参数等,之后点击 “Execute” 即可发送该修改后的网络请求。Charles 支持我们多次修改和发送该请求,这对于我们和服务器端调试接口非常方便,如下图所示:
有候为方便我们调试一些特殊情况,需要服务器返回一些特定的响应内容。例如数据为空或者数据异常的情况,部分耗时的网络请求超时的情况等。通常让服务端配合,构造相应的数据显得会比较麻烦,这个时候,使用 Charles 就可以满足我们的需求。
根据不同的场景需求,Charles 提供了 Map 功能、 Rewrite 功能以及 Breakpoints 功能,都可以达到修改服务器返回内容的目的。这三者在功能上的差异是:
Charles 的 Map 功能分 Map Remote 和 Map Local 两种。Map Remote 是将指定的网络请求重定向到另一个网址请求地址,而 Map Local 是将指定的网络请求重定向到本地文件。在 Charles 的菜单中,选择 “Tools” -> ”Map Remote” 或 “Map Local” ,即可进入到相应功能的设置页面。
对于 Map Remote 功能(选中 Enable Map Remote),我们需要填写网络重定向的源地址和目的地址,对于其他非必需字段可以留空。下图是一个示例,我将测试环境t.fanhaobai.com
的请求重定向到了生产环境www.fanhaobai.com
。
对于 Map Local 功能(选中 Enable Map Local),我们需要填写的重定向的源地址和本地的目标文件。对于有一些复杂的网络请求结果,我们可以先使用 Charles 提供的 “Save Response…” 功能,将请求结果保存到本地并稍加修改,成为我们的目标映射文件。
Rewrite 功能功能适合对某一类网络请求进行一些正则替换,以达到修改结果的目的。
例如,将服务端返回的www.fanhaobai.com
全部替换为www.baidu.com
,如下:
将响应中的www.fanhaobai.com
全部替换为www.baidu.com
。于是在 “Tools” -> “Rewrite” 下配置如下的规则:
选中 “Enable Rewrite” 启用 Rewrite 功能 ,响应如下:
上面提供的 Rewrite 功能最适合做批量和长期的替换,但是很多时候,我们只是想临时修改一次网络请求结果,这个时候,我们最好使用 Breakpoints 功能。
在需要打断点的请求上右击并选择 “Breakpoints”,重新请求该地址,可以发现客户端被挂起,Charles 操作界面如下:
此时可以修改请求信息,但这里只修改响应信息,故点击 “Execute” 后选择 “Edit Response” 项,修改 title 为fanhaobai.com
,如下:
继续点击 “Execute” ,可看见响应的 title 已经变为fanhaobai.com
。
我们可以使用 Charles 的 Repeat 功能来简单地测试服务器的并发处理能力。在想压测的网络请求上右击,然后选择 “Repeat Advanced” 项,如下所示:
这样我们就可以在上图的对话框中,选择压测的并发线程数以及压测次数,确定之后,即可开始压力测试了。
Charles 的反向代理功能允许我们将本地的端口映射到远程的另一个端口上。
]]>原文:链家产品技术团队
PHP 代码中 Foreach 结构随处可见,我们在使用时,是否了解其行为呢?我们这篇文章通过一些例子来分析下 Foreach 结构的内存行为。如果你想了解 PHP 内存相关的内容,不妨把这篇文章作为一个参考。
我们在写代码时经常会有这样的场景:遍历数组,对每个元素进行操作。一般这样的代码有两种写法:
$arr = ['a','b','c','d']; |
对此,老司机们建议我们采用 非引用方式,主要原因是变量的 作用域。下面我们来看一个具体案例。
老司机们建议我们:在使用引用方式去遍历数组时,最好遍历结束后显式地 unset 掉该引用。原因是变量的作用域是整个函数,如果不 unset 掉该引用,在这个函数内其他地方操作这个引用时会引起冲突。下面我们来看这段代码:
$arr = ['a','b','c','d']; |
结果为:
string(2)"aa" |
第一次的遍历打印了aa,bb,cc,dd
比较容易理解,但是第一次遍历完成后$item
保留下来了,而且指向$arr
的最后一个元素。这样在第二次循环中,实际的行为是将$arr
的每个元素依次赋值给了$item
。具体的行为是:
第一次:’aa’ => $item $arr = ['aa', 'bb', 'cc', 'aa'] |
最后$item
指向的值是 ‘cc’,用 xdebug_debug_zval()方法可以看到每个元素的引用情况,大家可以自行验证。
变量作用域相对比较容易理解,因为如果操作不当,我们容易从代码行为看到问题。除了变量作用域,我们还可以从内存行为去分析二者的差异。在开始行为分析之前,我们需要了解 Array 的内存结构。
注:以下代码都是基于(PHP5.5.38,64 位 centos 系统)。
我们从最简单的问题开始,创建长度为 1M 的长整数 Array,占用的内存是多少呢?我们首先想到的是长整型的长度是 8 字节,那么 1M 个长整型数字当然是 8MB。然而,在 PHP 中却不是 8MB。先看代码:
$mem_start = memory_get_usage(); |
结果是:float(144.00043487549)
注:这里计算的是 Array 实际占用内存,不包含已分配但是没有被占用的内存,详情参考 memory_get_usage() 的文档。
为什么是 144MB 而不是 8MB 呢?我们要从 Array 的结构入手开始分析。
PHP 的 Array 是基于哈希表实现的,那么哈希表长什么样呢?先看下面这张图(参考 zend_hash.h)
关于哈希表的定义,请参 zend_hash.h (55-84 行),对于哈希表,我们需要记住以下几点:
在 PHP 中,每个 Array 其实就是一个哈希表!
typedefstruct bucket { |
关于 Bucket,我们需要理解以下内容:
PHP 中存储值的最基本元素就是 zval。在看 zval 之前,我们先看 zvalue。zvalue 定义如下(参考 zend.h: 321-330 行):
typedefunion _zvalue_value { |
zvalue->lval
拿到的是 long 类型,zvalue->ht
拿到的是指向哈希表的指针。max(long, double, struct, pointer, zend_object_value) = max(8, 8, 12,8, 12)
,加上对齐,实际占用 16byte。(注:zend_object_value 长度是 12byte)我们再看 zval 的定义(zend.h: 332-338 行)。
struct _zval_struct { |
我们看到除了 zvalue,zval 中还包含了 GC(Garbage Collection) 的内容:比如说被引用次数 refcount_gc,是否被引用 is_ref_gc,所以总的大小是:16+4+1+1=22byte,对齐之后是 24byte。
PHP5.3 之后,对于循环引用引入了新的垃圾回收机制。这里先不介绍 GC 的细节(参考 GC),只是要说明引入 GC 增加了实际存储的空间。(参考 zend_gc.h: 91-97 行)
typedefstruct _zval_gc_info { |
还是老套路,union 的实际大小是max(pointer, pointer) = max(8, 8) = 8 byte
,所以包装好的 zval_gc_info 实际是 32byte。
这还不够。
C/C++ 是自己管理内存的。为了让用户不直接管理内存,PHP 在内核中加入了 MM (Memory Management) 模块。具体来讲就是为每个经 MM 分配的内容增加了一个 zend_mm_block。关于内存分配,这里先略过。我们先来看 zend_mm_block 的结构(参考 zend_alloc.c: 336-342, 366-377 行)。
typedefstruct _zend_mm_block_info { |
这个结构的大小受很多编译参数的影响,最小是 zend_mm_block_info,也就是两个 size_t 的长度,共 16byte。其他的编译参数在我的测试机上面没有开启,这里也暂不讨论。
所以,综合以上的分析,我们可以画出 Array 每个元素的结构:
zval | 24 bytes |
通过上面的分析,我们可以看到在 64 位操作系统中,Array 的每个元素实际上是要占用 144 字节的,所以在文章最开始问题解决了:1M 的 Array 实际占用了 144MB。
那么,第二个问题来了,如果将这 1M 的 Array 每个元素存储两次,那么消耗的空间会是 288M 么?看代码:
$count = 0; |
结果是:float(240.00015258789)
奇怪的是内存并不是 288M,而是 240M。根据我们对 PHP 的理解,对于相同的 zval,PHP 进行了复用,复用的结果仅仅是对该 zval 的 ref_count 加 1。用 xdebug_debug_zval() 分析,我们看到:
arr: (refcount=1, is_ref=0)=array (0 => (refcount=2,is_ref=0)=0, ...) |
对于每个 zval,refcount=2。arr 作为一个单独的 zval,refcount=1。所以在这个例子中,Array 的结构被复制了 2 份,zval 没有发生复制,所以占用的内存是 96M+144M=240M。
咦,好像还漏了一个问题,当 Array 的元素增长时,我们不是说过哈希表的长度是指数增长的么?我们再看一个例子:
$count = 0; |
结果如下:
1 int(280) 9 int(1464) 17 int(2680) 25 int(3768) |
第一行和第二行我们可以忽略,因为最开始有些初始化的内容,我们不做讨论。我们重点关注 3->7,8->9,10->15,16->17,18->32。我们看到 3->7,10->15,18->32 中间的数值是等差数列,差值是 136byte。8->9 的差别是 200 = 136 + 88,16->17 的差别是 264 = 136 + 816。我们知道,哈希表的默认长度是 8。当长度从 8增长到 9 时,长度变为 16,从 16 增长到 17 时,长度变为 32。然而在这个过程中并没有为每个元素都申请 96 字节的 bucket,而是将哈希表的 arBuckets 增加两倍,因为 arBuckets 里面存放的是指向 bucket 的指针(8byte),所以每次 Array 增长时实际增加的大小是 8byte 增长的长度。136byte = 72+16+48。
回过头来,foreach 过程中的内存行为是什么样子的呢?
我们分两种情况来讨论内存使用:1,只读;2,读写。我们来看个例子:
$arr = range(0,(1<<5) - 1); |
结果是两段代码输出是一样的,迭代过程中消耗的内存都是常量,说明迭代过程中的内存开销仅仅是迭代类和变量的开销。
当有写的情况是什么样子呢?再看个例子:
$arr = range(0,(1<<4) - 1); |
code2同上面只读,内存增加仍旧是常量。
code1内存增长如下:
1 int(384) 5 int(2296) 9 int(2488) 13 int(2680) |
第一行是增加了迭代类和变量,可以理解。关键是第二行,我们看到突然增加到 2152 个字节,这个内存增加比较大。我可以假定这个地方复制了 Array 的结构。
我们再看后面的行数基本上每行都增加 48 字节,好熟悉有没有,分明是后面每次改变 Array 的值的时候增加了一个 zval 的大小。所以我们是否可以推测第二行增加是因为复制了 Array 的结构部分,也就是所有的 Bucket。
这个例子我们看不出太大的规律,但是将 Array 增长为 1M 或者更大时,我们可以看到这个地方的内存增加确实是拷贝了所有的 Bucket。值得注意的是最后一行,当迭代结束后,我们看到内存使用变得很小,说明迭代结束后没用的内存被释放掉了,也就是说原来 Array 的 Buckets 和 zval 全都被释放,因为已经没有地方引用它们了。
上面两个例子是我们最常见的例子,我们看一些复杂的例子,还是只读:
$arr = range(0,(1<<4) - 1); |
我们重点对比两段代码的结果,我们发现下面几个不同:
还不够,我们再看个例子:
$arr = range(0,(1<<4) - 1); |
当 refcount>1 时,迭代过程中修改被迭代的数组,当使用引用方式访问时,首先复制了 Bucket,然后逐个增加 zval 的值。当使用值方式访问时,我们看到进入循环时 Bucket 发生复制,然后当第一次发生写操作时,Bucket 又发生,写操作完成后,内存释放,最终两种方式内存增加一样。
综合上面两个例子,我们可以得出结论:
当 refcount>1, is_ref = 0 时,用值引用来迭代 Array,如果只读,那么只会拷贝 Array 的 Bucket 部分,且迭代完成后复制的内存会释放,$arr
和$arr2
还是引用相同的 zval(这是合理的,当 refcount>1 时,你要是迭代 Array,但是不能改变另外 Array 的结构,所以只能复制 Bucket);如果有写操作,那么在进入循环时会拷贝 Bucket一份,然后当写操作发生后,又会复制 Bucket,然后对每个写操作都会增加相应的 zval 的内存开销,迭代完成后$arr
和$arr2
是不同的 Array。
用引用在遍历对象时,无论读写,都会首先复制 Array 的 Bucket 部分,然后在迭代过程中再逐渐增加 zval 的开销,迭代完成后$arr
和$arr2
已经是完全不同的 Array。
最后我们再来讨论一个 is_ref = 1 的情况:
$arr = range(0,(1<<4) - 1); |
这种情况下四个 case 结果都是一样的,因为$arr
和$arr2
本质上就是同一个 Array,所以当 is_ref=1 的时候在以何种方式访问或者修改 Array 都是不会增加内存开销的。
综上:在 refcount>1,is_ref=0 的时候,无论以何种方式进行 foreach 操作,都会对 Array 的结构发生拷贝(Bucket)。如果采用引用的方式去迭代 Array,那么每次迭代都会增加一个 zval 的内存空间。
我们还是用表格来描述所有的情况吧:
只读&值访问 | 读写&值访问 | 只读&引用访问 | 读写&引用访问 | |
---|---|---|---|---|
ref_count=1,is_ref=0 | 不拷贝Bucket 无增量zval | 拷贝Bucket 有增量zval 最终拷贝内存会释放 | 不拷贝Bucket 无增量zval | 不拷贝Bucket 无增量zval |
ref_count>1,is_ref=0 | 拷贝Bucket 无增量zval 最终拷贝内存会释放 | 拷贝Bucket(共两份) 有增量zval 最终多余拷贝内存会释放 | 拷贝Bucket 有增量zval 最终内存不会释放 | 拷贝Bucket 有增量zval 最终内存不会释放 |
is_ref=1 | 不拷贝Bucket 无增量zval | 不拷贝Bucket 无增量zval | 不拷贝Bucket 无增量zval | 不拷贝Bucket 无增量zval |
所以,基于内存方面的考虑,在写代码的时候,如果迭代数组时是只读操作,我们建议是使用 值引用 来访问元素,因为当 Array 被引用多次时,读操作最终不会增加内存消耗。当对数组有修改操作时,建议使用 引用 的方式去访问数组,因为发生写操作时无额外内存开销。但是!!用完一定要记着 unset!
]]>原文:http://mp.weixin.qq.com/s/raIWLUM1kdbYvz0lTWPTvw
————————————
————————————
二叉查找树的结构:
第 1 次磁盘 IO:
第 2 次磁盘 IO:
第 3 次磁盘 IO:
第 4 次磁盘 IO:
下面来具体介绍一下 B- 树(Balance Tree),一个 m 阶的 B 树具有如下几个 特征 :
1.根结点至少有两个子女。
2.每个中间节点都包含 k-1 个元素和 k 个孩子,其中 m/2 <= k <= m。
3.每一个叶子节点都包含 k-1 个元素,其中 m/2 <= k <= m。
4.所有的叶子结点都位于同一层。
5.每个节点中的元素从小到大排列,节点当中 k-1 个元素正好是 k 个孩子包含的元素的值域分划。
第 1 次磁盘 IO:
在内存中定位(和 9 比较):
第 2 次磁盘 IO:
在内存中定位(和 2,6 比较):
第 3 次磁盘 IO:
在内存中定位(和 3,5 比较):
自顶向下查找 4 的节点位置,发现 4 应当插入到节点元素 3,5 之间。
节点 3,5 已经是两元素节点,无法再增加。父亲节点 2, 6 也是两元素节点,也无法再增加。根节点 9 是单元素节点,可以升级为两元素节点。于是 拆分 节点 3,5 与节点 2,6,让根节点 9 升级为两元素节点 4,9。节点 6 独立为根节点的第二个孩子。
自顶向下查找元素 11 的节点位置。
删除 11 后,节点 12 只有一个孩子,不符合 B 树规范。因此找出 12,13,15 三个节点的中位数 13,取代节点 12,而节点 12 自身下移成为第一个孩子。(这个过程称为 左旋)
漫画算法系列 »
]]>整个抽奖过程是同步进行,由于前置了开启抽奖资格保护,会避免用户集中进行抽奖,故系统并发量并不会太高。突出的问题主要有以下几个:
1)由于同步调用第三方接口发放奖品,奖品可能发放失败;
2)有一些奖品存在数量限制,可能已经发放完;
3)系统要求用户 100% 抽中奖品;
4)系统要求各个奖品总的发放情况符合预期的比例分布;
针对以上突出问题,给出针对的解决办法。
核心思想是采用随机函数 mt_rand() 来模拟用户抽奖。
奖品信息如下:
//所有奖品信息 |
方式一
这是一个比较中规中矩的方式,主要思想 是:将所有奖品按照期望比例分布,一段一段小区间分布到 1100 这个区间,然后随机一个 1100 的随机数,如果这个随机数落在某段区间,则表示抽取对应区间的奖品。
1 30 10 60 |
代码如下:
/** |
方式二
该方式如果直接看代码比较难理解。主要思想:按照给定顺序(按照奖品配置顺序),先后一个一个抽取奖品,直到抽中一个奖品为止, 抽中后续奖品的概率的前提是没有抽中当前奖品,多次抽取概率应该相乘。
例如:
次数 奖品 概率 基数 中奖概率 未中奖概率 |
/** |
主要包含重试机制、自动重新一轮按照概率抽奖机制、兜底机制的实现。
/** |
抽样方法
public function sample($all, $times) |
抽样结果
//期望概率 |
尝试按照概率抽取次数: 3 |
原文:阮一峰老师的 常用 Git 命令清单
我每天使用 Git ,但是很多命令记不住。一般来说,日常使用只要记住下图 6 个命令,就可以了。但是熟练使用,恐怕要记住 60~100 个命令。
下面是我整理的常用 Git 命令清单。几个专用名词的译名如下。
- Workspace:工作区 |
在当前目录新建一个Git代码库 |
Git 的设置文件为.gitconfig
,它可以在用户主目录下(全局配置),也可以在项目目录下(项目配置)。
显示当前的Git配置 |
添加指定文件到暂存区 |
提交暂存区到仓库区 |
来自阮一峰老师的 《Commit message 和 Change log 编写指南》
每次提交,Commit 的 message 都包括三个部分:Header,Body 和 Footer。
<type>(<scope>): <subject> |
其中,Header 是必需的,Body 和 Footer 可以省略。
Header 部分只有一行,包括三个字段:type(必需)、scope(可选)和 subject(必需)。
type 用于说明 commit 的类别,只允许使用下面 7 个标识。
如果 type 为 feat 和 fix,则该 commit 将肯定出现在 Change log 之中。其他情况(docs、chore、style、refactor、test)由你决定,要不要放入 Change log,建议是不要。
scope 用于说明 commit 影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。
subject 是 commit 目的的简短描述,不超过 50 个字符。
列出所有本地分支 |
列出所有tag |
显示有变更的文件 |
下载远程仓库的所有变动 |
恢复暂存区的指定文件到工作区 |
生成一个可供发布的压缩包 |
本文废话较多,可以直接跳转到 编码实现 部分。
这是我遇到的一道笔试题。首次遇见我也是很懵,当时我的第一感觉就是排序,但是没有及时理清里面的规律,导致后面并没有解答出此题。
该问题描述很简单,也给出了测试用例,需求很明白。但是还需要注意问题背后隐藏的一些问题。
可确定输入的情况大致为:
面试时请教了一下面试官,面试官的思路:
最简单办法就是枚举所有可能的排列组合情况,然后求排列组合后的最大值;再就是寻找组合的规律,满足什么条件的元素排列在前。
当然这只是面试官提供的一些解决思路,付诸于实践还需要探索。在复试前的一天晚上我再次翻出这个问题,并找到了一些思路。
就拿问题中的用例 [0, 9, 523, 94, 10, 4] 来说,需要找出的结果为:9,94,523,4,10,0(为了方便说明,用”,“分割了数组元素)。
先将复杂问题简单化处理,首先尝试使用 排序算法 来分析过程。分析 9 和 94 的排列,为什么 9 排列在 94 前?那是因为这 2 个数存在 2 种排列情况,既_ 9_94_ 和_ 9_49_,很明显 9_94_ 排列大于 _9_49 排列,所以需要将 9 排列在 94 前,反之则需要交换元素位置。如果采用这样规则处理,是在 2 个元素之间进行枚举排列情况,且单次枚举情况限定在了 2 种,降低了问题的复杂程度并易于编码实现,后续可以直接使用排序方法来多次重复这种 2 个元素之间的单次枚举动作。
说明:符号“_”为占位符,表示该位置可能还存在其他元素,但不影响当前两个元素的前后排列顺序。后续出现该符号将不再说明。
总之,我认为该问题是排序问题的一个变种情况,同排序问题不同的是 比较规则。这里不是直接比较 2 个元素值大小,而是比较 2 个元素排列组合后值的大小。
经过上述分析,问题规律已经掌握清楚,这里整理出实现的思路。
使用冒泡排序来说明上述用例的排序过程。
本问题的排序比较规则可以描述为:假设参与比较的两个元素为 A、B(初始时 A 在 B 前,排序结果从左至右为由大到小),比较时如果排列 _A_B_ 小于排列 _B_A_,A 和 B 则交换位置,反之不交换。
/** |
/** |
由于 PHP 中 sort 排序函数采用快速排序算法,这里直接使用之。
/** |
这里只对快速排序方法使用 2 组测试用例并列举如下。
$Arr = [20,913,223,91,20,3]; |
//第1组用例 |
经过深入分析问题的本质,也使得我对与排序算法有了更深入的认识,更算是一个巩固。同时,正是由于我尝试着去解决这个问题,才使得我在后面的复试环节中面试官再次提出相同问题时,给出了一个满意的解决方案。
相关文章 »
原文:https://linux.cn/article-8290-1.html
今天,我来为大家解读一幅来自 TurnOff.us 的漫画 “InSide The Linux Kernel” 。 TurnOff.us是一个极客漫画网站,作者Daniel Stori 画了一些非常有趣的关于编程语言、Web、云计算、Linux 相关的漫画。今天解读的便是其中的一篇。
在开始,我们先来看看这幅漫画的全貌!
这幅漫画是以一个房子的侧方刨面图来绘画的。使用这样的一个房子来代表 Linux 内核。
作为一个房子,最重要的莫过于其地基,在这个图片里,我们也从最下面的地基开始看起:
地基(底层)由一排排的文件柜组成,井然有序,文件柜里放置着“文件”——电脑中的文件。左上角,有一只胸前挂着 421 号牌的小企鹅,它表示着 PID(进程 IDProcess ID) 为 421 的进程,它正在查看文件柜中的文件,这代表系统中正有一个进程在访问文件系统。在右下角有一只小狗,它是看门狗 watchdog ,这代表对文件系统的监控。
看完了地基,接下来我们来看地基上面的一层,都有哪些东西。
在这一层,最引人瞩目的莫过于中间的一块垫子,众多小企鹅在围着着桌子坐着。这个垫子的区域代表进程表。
左上角有一个小企鹅,站着,仿佛在说些什么这显然是一位家长式的人物,不过看起来周围坐的那些小企鹅不是很听话——你看有好多走神、自顾自聊天的——“喂喂,说你呢,哇塞娃(171),转过身来”。它代表着 Linux 内核中的初始化(init)进程,也就是我们常说的 PID 为 1 的进程。桌子上坐的小企鹅都在等待状态 wait 中,等待工作任务。
瞧瞧,垫子(进程表)旁边也有一只小狗,它会监控小企鹅的状态(监控进程),当小企鹅们不听话时,它就会汪汪地叫喊起来。
在这层的左侧,有一只号牌为 1341 的小企鹅,守在门口,门上写着 80,说明这个 PID 为 1341 的小企鹅负责接待 80 端口,也就是我们常说的 HTTP (网站)的端口。小企鹅头上有一片羽毛,这片羽毛大有来历,它是著名的 HTTP 服务器 Apache 的 Logo。喏,就是这只:
向右看,我们可以看到这里仍有一扇门,门上写着 21,但是,看起来这扇门似乎年久失修,上面的门牌号都歪了,门口也没人守着。看起来这个 21 端口的 FTP 协议有点老旧了,目前用的人也比以前少了,以至于这里都没人接待了。
而在最右侧的一个门牌号 22 的们的待遇就大为不同,居然有一只带着墨镜的小企鹅在守着,看起来好酷啊,它是黑衣人叔叔吗?为什么要这么酷的一个企鹅呢,因为 22 端口是 SSH 端口,是一个非常重要的远程连接端口,通常通过这个端口进行远程管理,所以对这个端口进来的人要仔细审查。
它的身上写着 52,说明它是第 52 个小企鹅。
在图片的左上角,有一个向下台阶。这个台阶是底层(地基)的文件系统中的,进程们可以通过这个台阶,到文件系统中去读取文件,进行操作。
在这一层中,有一个身上写着 217 的小企鹅,他正满头大汗地看着自己的手表。这只小企鹅就是定时任务(Crontab),他会时刻关注时间,查看是否要去做某个工作。
在图片的中部,有两个小企鹅扛着管道(PipeLine)在行走,一只小企鹅可以把自己手上的东西通过这个管道,传递给后面的小企鹅。不过怎么看起来前面这种(男?)企鹅累得满头大汗,而后面那只(女?)企鹅似乎游刃有余——喂喂,前面那个,裤子快掉了~
在这一层还有另外的一个小企鹅,它手上拿着一杯红酒,身上写着 411,看起来有点不胜酒力。它就是红酒(Wine)小企鹅,它可以干(执行)一些来自 Windows 的任务。
在一层之上,还有一个跃层,这里有很多不同的屏幕,每个屏幕上写着 TTY(这就是对外的终端)。比如说最左边 tty4 上输入了“fre”——这是想输入“freshmeat…”么 :d ;它旁边的 tty2 和 tty3 就正常多了,看起来是比较正常的命令;tty7 显示的图形界面嗳,对,图形界面(X Window)一般就在 7 号终端;tty5 和 tty6 是空的,这表示这两个终端没人用。等等,tty1 呢?
tty(终端)是对外沟通的渠道之一,但是,不是每一个进程都需要 tty,某些进程可以直接通过其他途径(比如端口)来和外部进行通信,对外提供服务的,所以,这一层不是完整的一层,只是个跃层。
好了,我们有落下什么吗?
这小丑是谁啊?
啊哈,我也不知道,或许是病毒?你说呢?
]]>Hexo 如官方介绍一样,安装方便快捷。安装前请确保 Node 和 Nginx 环境已经存在,需要安装可以参考 CentOS 6 安装 Node 和 Nginx 安装。
只需使用如下命令即可安装 Hexo。
npm install hexo-cli -g |
安装完成后目录结构如下:
├── _config.yml # 主配置文件 |
Hexo 默认启动 4000 端口,使用浏览器访问 http://localhost:4000,即可看见 Hexo 美丽的面容。
说明:Nginx 配置站点根目录为public
。
关于 Hexo 更详细的使用技巧,见官网文档,这里只列举常用的使用方法。
Hexo 提供的可选 主题 比较多,总有一款你如意的,我这里主题选择了 hexo-theme-yilia,没有为什么,就是看起来舒服而已,后续相关配置也是基于该主题。
找到喜欢的一款后,使用如下命令安装主题:
进入博客目录 |
最后一步,在_config.yml
配置中启用新主题。
theme: yilia |
关于主题的相关配置,参考主题源码中的 README.md 文档。
hexo-theme-yilia 主题我做了较多的修改,如果你觉得我的修改也适合你,那么你只要 pull 下来即可,而不需要再做 自定义修改 部分的修改。
这里只列举我使用过的方法,更多文章的使用方法,见这里。
1) 新建文章
当需要写文章时,使用如下命令新建文章,会在资源文件夹中生成与 title 对应的 md 文件。
hexo new [layout] <title> |
md 文件就是 Markdown 格式的文章表述。格式大致为:
title: Hello World |
文件最上方以---
为分隔符,分隔符以上为 Front-matter,用于指定与文章相关的基本信息,分隔符以下才为文章的内容区域。
2) Front-matter
Front-matter 内容如下:
layout 布局 |
其中 title、date、tags、categories 这 4 项,在新建文章时需要进行设置,其他项采用默认值即可,不需要在每篇文章中进行设置,故可以将这 4 项基本设置移到模板文件scaffolds\post.md
中,如下:
--- |
这样在新建文章时,就会自动在文章 md 文件中加入 4 项基本设置。
特别说明,文章中添加了分类和标签后, Hexo 会自动生成分类页面和统计分类的文章数。关于分类和标签的使用,如下:
categories: # 分类存在顺序关系 |
3) 正文
文章正文使用 Markdown 格式即可,我使用的 Markdown 编辑器主要有 Typora — Win版 和 马克飞象 — 网页版。
Typora 和 马克飞象 的对比:
使用编辑器预览编辑完文章后,导出 md 文件替换新建文章时生成的同名 md 文件即可。
编辑完文章后,使用hexo s
命令即可实时预览到文章效果。
文章的新增和编辑都是在资源文件夹下(source
)操作,完成后需要发布才能生成静态文件(public
),进而才能通过浏览器直接访问。
发布更新命令如下:
hexo generate |
发布后,public
文件夹更新到最新状态,此时即可直接访问。
安装 hexo-generator-search,在_config.yml
中添加如下配置代码:
search: |
安装 hexo-generator-feed,并按照说明配置(atom.xml 的链接写在source/_data/link.json
的 social 项中,一般无需更改)
安装 hexo-generator-json-content,即可生成所有文章的 json 描述。需在_config.yml
中添加如下配置代码:
jsonContent: |
安装 hexo-generator-sitemap,并在_config.yml
中添加如下配置代码:
sitemap: |
在使用 Hexo 生成器时会自动生成最新的站点地图 sitemap.xml文件。
更多的配置信息,见这里。我这里只列举比较重要的配置。
在 Hexo 中,相对路径是针对资源文件夹source
来讲,所以文章的静态图片应放置于资源文件夹下。
可以将所有文章的静态图片统一放置于source/images
下,但是这样不方便于管理,推荐方法是将每篇文章的图片放置于与该文章同名的资源文件下,然后使用相对路径引用即可。
在配置文件_config.yml
中开启post_asset_folder
项,即更改为:
post_asset_folder: true |
开启该项配置后,Hexo 将会在你每一次通过hexo new [layout] <title>
命令创建新文章时自动创建一个文件名同 md 文件的文件夹。将所有与你的文章有关的资源放在这个关联文件夹中之后,就可以通过相对路径来引用它们。
写文章时你只需在 Markdown 中插入相对 md 文件的 相对路径 的图片即可,hexo-asset-image 自动转化为网站 绝对路径。此时,可以直接使用 Hexo 提供的标签asset_img
来插入图片,但是这样违背了 Markdown 语法,无法及时预览,不便于编辑文章。
可以通过以下 Markdown 语法在文章中插入图片,这种方式同时也支持本地 Markdown 编辑器实时预览。
![alt](/post_title/image_name) |
Hexo 默认 URL 地址为year/month/day/title/
形式,而这种形式并不友好,需更改为year/month/title.html
形式。这里我已经将source
目录下的 md 文件按year/month
手动归档了,所以 Hexo 发布时只需要title.html
这部分。配置如下:
permalink: :title.html |
修改_config.yml
配置项如下:
highlight: |
如果采用本地编辑博客,博客部署在远程服务器上,那么你就需要部署,才能同步本地更新到远程服务器。
Hexo 提供了 5 种部署方案,见这里,这里只介绍以下 2 种:
1) Git
_config.yml
配置如下:
deploy: |
该方案适用于采用 Github Pages 托管博客的用户,当然使用服务器搭建博客的用户可以使用 Webhook 方案来实现。
2) Rsync
_config.yml
配置如下:
deploy: |
显然,该方案适用于使用服务器搭建博客的用户,但是需要在本地安装 Rsync 客户端(cwRsync)。同时,需要在服务器搭建和配置 Rsync 服务,见这里。
我尝试在 Win10 下实现这种方案,但是遇到了很多问题,例如 rsync 服务端采用 SSH 认证方式,但是 cwRsync 使用的 SSH 客户端呆板的从
/home/.ssh
目录查找 SSH 配置和公钥,很悲剧 Win10 下无法识别这个路径,导致无法免密登录 SSH,Rsync 同步也无法进行。
总之,部署的目的,就是将发布生成的静态文件public
更新到服务器上,如果能实现这个目的,途径倒是无所谓了。
上述推荐部署方案,明显的缺点是本地需要部署 Hexo 环境,无法实现随时随地的更新博客。为了方便写作,我的部署方案见 我的博客发布上线方案 — Hexo。
由于 highlight.js 对 Shell 语法高亮解析效果并不理想,为此我对 languages/shell.js 部分做了修改来更好地支持 Shell,你只需要 pull 并替换掉原 languages/shell.js 文件即可。
git clone https://github.com/fan-haobai/highlight.js.git |
并将 shell.js 中的如下部分:
function(hljs) |
修改为:
module.exports = function(hljs) |
换来换去,最后还是觉得只有 Disqus 合适,但是需要先解决被墙的问题,不过 fooleap 已经提供了一个较好的解决方案—— disqus-php-api。你只需要 pull 代码到境外服务器,部署一个 PHP 服务即可。
我部署后域名为 disqus.fanhaobai.com。首先在layout/_partial/article.ejs
文件中追加以下内容:
<% if (!index && post.comments){ %> |
然后,在layout/_partial/post
目录下创建disqus.ejs
文件,内容如下:
<div id="disqus_thread"></div> |
最后,在_config.yml
增加如下配置:
disqus: |
有关 Disqus 更详细的配置,见 Disqus 设置 部分。
首先,在layout/_partial/after-footer.ejs
文件中追加如下代码:
<%- partial('baidu-analytics') %> |
并在layout/_partial
目录下创建baidu-analytics.ejs
文件,内容为:
<% if (theme.baidu_analytics){ %> |
然后,在配置文件_config.yml
中,增加如下配置信息:
# 百度分析Uid,若为空则不启用 |
为了更好的收录本站文章,这里引进了百度 主动推送功能,只需添加如下 JS代码,每当文章被浏览时都会自动向百度提交链接,这种方式以用户为驱动,较为方便和实用。
在主题模板文件layout/_partial/article.ejs
中,追加以下代码:
<% if (!index){ %> |
到这里,也终于算是搭建结束了。至于 404 页面打算采用 腾讯的公益404页面 来做,见这里。
更新 »
相关文章 »
原文:http://www.lcode.cc/2016/12/24/rand_ward.html
前一阵公司业务有一个生成红包的需求,分为固定红包和随机红包两种,固定红包没什么好说的了,随机红包要求指定最小值,和最大值,必须至少有一个最大值,可以没有最小值,但任何红包不能小于最小值。
以前从来没做过这方面,有点懵B,于是去百度了一番,结果发现能找到的红包算法都有各种各样的 bug,要么会算出负值,要么超过最大值,所以决定自己撸一套出来。
在随机数生成方面,我借鉴了这位博主 @悲惨的大爷 的思路:
原文:比如要把 1 个红包分给 N 个人,实际上就是相当于要得到 N 个百分比数据 条件是这 N 个百分比之和 = 100/100。这 N 个百分比的平均值是 1/N。 并且这 N 个百分比数据符合一种正态分布(多数值比较靠近平均值)。
解读:比如我有 1000 块钱,发 50 个红包,就先随机出 50 个数,然后算出这 50 个数的均值 avg,用 avg/(1/N),就得到了一个基数 mixrand ,然后用随机出的那 50 个数分别去除以 mixrand ,得到每个数相对基数的百分比 randVal ,然后用 randVal 乘以 1000 块钱,就可以得到每个红包的具体金额了。
还是不太清楚咋回事?没关系,我们一起撸代码!
Talk is cheap, show me your code!
|
下边这段代码用来控制具体的业务逻辑,按照具体的需求,留出固定的最大值、最小值红包的金额等;在代码中调用生成红包的方法时 splitReward($total, $num,$max - 0.01, $min),我传入的最大值减了 0.01,这样就保证了里面生成的红包最大值绝对不会超过我们设置的最大值。
|
先设置好各种初始值。
|
因为 memory_limit 的限制,所以只测了 5 次的均值,结果都在 1.6s 左右。
for($i=0; $i<5; $i++) { |
运行结果:
1) 数值是否有误
检测有没有负值,有没有最大值,最大值有多少个,有没有小于最小值的值。
$reward_arr = $create_reward->random_red($total, $num, $max, $min); |
运行结果:
2) 正态分布情况
注意,出图的时候,红包的数量不要给的太大,不然页面渲染不出来,会崩 。
$reward_arr = $create_reward->random_red($total, $num, $max, $min); |
运行结果:
PS:有朋友问我生成的数据有没有通过数学方法来验证其是否符合标准正态分布,因为我的数学不好,这个还真没算过,只是看着觉得像,就当他是了。既然遇到了这个问题,就一定要解决嘛,所以我就用 php 内置函数算了一下,算出来的结果在数据量小的时候还是比较接近正态分布的,但是数据量大起来的时候就不能看了,我整不太明白这个,大家感兴趣的可以找一下原因哟。 php 的四个函数:stats_standard_deviation(标准差),stats_variance(方差), stats_kurtosis((峰度),stats_skew(偏度)。使用上面的函数需要安装 stats 扩展。
到这里,红包就算是写完啦,不知道能不能涨 50 块工资,但应该能解决燃眉之急了。
哦对,还落下了这个源码,打包下载 。
Robots 协议代表了互联网领域的一种契约精神,互联网企业只有遵守这一规则,才能保证网站及用户的隐私数据不被侵犯,违背 Robots 协议将带来巨大安全隐忧。例如,百度诉奇虎360违反“Robots协议”抓取、复制其网站内容侵权一案。
互联网的网页都是通过超链接互相关联的,进而形成了网页的网状结构。所以爬虫的工作方法就如蜘蛛在网络上沿着超链接按照一定的爬取规则爬取网页。
基本流程大致为:
1) 喂给爬虫一堆 URL,称之为 种子(Seeds);
2) 爬虫爬取 Seeds,分析 HTML 网页,抽取其中的 超链接;
3) 爬虫接着爬取这些 新发现 的超链接指向的 HTML 网页;
4) 对过程 2),3)循环往复;
Robots协议 主要功能 为以下 4 项:
1) 网站通过该协议告诉搜索引擎哪些页面可以抓取,哪些页面不能;
2) 可以屏蔽一些网站中比较大的文件,如:图片,音乐,视频等,节省服务器带宽;
3) 可以屏蔽站点的一些死链接,方便搜索引擎抓取网站内容;
4) 设置网站地图导向,方便引导蜘蛛爬取页面;
可以想象,如果一个站点没有引入 Robots 协议,那么爬虫就会漫无目地爬取,爬取结果一般不尽人意。反之,将我们站点内容通过 Robots 协议表述出来并引入 Robots 协议,爬虫就会按照我们的意愿进行爬取。
Robots 协议是国际互联网界通行的 道德规范,基于以下 ** 原则** 建立:
1) 搜索技术应服务于人类,同时尊重信息提供者的意愿,并维护其隐私权;
2) 网站有义务保护其使用者的个人信息和隐私不被侵犯;
Robots 协议是通过 robots.txt 文件来进行表述的,robots.txt文件规范见这里 。robots.txt 文件是一个 文本文件,使用任何一个常见的文本编辑器都可以对它进行查看与编辑。当然,也可以使用 Robots 文件生成工具 方便地生成我们所需要的 robots.txt 文件。
提示: robots.txt 文件应该放置在网站根目录下。
对规范大致描述为:
User-agent: * # *代表所有的搜索引擎种类,是一个通配符,其他常用值:百度-Baiduspider,搜狗-sogou spider,谷歌-Googlebot |
一般情况下不需要指定 Allow 这项配置。
这里参照了百度站长的 官方文档,大致描述如下:
"1.0" encoding="utf-8" xml version= |
主体结构为完整的 HTML,将需要被爬的链接以<a></>
标签的形式加入到body
中即可。
... ... |
本站拒绝了雅虎爬虫的爬取,对其他的爬虫,theme、static 目录下的 2 个逻辑代码目录 api、module 和 4 个静态资源目录 font、css、img、js 做了限制爬取,对 static 目录下 upload 做了允许爬取处理,并配置了后缀为.xml
和.htm
文件的站点地图。
# robots.txt for fanhaobai.com 2017.01.12 |
User-agent: * |
一般情况下,站点根目录下加入了 robots.txt 文件后,各种搜索引擎的爬虫就会自动爬取该文件。尽管如此,还是建议手动将 robots.txt 文件提交到搜索引擎,同时也能帮助检测 robots.txt 文件是否存在错误。
本站手动将 robots.txt 提交到谷歌和百度两个搜索引擎:
1) 谷歌测试工具
2) 百度测试工具
按照对应提示操作即可,出现上图情况则表示 robots.txt 手动提交成功。
Robots 协议只是爬虫抓取站点内容的一种规则,需要搜索引擎爬虫的配合才行,并不是每个搜索引擎爬虫都遵守的。但是,目前看来,绝大多数的搜索引擎爬虫都遵守 Robots 协议的规则。
值得注意的是,robots.txt 文件虽说是提供给爬虫使用,但是正如它的名称——网络爬虫排除标准,它具有消极的排爬虫抓取作用。所以百度官方建议,** 仅当网站包含不希望被搜索引擎收录的内容时,才需要使用 robots.txt 文件。如果您希望搜索引擎收录网站上所有内容,请勿建立robots.txt 文件 **。
推荐一篇相关文章:http://lusongsong.com/reed/732.html。
相关文章 »
转自「码农翻身」
我是一个 线程,我一出生就被编了个号:0x3704。
我出生后被领到一个昏暗的屋子里,在这里我发现了很多和我一模一样的同伴。我身边的同伴 0x6900 待的时间比较长,他带着沧桑的口气对我说:“我们线程的宿命就是处理包裹。把包裹处理完以后还得马上回到这里,否则可能永远回不来了。”
我一脸懵懂,“包裹,什么包裹?”
“不要着急,马上你就会明白了,我们这里是不养闲人的。”
果然,没多久,屋子的门开了, 一个面貌凶恶的家伙吼道:“0x3704,出来!”
我一出来就被塞了一个沉甸甸的包裹,上面还附带着一个写满了操作步骤的纸。
“快去,把这个包裹处理了。”
“去哪儿处理?”
“跟着指示走,先到就绪车间。”
果然,地上有指示箭头,跟着它来到了一间明亮的大屋子,这里已经有不少线程了,大家都很紧张,好像时刻准备着往前冲。
我刚一进来,就听见广播说:“0x3704,进入车间。”
我赶紧往前走,身后有很多人议论。
“他太幸运了,刚进入就绪状态就能运行。”
“是不是有关系?”
“不是,你看人家的优先级多高啊,唉!”
前边就是车间,这里简直是太美了,怪不得老线程总是唠叨着说:“要是能一直待在这里就好了。”
这里空间大,视野好,空气清新,鸟语花香,还有很多从来没见过的人,像服务员一样等着为我服务。
他们也都有编号,更重要的是每个人还有个标签,上面写着:硬盘、数据库、内存、网卡……
我现在理解不了,看看操作步骤吧。
1) 第一步:从包裹中取出参数
打开包裹,里边有个HttpRequest
对象,可以取到userName
、 password
两个参数。
2) 第二步:执行登录操作
奥,原来是有人要登录啊,我把userName
、password
交给数据库服务员,他拿着数据,慢腾腾地走了。
他怎么这么慢?不过我是不是正好可以在车间里多待一会儿?反正也没法执行第三步。
就在这时,车间里的广播响了:“0x3704,我是 CPU,记住你正在执行的步骤,然后马上带着包裹离开!”
我慢腾腾地开始收拾。
“快点,别的线程马上就要进来了。”
离开这个车间,又来到一个大屋子,这里有很多线程在慢腾腾地喝茶,打牌。
“哥们,你们没事干了?”
“你新来的吧,你不知道我在等数据库服务员给我数据啊!据说他们比我们慢好几十万倍,在这里好好歇吧。”
“啊? 这么慢!我这里有人在登录系统,能等这么长时间吗?”
“放心,你没听说过人间一天,CPU 一年吗?我们这里是用纳秒、毫秒计时的,人间等待一秒,相当于我们好几天呢,来得及。”
干脆睡一会吧。不知道过了多久,大喇叭又开始广播了:“0x3704,你的数据来了,快去执行!”
我转身就往 CPU 车间跑,发现这里的门只出不进!
后面传来阵阵哄笑声:“果然是新人,不知道还得去就绪车间等。”
于是赶紧到就绪车间,这次没有那么好运了,等了好久才被再次叫进 CPU 车间。
在等待的时候,我听见有人小声议论:
“听说了吗,最近有个线程被 kill 掉了。”
“为啥啊?”
“这家伙赖在 CPU 车间不走,把 CPU 利用率一直搞成 100% ,后来就被 kill 掉了。”
“Kill 掉以后弄哪儿去了?”
“可能被垃圾回收了吧。”
我心里打了个寒噤,赶紧接着处理,剩下的动作快多了,第二步登录成功。
3) 第三步:构建登录成功后的主页
这一步有点费时,因为有很多HTML
需要处理,不知道代码谁写的,处理起来很烦人。
我正在紧张的制作HTML
呢,CPU 又开始叫了:
“0x3704,我是 CPU ,记住你正在执行的步骤,然后马上带着包裹离开!”
“为啥啊?”
“每个线程只能在 CPU 上运行一段时间,到了时间就得让别人用了,你去就绪车间待着,等着叫你吧。”
就这样,我一直在“就绪——运行”这两个状态中不知道轮转了多少次, 终于按照步骤清单把工作做完了。
最后顺利地把包含HTML
的包裹发了回去。至于登录以后干什么事儿,我就不管了。马上就要回到我那昏暗的房间了,真有点舍不得这里。不过相对于有些线程,我还是幸运的,他们运行完以后就被彻底地销毁了,而我还活着!
回到了小黑屋,老线程 0x6900 问:
“怎么样?第一天有什么感觉?”
“我们的世界规则很复杂,首先你不知道什么时候会被挑中执行;第二,在执行的过程中随时可能被打断,让出 CPU 车间;第三,一旦出现硬盘、数据库这样耗时的操作,也得让出 CPU 去等待;第四,就是数据来了,你也不一定马上执行,还得等着 CPU 挑选。”
“小伙子理解的不错啊。”
“我不明白为什么很多线程执行完任务就死了,为什么咱们还活着?”
“你还不知道?长生不老是我们的特权!我们这里有个正式的名称,叫作线程池!”
平淡的日子就这么一天天地过去,作为一个线程,我每天的生活都是取包裹、处理包裹,然后回到我们昏暗的家:线程池 。
有一天我回来的时候,听到有个兄弟说,今天要好好休息下,明天就是最疯狂的一天。我看了一眼日历,明天是 11 月 11 号。
果然,零点刚过,不知道那些人类怎么了,疯狂地投递包裹,为了应付蜂拥而至的海量包裹,线程池里没有一个人能闲下来,全部出去处理包裹,CPU 车间利用率超高,硬盘在嗡嗡转,网卡疯狂的闪,即便如此,还是处理不完,堆积如山。
我们也没有办法,实在是太多太多了,这些包裹中大部分都是浏览页面,下订单,买、买、买。
不知道过了多久,包裹山终于慢慢地消失了。终于能够喘口气,我想我永远都不会忘记这一天。
通过这个事件,我明白了我所处的世界:这是一个电子商务的网站!
我每天的工作就是处理用户的登录,浏览,购物车,下单,付款。
我问线程池的元老 0x6900:“我们要工作到什么时候?”
“要一直等到系统重启的那一刻。”0x6900 说。
“那你经历过系统重启吗?”
“怎么可能?系统重启就是我们的死亡时刻,也就是世界末日,一旦重启,整个线程池全部销毁,时间和空间全部消失,一切从头再来。”
“那什么时候会重启?”
“这就不好说了,好好享受眼前的生活吧……”
其实生活还是丰富多彩的,我最喜欢的包裹是上传图片,由于网络慢,所以能在就绪车间、CPU 车间待很长很长时间,可以认识很多好玩的线程。
比如说上次认识了 memecached 线程,他对我说在他的帮助下缓存了很多的用户数据,还是分布式的!很多机器上都有!
我问他:“怪不得后来的登录操作快了那么多,原来是不再从数据库取数据了你那里就有啊,哎对了你是分布式的你去过别的机器没有?”
他说:“怎么可能!我每次也只能通过网络往那个机器发送一个GET
、PUT
命令才存取数据而已,别的一概不知。”
再比如说上次在等待的时候遇到了数据库连接的线程,我才知道他那里也是一个连接池,和我们的线程池几乎一模一样。
他告诉我:“有些包裹太变态了,竟然查看一年的订单数据,简直把我累死了。”
我说:“拉倒吧你,你那是纯数据,你把数据传给我以后,我还得组装成HTML
,工作量不知道比你大多少倍。”
他建议我:“你一定要和 memecached 搞好关系,直接从他那儿拿数据,尽量少直接调用数据库,这样我们JDBC connection
也能活得轻松点。”
我欣然接纳:“好啊好啊,关键是你得提前把数据搞到缓存啊,要不然我先问一遍缓存,没有数据,我这不还得找你吗?”
生活就是这样,如果你自己不找点乐子,还有什么意思?
前几天我遇到一个可怕的事情,差一点死在外边,回不了线程池了。其实这次遇险我应该能够预想得到才对,真是太大意了。
那天我处理了一些从http
发来的存款和取款的包裹,老线程 0x6900 特意嘱咐我:“处理这些包裹的时候一定要特别小心,你必须先获得一把锁,在对账户存款或取款的时候一定要把账户锁住,要不然别的线程就会在你等待的时候趁虚而入,搞破坏,我年轻那会儿很毛糙,就捅了篓子。”
为了“恐吓”我, 好心的 0x6900 还给了我两个表格:
1) 没有加锁的情况
一个银行账号:账户 A,余额 1000 元
一个存钱的线程
一个取款的线程
线程1:存入300元 | 线程2:取出200元 |
---|---|
获得当前余额:1000 | |
计算最新余额:1000+300=1300 | |
线程中断,等待下次被系统执行 | 获取当前余额:1000 |
计算最新余额:1000-200=800 | |
线程中断,等待下次被系统执行 | |
再次执行,更新余额1300 | |
再次执行,更新余额:800 存入的钱丢了 |
2) 加锁的情况
线程1:存入300元 | 线程2:取出200元 |
---|---|
获取账号A的锁:成功 | |
获取余额:1000 | |
计算最新余额:1000+300=1300 | |
线程中断,等待下次被系统执行 | |
获取账户A的锁:失败,进入阻塞状态 | |
被系统选中,再次执行,更新余额1300 | |
释放账户A的锁 | |
获取账户A的锁:成功 | |
获取余额:1300 | |
计算最新余额:1300-200=1100 | |
更新余额:1100 | |
释放锁账户A的锁 |
我看得胆颤心惊,原来不加锁会带来这么严重的事故。从此以后看到存款、取款的包裹就倍加小心,还好没有出过事故。
今天我收到的一个包裹是转账,从某著名演员的账户给某著名导演的账户转钱,具体是谁我就不透漏了,数额可真是不小。
我按照老线程的吩咐,肯定要加锁啊,先对著名演员的账户加锁,再对著名导演的账户加锁。
可我万万没想到的是,还有一个线程,对,就是 0x7954,竟然同时在从这个导演的账户往这个演员的账户转账。
于是乎,就出现了这么个情况:
线程0x3704:著名演员->著名导演 | 线程0x7954:著名导演->著名演员 |
---|---|
获取著名演员的锁:成功 | |
线程中断,等待下次被系统执行 | |
获取著名导演的锁:成功 | |
线程中断,等待下次被系统执行 | |
获取著名导演的锁:失败,继续等待 | |
获取著名演员的锁:失败,继续等待 |
刚开始我还不知道什么情况,一直坐在等待车间傻等,可是等的时间太长了,长达几十秒!我可从来没有经历过这样的事件。
这时候我就看到了线程 0x7954,他悠闲地坐在那里喝咖啡,我和他聊了起来:
“哥们,我看你已经喝了 8 杯咖啡了,怎么还不去干活?”
“你不喝了 9 杯茶了吗?”0x7954 回敬道。
“我在等一个锁,不知道哪个孙子一直不释放!”
“我也在等锁啊,我要是知道哪个孙子不释放锁我非揍死他不可!”0x7954 毫不示弱。
我偷偷地看了一眼,这家伙怀里不就抱着我正等的某导演的锁吗?
很明显,0x7954 也发现了我正抱着他正在等待的锁。
很快我们两个就吵了起来,互不相让:
“把你的锁先给我,让我先做完!”
“不行,从来都是做完工作才释放锁,现在绝对不能给你!”
从争吵到打起来,就那么几秒钟的事儿。更重要的是,我们俩不仅仅持有这个著名导演和演员的锁,还有很多其他的锁,导致等待的线程越来越多,围观的人们把屋子都挤满了。最后事情真的闹大了,我从来没见过的终极大 Boss “操作系统”也来了。大 Boss 毕竟见多识广,他看了一眼,哼了一声,很不屑地说:
“又出现死锁了。”
“你们俩要 kill 掉一个,来吧,过来抽签。”
这一下子把我给吓尿了,这么严重啊!我战战兢兢地抽了签,打开一看,是个“活”字。唉,小命终于保住了。
可怜的 0x7954 被迫交出了所有的资源以后,很不幸地被 kill 掉,消失了。我拿到了导演的锁,可以开始干活了。大 Boss “操作系统”如一阵风似的消失了,身后只传来他的声音:
“记住,我们这里导演>演员,无论任何情况都要先获得导演的锁。”
由于这里不仅仅只有导演和演员,还有很多其他人,大 Boss 留下了一个表格, 里边是个算法,用来计算资源的大小,计算出来以后,永远按照从大到小的方式来获得锁:
线程1:账户A转到账户B | 线程2:账户B转到账户A |
---|---|
获取账户A的锁:成功 | |
线程中断,等待下次被系统执行 | |
获取账户A的锁:失败,继续等待 | |
获取账户B的锁:成功 | |
执行转账 | |
释放B的锁 | |
释放A的锁 | |
获取账户A的锁:成功 | |
获取账户B的锁:成功 | |
执行转账 | |
释放B的锁 | |
释放A的锁 |
我回到线程池,大家都知道了我的历险,围着我问个不停。
凶神恶煞的线程调度员把大 Boss 的算法贴到了墙上。
每天早上,我们都得像无节操的房屋中介、美容美发店的服务员一样,站在门口,像被耍猴一样大声背诵:
“多个资源加锁要牢记,一定要按 Boss 的算法比大小,然后从最大的开始加锁。”
又过了很多天,我和其他线程们发现了一个奇怪的事情:包裹的处理越来越简单,不管任何包裹,不管是登录、浏览、存钱……处理的步骤都是一样的, 返回一个固定的HTML
页面。
有一次我偷偷地看了一眼,上面写着:“本系统将于今晚 00:00 至 4:00 进行维护升级, 给您带来的不便我们深感抱歉!”
我去告诉了老线程 0x6904,他叹了一口气说:
“唉,我们的生命也到头了,看来马上就要重启系统,我们就要消失了,再见吧兄弟。”
系统重启的那一刻终于到来了。我看到屋子里的东西一个个的不见了,等待车间、就绪车间,甚至 CPU 车间都慢慢地消失了。我身边的线程兄弟也越来越少,最后只剩我自己了。
我在空旷的原野上大喊:“还有人吗?”
无人应答。
我们这一代线程池完成了使命……
不过下一代线程池即将重生!
]]>原文:http://www.libenfu.com/vim-快捷键整理
作为一名后端码农,常用的 vim 快捷键,你了解多少呢?
1、左移h
、右移l
、下移j
、上移k
2、向下翻页ctrl + f
,向上翻页ctrl + b
3、向下翻半页ctrl + d
,向上翻半页ctrl + u
4、移动到行尾$
,移动到行首0
(数字),移动到行首第一个字符处^
5、移动光标到下一个句子)
,移动光标到上一个句子(
6、移动到段首{
,移动到段尾}
7、移动到下一个词w
,移动到上一个词b
8、移动到文档开始gg
,移动到文档结束G
9、移动到匹配的{}.().[]
处%
10、跳到第n
行ngg
或nG
或 :n
11、移动光标到屏幕顶端H
,移动到屏幕中间M
,移动到底部L
12、读取当前字符,并移动到本屏幕内下一次出现的地方*
13、读取当前字符,并移动到本屏幕内上一次出现的地方#
1、光标向后查找关键字#
或者g#
2、光标向前查找关键字*
或者g*
3、当前行查找字符fx/Fx/tx/Tx
4、基本替换:s/s1/s2
(将下一个s1
替换为s2
)
5、全部替换:%s/s1/s2
6、只替换当前行:s/s1/s2/g
7、替换某些行:n1,n2 s/s1/s2/g
8、搜索模式为/string
,搜索下一处为n
,搜索上一处为N
9、制定书签mx
,但是看不到书签标记,而且只能用小写字母
10、移动到某标签处``x 11、移动到上次编辑文件的位置
.
. 代表一个任意字符 |
1、光标后插入a
, 行尾插入A
2、后插一行插入o
,前插一行插入O
3、删除字符插入s
, 删除正行插入S
4、光标前插入i
,行首插入I
5、删除一行dd
,删除后进入插入模式cc
或者S
6、删除一个单词dw
,删除一个单词进入插入模式cw
7、删除一个字符x
或者dl
,删除一个字符进入插入模式s
或者cl
8、粘贴p
,交换两个字符xp
,交换两行ddp
9、复制y
,复制一行yy
10、撤销u
,重做ctrl + r
,重复.
11、智能提示ctrl + n
或者ctrl + p
12、删除motion
跨过的字符,删除并进入插入模式c{motion}
13、删除到下一个字符跨过的字符,删除并进入插入模式,不包括x
字符ctx
14、删除当前字符到下一个字符处的所有字符,并进入插入模式,包括x
字符,cfx
15、删除motion
跨过的字符,删除但不进入插入模式d{motion}
16、删除motion
跨过的字符,删除但不进入插入模式,不包括x
字符dtx
17、删除当前字符到下一个字符处的所有字符,包括x
字符dfx
18、如果只是复制的情况时,将12-17
条中的c
或d
改为y
19、删除到行尾可以使用D
或C
20、拷贝当前行yy
或者Y
21、删除当前字符x
22、粘贴p
23、可以使用多重剪切板,查看状态使用:reg
,使用剪切板使用”
,例如复制到w
寄存器,”wyy
或者使用可视模式v”wy
24、重复执行上一个作用使用.
25、使用数字可以跨过n
个区域,如y3x
,会拷贝光标到第三个x
之间的区域,3j
向下移动3
行
26、在编写代码的时候可以使用]p
粘贴,这样可以自动进行代码缩进
27、>>
缩进所有选择的代码
28、<<
反缩进所有选择的代码
29、gd
移动到光标所处的函数或变量的定义处
30、K
在man
里搜索光标所在的词
31、合并两行J
32、若不想保存文件,而重新打开:e!
33、若想打开新文件:e filename
,然后使用ctrl + ^
进行文件切换
1、分隔一个窗口:split
或者:vsplit
2、创建一个窗口:new
或者:vnew
3、在新窗口打开文件:sf {filename}
4、关闭当前窗口:close
5、仅保留当前窗口:only
6、到左边窗口ctrl + w,h
7、到右边窗口ctrl + w,l
8、到上边窗口ctrl + w,k
9、到下边窗口ctrl + w,j
10、到顶部窗口ctrl + w,t
11、到底部窗口ctrl + w,b
1、开始记录宏操作q[a-z]
,按q
结束,保存操作到寄存器[a-z]
中
2、@[a-z]
执行寄存器[a-z]
中的操作
3、@@
执行最近一次记录的宏操作
1、进入块可视模式ctrl + v
2、进入字符可视模式v
3、进入行可视模式V
4、删除选定的块d
5、删除选定的块然后进入插入模式c
6、在选中的块同是插入相同的字符I<String>ESC
1、[[
向前跳到顶格第一个{
2、[]
向前跳到顶格第一个}
3、]]
向后跳到顶格的第一个{
4、]]
向后跳到顶格的第一个}
5、[{
跳到本代码块的开头
6、]}
跳到本代码块的结尾
1、挂起Vim ctrl + z
或者:suspend
2、查看任务,在shell
中输入jobs
3、恢复任务fg [job number]
(将后台程序放到前台)或者bg [job number]
(将前台程序放到后台)
4、执行shell
命令:!command
5、开启shell
命令:shell
,退出该shell exit
6、保存vim
状态:mksession name.vim
7、恢复vim
状态:source name.vim
8、启动vim
时恢复状态vim -S name.vim
来一张键位图,好好学习。
]]>原文:http://blog.codinglabs.org/articles/theory-of-mysql-index.html
本文以 MySQL 数据库为研究对象,讨论与数据库索引相关的一些话题。特别需要说明的是, MySQL 支持诸多存储引擎,而各种存储引擎对索引的支持也各不相同,因此 MySQL 数据库支持多种索引类型,如 BTree 索引,哈希索引,全文索引等等。为了避免混乱,本文将只关注于 BTree 索引,因为这是平常使用 MySQL 时主要打交道的索引,至于哈希索引和全文索引本文暂不讨论。
文章主要内容分为三个部分。
MySQL 官方对索引的定义为:索引(Index)是帮助 MySQL 高效获取数据的数据结构。提取句子主干,就可以得到索引的本质:索引是数据结构。
我们知道,数据库查询是数据库的最主要功能之一。我们都希望查询数据的速度能尽可能的快,因此数据库系统的设计者会从查询算法的角度进行优化。最基本的查询算法当然是 顺序查找(linear search),这种复杂度为 O(n) 的算法在数据量很大时显然是糟糕的,好在计算机科学的发展提供了很多更优秀的查找算法,例如 二分查找(binary search)、二叉树查找(binary tree search)等。如果稍微分析一下会发现,每种查找算法都只能应用于特定的数据结构之上,例如二分查找要求被检索数据有序,而二叉树查找只能应用于 二叉查找树 上,但是数据本身的组织结构不可能完全满足各种数据结构(例如,理论上不可能同时将两列都按顺序进行组织),所以,在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法。这种数据结构,就是索引。
看一个例子:
图 1 展示了一种可能的索引方式。左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。为了加快 Col2 的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据记录物理地址的指针,这样就可以运用二叉查找在$O \left ( \log_{2} n \right )$ 的复杂度内获取到相应数据。
虽然这是一个货真价实的索引,但是实际的数据库系统几乎没有使用二叉查找树或其进化品种 红黑树(red-black tree)实现的,原因会在下文介绍。
目前大部分数据库系统及文件系统都采用 B-Tree 或其变种 B+Tree 作为索引结构,在本文的下一节会结合存储器原理及计算机存取原理讨论为什么 B-Tree 和 B+Tree 在被如此广泛用于索引,这一节先单纯从数据结构角度描述它们。
为了描述 B-Tree,首先定义一条数据记录为一个二元组 [key, data],key 为记录的键值,对于不同数据记录,key 是互不相同的,data 为数据记录除 key 外的数据。那么 B-Tree 是满足下列条件的数据结构:
图 2 是一个 d=2 的 B-Tree 示意图。
由于 B-Tree 的特性,在 B-Tree 中按 key 检索数据的算法非常直观:首先从根节点进行二分查找,如果找到则返回对应节点的 data,否则对相应区间的指针指向的节点递归进行查找,直到找到节点或找到 null 指针,前者查找成功,后者查找失败。B-Tree 上查找算法的伪代码如下:
BTree_Search(node, key) { |
关于 B-Tree 有一系列有趣的性质,例如一个度为 d 的 B-Tree,设其索引 N 个 key,则其树高h的上限为 $\log_{d} \left ( \left ( N + 1 \right ) / 2 \right )$,检索一个 key,其查找节点个数的渐进复杂度为 $O \left ( \log_{d} N \right )$。从这点可以看出,B-Tree 是一个非常有效率的索引数据结构。
另外,由于插入删除新的数据记录会破坏 B-Tree 的性质,因此在插入删除时,需要对树进行一个分裂、合并、转移等操作以保持 B-Tree 性质,本文不打算完整讨论 B-Tree 这些内容,因为已经有许多资料详细说明了 B-Tree 的数学性质及插入删除算法,有兴趣的朋友可以在本文末的参考文献一栏找到相应的资料进行阅读。
B-Tree 有许多变种,其中最常见的是 B+Tree,例如 MySQL 就普遍使用 B+Tree 实现其索引结构。
与 B-Tree 相比,B+Tree 有以下不同点:
图 3 是一个简单的 B+Tree 示意。
由于并不是所有节点都具有相同的域,因此 B+Tree 中叶节点和内节点一般大小不同。这点与 B-Tree 不同,虽然 B-Tree 中不同节点存放的 key 和指针可能数量不一致,但是每个节点的域和上限是一致的,所以在实现中 B-Tree 往往对每个节点申请同等大小的空间。
一般来说,B+Tree 比 B-Tree 更适合实现外存储索引结构,具体原因与外存储器原理及计算机存取原理有关,将在下面讨论。
一般在数据库系统或文件系统中使用的 B+Tree 结构都在经典 B+Tree 的基础上进行了优化,增加了顺序访问指针。
如图 4 所示,在 B+Tree 的每个叶子节点增加一个指向相邻叶子节点的指针,就形成了带有顺序访问指针的 B+Tree。做这个优化的目的是为了提高区间访问的性能,例如图 4 中如果要查询 key 为从 18 到 49 的所有数据记录,当找到 18 后,只需顺着节点和指针顺序遍历就可以一次性访问到所有数据节点,极大提到了区间查询效率。
这一节对 B-Tree 和 B+Tree 进行了一个简单的介绍,下一节结合存储器存取原理介绍为什么目前 B+Tree 是数据库系统实现索引的首选数据结构。
上文说过,红黑树等数据结构也可以用来实现索引,但是文件系统及数据库系统普遍采用 B-/+Tree 作为索引结构,这一节将结合计算机组成原理相关知识讨论 B-/+Tree 作为索引的理论基础。
一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘 I/O 消耗,相对于内存存取,I/O 存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘 I/O 操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘 I/O 的存取次数。下面先介绍内存和磁盘存取原理,然后再结合这些原理分析 B-/+Tree 作为索引的效率。
目前计算机使用的主存基本都是随机读写存储器(RAM),现代 RAM 的结构和存取原理比较复杂,这里本文抛却具体差别,抽象出一个十分简单的存取模型来说明 RAM 的工作原理。
从抽象角度看,主存是一系列的存储单元组成的矩阵,每个存储单元存储固定大小的数据。每个存储单元有唯一的地址,现代主存的编址规则比较复杂,这里将其简化成一个二维地址:通过一个行地址和一个列地址可以唯一定位到一个存储单元。图 5 展示了一个 4 x 4 的主存模型。
主存的存取过程如下:
当系统需要读取主存时,则将地址信号放到地址总线上传给主存,主存读到地址信号后,解析信号并定位到指定存储单元,然后将此存储单元数据放到数据总线上,供其它部件读取。
写主存的过程类似,系统将要写入单元地址和数据分别放在地址总线和数据总线上,主存读取两个总线的内容,做相应的写操作。
这里可以看出,主存存取的时间仅与存取次数呈线性关系,因为不存在机械操作,两次存取的数据的“距离”不会对时间有任何影响,例如,先取 A0 再取 A1 和先取 A0 再取 D3 的时间消耗是一样的。
上文说过,索引一般以文件形式存储在磁盘上,索引检索需要磁盘 I/O 操作。与主存不同,磁盘 I/O 存在机械运动耗费,因此磁盘 I/O 的时间消耗是巨大的。
图 6 是磁盘的整体结构示意图。
一个磁盘由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘必须同步转动)。在磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头负责存取一个磁盘的内容。磁头不能转动,但是可以沿磁盘半径方向运动(实际是斜切向运动),每个磁头同一时刻也必须是同轴的,即从正上方向下看,所有磁头任何时候都是重叠的(不过目前已经有多磁头独立技术,可不受此限制)。
图 7 是磁盘结构的示意图。
盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道,所有半径相同的磁道组成一个柱面。磁道被沿半径线划分成一个个小的段,每个段叫做一个扇区,每个扇区是磁盘的最小存储单元。为了简单起见,我们下面假设磁盘只有一个盘片和一个磁头。
当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道,所耗费时间叫做寻道时间,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间。
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,磁盘的存取速度往往是主存的几百分分之一,因此为了提高效率,要尽量减少磁盘 I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据是计算机科学中著名的局部性原理:
当一个数据被用到时,其附近的数据也通常会马上被使用。
程序运行期间所需要的数据通常比较集中。
由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高 I/O 效率。
预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为 4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
到这里终于可以分析 B-/+Tree 索引的性能了。
上文说过一般使用磁盘 I/O 次数评价索引结构的优劣。先从 B-Tree 分析,根据 B-Tree 的定义,可知检索一次最多需要访问 h 个节点。数据库系统的设计者巧妙利用了磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次 I/O 就可以完全载入。为了达到这个目的,在实际实现 B-Tree 还需要使用如下技巧:
每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了一个 node 只需一次 I/O。
B-Tree 中一次检索最多需要 h-1 次 I/O(根节点常驻内存),渐进复杂度为 $O \left ( \log_{d} N \right )$。一般实际应用中,出度 d 是非常大的数字,通常超过 100,因此 h 非常小(通常不超过 3)。
综上所述,用 B-Tree 作为索引结构效率是非常高的。
而红黑树这种结构,h 明显要深的多。由于逻辑上很近的节点(父子)物理上可能很远,无法利用局部性,所以红黑树的 I/O 渐进复杂度也为 O(h),效率明显比 B-Tree 差很多。
上文还说过,B+Tree 更适合外存索引,原因和内节点出度 d 有关。从上面分析可以看到,d 越大索引的性能越好,而出度的上限取决于节点内 key 和 data 的大小:
$$d_{max}= floor\left(pagesize/\left(keysize+datasize+pointsize\right)\right)$$
floor 表示向下取整。由于 B+Tree 内节点去掉了 data 域,因此可以拥有更大的出度,拥有更好的性能。
这一章从理论角度讨论了与索引相关的数据结构与算法问题,下一章将讨论 B+Tree 是如何具体实现为 MySQL 中索引,同时将结合 MyISAM 和 InnDB 存储引擎介绍非聚集索引和聚集索引两种不同的索引实现形式。
在 MySQL 中,索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的,本文主要讨论 MyISAM 和 InnoDB 两个存储引擎的索引实现方式。
MyISAM 引擎使用 B+Tree 作为索引结构,叶节点的 data 域存放的是数据记录的地址。下图是 MyISAM 索引的原理图:
这里设表一共有三列,假设我们以 Col1 为主键,则图 8 是一个 MyISAM 表的主索引(Primary key)示意。可以看出 MyISAM 的索引文件仅仅保存数据记录的地址。在 MyISAM 中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求 key 是唯一的,而辅助索引的 key 可以重复。如果我们在 Col2 上建立一个辅助索引,则此索引的结构如下图所示:
同样也是一颗 B+Tree,data 域保存数据记录的地址。因此,MyISAM 中索引检索的算法为首先按照 B+Tree 搜索算法搜索索引,如果指定的 Key 存在,则取出其 data 域的值,然后以 data 域的值为地址,读取相应数据记录。
MyISAM 的索引方式也叫做“非聚集”的,之所以这么称呼是为了与 InnoDB 的聚集索引区分。
虽然 InnoDB 也使用 B+Tree 作为索引结构,但具体实现方式却与 MyISAM 截然不同。
第一个重大区别是 InnoDB 的数据文件本身就是索引文件。从上文知道,MyISAM 索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在 InnoDB 中,表数据文件本身就是按 B+Tree 组织的一个索引结构,这棵树的叶节点 data 域保存了完整的数据记录。这个索引的 key 是数据表的主键,因此 InnoDB 表数据文件本身就是主索引。
图 10 是 InnoDB 主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录。这种索引叫做聚集索引。因为 InnoDB 的数据文件本身要按主键聚集,所以 InnoDB 要求表必须有主键(MyISAM 可以没有),如果没有显式指定,则 MySQL 系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则 MySQL 自动为 InnoDB 表生成一个隐含字段作为主键,这个字段长度为 6 个字节,类型为长整形。
第二个与 MyISAM 索引的不同是 InnoDB 的辅助索引 data 域存储相应记录主键的值而不是地址。换句话说,InnoDB 的所有辅助索引都引用主键作为 data 域。例如,图 11 为定义在 Col3 上的一个辅助索引:
这里以英文字符的 ASCII 码作为比较准则。聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。
了解不同存储引擎的索引实现方式对于正确使用和优化索引都非常有帮助,例如知道了 InnoDB 的索引实现后,就很容易明白为什么不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。再例如,用非单调的字段作为主键在 InnoDB 中不是个好主意,因为 InnoDB 数据文件本身是一颗 B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持 B+Tree 的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。
下一章将具体讨论这些与索引有关的优化策略。
MySQL 的优化主要分为结构优化(Scheme optimization)和查询优化(Query optimization)。本章讨论的高性能索引策略主要属于结构优化范畴。本章的内容完全基于上文的理论基础,实际上一旦理解了索引背后的机制,那么选择高性能的策略就变成了纯粹的推理,并且可以理解这些策略背后的逻辑。
为了讨论索引策略,需要一个数据量不算小的数据库作为示例。本文选用 MySQL 官方文档中提供的示例数据库之一:employees。这个数据库关系复杂度适中,且数据量较大。下图是这个数据库的 E-R 关系图(引用自 MySQL 官方手册):
MySQL 官方文档中关于此数据库的页面为 http://dev.mysql.com/doc/employee/en/employee.html。 里面详细介绍了此数据库,并提供了下载地址和导入方法,如果有兴趣导入此数据库到自己的 MySQL 可以参考文中内容。
高效使用索引的首要条件是知道什么样的查询会使用到索引,这个问题和 B+Tree 中的“最左前缀原理”有关,下面通过例子说明最左前缀原理。
这里先说一下联合索引的概念。在上文中,我们都是假设索引只引用了单个的列,实际上,MySQL 中的索引可以以一定顺序引用多个列,这种索引叫做联合索引,一般的,一个联合索引是一个有序元组 <a1, a2, …, an>,其中各个元素均为数据表的一列,实际上要严格定义索引需要用到关系代数,但是这里我不想讨论太多关系代数的话题,因为那样会显得很枯燥,所以这里就不再做严格定义。另外,单列索引可以看成联合索引元素数为 1 的特例。
以 employees.titles 表为例,下面先查看其上都有哪些索引:
SHOW INDEX FROM employees.titles; |
从结果中可以到 titles 表的主索引为 <emp_no, title, from_date>,还有一个辅助索引
ALTER TABLE employees.titles DROP INDEX emp_no; |
这样就可以专心分析索引 PRIMARY 的行为了。
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title='Senior Engineer' AND from_date='1986-06-26'; |
很明显,当按照索引中所有列进行精确匹配(这里精确匹配指“=”或“IN”匹配)时,索引可以被用到。这里有一点需要注意,理论上索引对顺序是敏感的,但是由于 MySQL 的查询优化器会自动调整 where 子句的条件顺序以使用适合的索引,例如我们将 where 中的条件顺序颠倒:
EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26' AND emp_no='10001' AND title='Senior Engineer'; |
效果是一样的。
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001'; |
当查询条件精确匹配索引的左边连续一个或几个列时,如<emp_no>
或<emp_no, title>
,所以可以被用到,但是只能用到一部分,即条件所组成的最左前缀。上面的查询从分析结果看用到了 PRIMARY 索引,但是 key_len 为 4,说明只用到了索引的第一列前缀。
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26'; |
此时索引使用情况和情况二相同,因为 title 未提供,所以查询只用到了索引的第一列,而后面的 from_date 虽然也在索引中,但是由于 title 不存在而无法和左前缀连接,因此需要对结果进行扫描过滤 from_date(这里由于 emp_no 唯一,所以不存在扫描)。如果想让 from_date 也使用索引而不是 where 过滤,可以增加一个辅助索引 <emp_no, from_date>,此时上面的查询会使用这个索引。除此之外,还可以使用一种称之为“隔离列”的优化方法,将 emp_no 与 from_date 之间的“坑”填上。
首先我们看下 title 一共有几种不同的值:
SELECT DISTINCT(title) FROM employees.titles; |
只有 7 种。在这种成为“坑”的列值比较少的情况下,可以考虑用“IN”来填补这个“坑”从而形成最左前缀:
EXPLAIN SELECT * FROM employees.titlesWHERE emp_no='10001'AND title IN ('Senior Engineer', 'Staff', 'Engineer', 'Senior Staff', 'Assistant Engineer', 'Technique Leader', 'Manager')AND from_date='1986-06-26'; |
这次 key_len 为 59,说明索引被用全了,但是从 type 和 rows 看出 IN 实际上执行了一个 range 查询,这里检查了 7 个 key。看下两种查询的性能比较:
SHOW PROFILES; |
“填坑”后性能提升了一点。如果经过 emp_no 筛选后余下很多数据,则后者性能优势会更加明显。当然,如果 title 的值很多,用填坑就不合适了,必须建立辅助索引。
EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26'; |
由于不是最左前缀,索引这样的查询显然用不到索引。
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title LIKE 'Senior%'; |
此时可以用到索引,但是如果通配符不是只出现在末尾,则无法使用索引。(原文表述有误,如果通配符 % 不出现在开头,则可以用到索引,但根据具体情况不同可能只会用其中一个前缀)
EXPLAIN SELECT * FROM employees.titles WHERE emp_no < '10010' and title='Senior Engineer'; |
范围列可以用到索引(必须是最左前缀),但是范围列后面的列无法用到索引。同时,索引最多用于一个范围列,因此如果查询条件中有两个范围列则无法全用到索引。
EXPLAIN SELECT * FROM employees.titlesWHERE emp_no < '10010'AND title='Senior Engineer'AND from_date BETWEEN '1986-01-01' AND '1986-12-31'; |
可以看到索引对第二个范围索引无能为力。这里特别要说明 MySQL 一个有意思的地方,那就是仅用 explain 可能无法区分范围索引和多值匹配,因为在 type 中这两者都显示为 range。同时,用了“between”并不意味着就是范围查询,例如下面的查询:
EXPLAIN SELECT * FROM employees.titlesWHERE emp_no BETWEEN '10001' AND '10010'AND title='Senior Engineer'AND from_date BETWEEN '1986-01-01' AND '1986-12-31'; |
看起来是用了两个范围查询,但作用于 emp_no 上的“BETWEEN”实际上相当于“IN”,也就是说 emp_no 实际是多值精确匹配。可以看到这个查询用到了索引全部三个列。因此在 MySQL 中要谨慎地区分多值匹配和范围匹配,否则会对 MySQL 的行为产生困惑。
很不幸,如果查询条件中含有函数或表达式,则 MySQL 不会为这列使用索引(虽然某些在数学意义上可以使用)。例如:
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND left(title, 6)='Senior'; |
虽然这个查询和情况五中功能相同,但是由于使用了函数 left,则无法为 title 列应用索引,而情况五中用 LIKE 则可以。再如:
EXPLAIN SELECT * FROM employees.titles WHERE emp_no - 1='10000'; |
显然这个查询等价于查询 emp_no 为 10001 的函数,但是由于查询条件是一个表达式,MySQL 无法为其使用索引。看来 MySQL 还没有智能到自动优化常量表达式的程度,因此在写查询语句时尽量避免表达式出现在查询中,而是先手工私下代数运算,转换为无表达式的查询语句。
既然索引可以加快查询速度,那么是不是只要是查询语句需要,就建上索引?答案是否定的。因为索引虽然加快了查询速度,但索引也是有代价的:索引文件本身要消耗存储空间,同时索引会加重插入、删除和修改记录时的负担,另外,MySQL 在运行时也要消耗资源维护索引,因此索引并不是越多越好。一般两种情况下不建议建索引。
第一种情况是表记录比较少,例如一两千条甚至只有几百条记录的表,没必要建索引,让查询做全表扫描就好了。至于多少条记录才算多,这个个人有个人的看法,我个人的经验是以 2000 作为分界线,记录数不超过 2000 可以考虑不建索引,超过 2000 条可以酌情考虑索引。
另一种不建议建索引的情况是索引的选择性较低。所谓索引的选择性(Selectivity),是指不重复的索引值(也叫基数,Cardinality)与表记录数(#T)的比值:
Index Selectivity = Cardinality / #T
显然选择性的取值范围为 (0, 1],选择性越高的索引价值越大,这是由 B+Tree 的性质决定的。例如,上文用到的 employees.titles 表,如果 title 字段经常被单独查询,是否需要建索引,我们看一下它的选择性:
SELECT count(DISTINCT(title))/count(*) AS Selectivity FROM employees.titles; |
title 的选择性不足 0.0001(精确值为0.00001579),所以实在没有什么必要为其单独建索引。
有一种与索引选择性有关的索引优化策略叫做前缀索引,就是用列的前缀代替整个列作为索引 key,当前缀长度合适时,可以做到既使得前缀索引的选择性接近全列索引,同时因为索引 key 变短而减少了索引文件的大小和维护开销。下面以 employees.employees 表为例介绍前缀索引的选择和使用。
从图 12 可以看到 employees 表只有一个索引
EXPLAIN SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'; |
如果频繁按名字搜索员工,这样显然效率很低,因此我们可以考虑建索引。有两种选择,建
SELECT count(DISTINCT(first_name))/count(*) AS Selectivity FROM employees.employees; |
SELECT count(DISTINCT(concat(first_name, left(last_name, 3))))/count(*) AS Selectivity FROM employees.employees; |
选择性还不错,但离 0.9313 还是有点距离,那么把 last_name 前缀加到 4:
SELECT count(DISTINCT(concat(first_name, left(last_name, 4))))/count(*) AS Selectivity FROM employees.employees; |
这时选择性已经很理想了,而这个索引的长度只有 18,比 <first_name, last_name> 短了接近一半,我们把这个前缀索引建上:
ALTER TABLE employees.employeesADD INDEX `first_name_last_name4` (first_name, last_name(4)); |
此时再执行一遍按名字查询,比较分析一下与建索引前的结果:
SHOW PROFILES; |
性能的提升是显著的,查询速度提高了 120 多倍。
前缀索引兼顾索引大小和查询速度,但是其缺点是不能用于 ORDER BY 和 GROUP BY 操作,也不能用于 Covering index(即当索引本身包含查询所需全部数据时,不再访问数据文件本身)。
在使用 InnoDB 存储引擎时,如果没有特别的需要,请永远使用一个与业务无关的自增字段作为主键。
经常看到有帖子或博客讨论主键选择问题,有人建议使用业务无关的自增主键,有人觉得没有必要,完全可以使用如学号或身份证号这种唯一字段作为主键。不论支持哪种论点,大多数论据都是业务层面的。如果从数据库索引优化角度看,使用 InnoDB 引擎而不使用自增主键绝对是一个糟糕的主意。
上文讨论过 InnoDB 的索引实现,InnoDB 使用聚集索引,数据记录本身被存于主索引(一颗B+Tree)的叶子节点上。这就要求同一个叶子节点内(大小为一个内存页或磁盘页)的各条数据记录按主键顺序存放,因此每当有一条新的记录插入时,MySQL 会根据其主键将其插入适当的节点和位置,如果页面达到装载因子(InnoDB 默认为 15/16),则开辟一个新的页(节点)。
如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。如下图所示:
这样就会形成一个紧凑的索引结构,近似顺序填满。由于每次插入时也不需要移动已有数据,因此效率很高,也不会增加很多开销在维护索引上。
如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置:
此时 MySQL 不得不为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过 OPTIMIZE TABLE 来重建表并优化填充页面。
因此,只要可以,请尽量在 InnoDB 上采用自增字段做主键。
这篇文章断断续续写了半个月,主要内容就是上面这些了。不可否认,这篇文章在一定程度上有纸上谈兵之嫌,因为我本人对 MySQL 的使用属于菜鸟级别,更没有太多数据库调优的经验,在这里大谈数据库索引调优有点大言不惭。就当是我个人的一篇学习笔记了。
其实数据库索引调优是一项技术活,不能仅仅靠理论,因为实际情况千变万化,而且 MySQL 本身存在很复杂的机制,如查询优化策略和各种引擎的实现差异等都会使情况变得更加复杂。但同时这些理论是索引调优的基础,只有在明白理论的基础上,才能对调优策略进行合理推断并了解其背后的机制,然后结合实践中不断的实验和摸索,从而真正达到高效使用 MySQL 索引的目的。
另外,MySQL 索引及其优化涵盖范围非常广,本文只是涉及到其中一部分。如与排序(ORDER BY)相关的索引优化及覆盖索引(Covering index)的话题本文并未涉及,同时除 B-Tree 索引外 MySQL 还根据不同引擎支持的哈希索引、全文索引等等本文也并未涉及。如果有机会,希望再对本文未涉及的部分进行补充吧。
[1] Baron Scbwartz等 著,王小东等 译;高性能MySQL(High Performance MySQL);电子工业出版社,2010
[2] Michael Kofler 著,杨晓云等 译;MySQL5权威指南(The Definitive Guide to MySQL5);人民邮电出版社,2006
[3] 姜承尧 著;MySQL技术内幕-InnoDB存储引擎;机械工业出版社,2011
[4] D Comer, Ubiquitous B-tree; ACM Computing Surveys (CSUR), 1979
[5] Codd, E. F. (1970). “A relational model of data for large shared data banks”. Communications of the ACM, , Vol. 13, No. 6, pp. 377-387
[6] MySQL5.1参考手册 - http://dev.mysql.com/doc/refman/5.1/zh/index.html