苹果手游代充灰色产业深度揭秘

[转]http://www.freebuf.com/articles/paper/157719.html?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io

苹果手游代充最早可以追溯到2012年前后,到现在已经经历了多次发展,从最开始的外币汇率差,退款,36漏洞,再到现在黑卡,盗刷信用卡,甚至出现了专门的库存系统。据圈内人士透露,许多人靠这个赚了成百上千万,然而游戏厂商却损失惨重,苹果一直也没彻底打击掉这些乱象。本文从目前最新的库存系统说起,深度揭秘整个产业链。

一、 一切罪恶的源泉——苹果内购机制

代充行业之所以可以持续这么久,根本原因就是苹果内购机制的问题,要是做到支付宝一样就没问题了。所以内购机制一日不改,代充就不会消失,但是苹果爸爸会改吗?可能性很小,其牵扯的利益太多。这个机制直接导致了外币绕过漏洞,36小额漏洞,凭据替换漏洞。

二、 行业的巅峰——库存系统

某游戏厂商朋友问我,“36技术不是被苹果禁止了吗,游戏也做禁止外币了,为什么还是能收到外币,这个月的坏账又是一千多万,现在要怎么预防”?其实,道高一尺魔高一丈 ,聪明的充值商家早就有对应的办法了。

库存系统,顾名思义,就是存货的仓库。

苹果内购流程如下图所示,我们可以看到,第8步,苹果在用户AppStore帐号里面正常扣款后,会返回一个凭据给游戏,游戏收到凭据后,再到苹果校验,无误就发放道具。然而,库存系统会在苹果返回凭据给游戏的时候,把这个凭据拦截并保存起来,不让游戏现在发放道具。同样的道理,用技术手段先模拟1-7步的流程,到8的时候,把保存的凭据发送给游戏程序,游戏收到凭据后,正常发货,这样就实现了凭据的使用。

苹果手游代充灰色产业深度揭秘

据圈内人士的描述,库存系统是这样的,首先有两个插件(要系统越狱才可以用),一个专门用的存货(凭据拦截),另外一个用来发货(凭据替换),货是存在系统后台的,有玩家要充值的时候再使用,非常方便。现在市面上有4-5家这样的系统,价格每个月几千到上万不等。

下图可以看到,这类系统几乎支持所有苹果的应用程序

苹果手游代充灰色产业深度揭秘

甚至还出现了交易系统,商家可以自由买卖凭证,里面的价格很低

苹果手游代充灰色产业深度揭秘

说道这里,大家肯定会有一个疑问,我直接用苹果帐号里面的钱不就可以了,为什么要把凭据拦截保存起来,再使用,不是多此一举吗?

其实,库存系统最大的作用是绕过苹果帐号的风控系统。代充商家的价格之所以比原价低很多,主要因为用的都是黑卡(非法渠道获得的低价苹果礼品卡)或盗刷信用卡这些非法的方式,但是苹果的风控系统检测到这些非法行为,会在很短的时间内进行封号,这样苹果帐号里面的钱就打水漂了。然而,苹果的封号不是秒封的,这里有个时间差,比如15分钟,但是充值商家要在这么短的时间内把帐号里面的钱用完难度也很大。这时用的库存系统就可以秒把苹果帐号里面的钱用完,以凭据的形式保存在系统里面,随时可以用。即使苹果封号了,也不影响,凭据还是可以用,苹果不会封凭据!这样便能绕过苹果风控系统,实现完美套现,但是这些黑钱会被苹果扣除,无法给游戏厂商结算,最后就成了坏账。

总结下,库存系统保存的就是苹果的消费凭据,充值商家等到有客户时候,可以随时使用,可谓完美绕过苹果风控,使黑卡和盗刷可以大规模实现,让供货和销售分开,降低了行业进入的门槛,更加细分了产业链,放大了黑卡和盗刷的影响。库存系统还能绕过大多数游戏的外币检测,甚至充值游戏里已经下架的面值,比如之前某款游戏已经下架了30的面值,库存系统用技术手段依然可以充,让游戏厂商深受其害。

三、 现阶段游戏厂商如何应对

据圈内人士透露,现在网易游戏是查的最严格的,发现代充就会扣回游戏道具,圈内几乎没人敢做,但是,据了解网易在这方面也是下了血本,一般小团队也无法企及。这里有几个简单建议,可以把损失最小化。

1. 针对库存系统:正常充值的玩家,苹果凭据生成的时间和游戏内下订单的时间不会差很多,但是如果用的库存系统,这个时间差必然会大很多,这样基本就可以确定是代充了。

2. 对于外币的问题:苹果已经提供的相关功能,可以关闭外币充值,库存系统也无法绕过,只要开启即可。或者通过苹果票据里面的信息也可以判断。

3. 游戏自身风控:早期苹果充值非常不方便,代充可能还能帮游戏增加一些用户。但是现在苹果已经支持微信和支付宝,这方面完全不用担心,一定要坚决打击。方法一般通过检测低版本越狱系统充值,ip登录异常等。

4. 发现淘宝有代充商家,可以向淘宝发律师函等手段令其下架,并对玩家到官方渠道充值进行引导,营造良好的游戏环境。

[转]PHP7下的协程实现

转:https://segmentfault.com/a/1190000012457145?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io

相信大家都听说过『协程』这个概念吧。

但是有些同学对这个概念似懂非懂,不知道怎么实现,怎么用,用在哪,甚至有些人认为yield就是协程!

我始终相信,如果你无法准确地表达出一个知识点的话,我可以认为你就是不懂。

如果你之前了解过利用PHP实现协程的话,你肯定看过鸟哥的那篇文章:在PHP中使用协程实现多任务调度| 风雪之隅

鸟哥这篇文章是从国外的作者翻译来的,翻译的简洁明了,也给出了具体的例子了。

我写这篇文章的目的,是想对鸟哥文章做更加充足的补充,毕竟有部分同学的基础还是不够好,看得也是云头雾里的。

我个人,不喜欢写长篇文章,微博关注我 @码云 ,每天用微博分享知识。文章同时记录在我的博客:https://bruceit.com/p/A4kSfE

什么是协程

先搞清楚,什么是协程。

你可能已经听过『进程』和『线程』这两个概念。

进程就是二进制可执行文件在计算机内存里的一个运行实例,就好比你的.exe文件是个类,进程就是new出来的那个实例。

进程是计算机系统进行资源分配和调度的基本单位(调度单位这里别纠结线程进程的),每个CPU下同一时刻只能处理一个进程。

所谓的并行,只不过是看起来并行,CPU事实上在用很快的速度切换不同的进程。

进程的切换需要进行系统调用,CPU要保存当前进程的各个信息,同时还会使CPUCache被废掉。

所以进程切换不到非不得已就不做。

那么怎么实现『进程切换不到非不得已就不做』呢?

首先进程被切换的条件是:进程执行完毕、分配给进程的CPU时间片结束,系统发生中断需要处理,或者进程等待必要的资源(进程阻塞)等。你想下,前面几种情况自然没有什么话可说,但是如果是在阻塞等待,是不是就浪费了。

其实阻塞的话我们的程序还有其他可执行的地方可以执行,不一定要傻傻的等!

所以就有了线程。

线程简单理解就是一个『微进程』,专门跑一个函数(逻辑流)。

所以我们就可以在编写程序的过程中将可以同时运行的函数用线程来体现了。

线程有两种类型,一种是由内核来管理和调度。

我们说,只要涉及需要内核参与管理调度的,代价都是很大的。这种线程其实也就解决了当一个进程中,某个正在执行的线程遇到阻塞,我们可以调度另外一个可运行的线程来跑,但是还是在同一个进程里,所以没有了进程切换。

还有另外一种线程,他的调度是由程序员自己写程序来管理的,对内核来说不可见。这种线程叫做『用户空间线程』。

协程可以理解就是一种用户空间线程。

协程,有几个特点:

  • 协同,因为是由程序员自己写的调度策略,其通过协作而不是抢占来进行切换
  • 在用户态完成创建,切换和销毁
  • ⚠️ 从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
  • generator经常用来实现协程

说到这里,你应该明白协程的基本概念了吧?

PHP实现协程

一步一步来,从解释概念说起!

可迭代对象

PHP5提供了一种定义对象的方法使其可以通过单元列表来遍历,例如用foreach语句。

你如果要实现一个可迭代对象,你就要实现Iterator接口:

<?php
class MyIterator implements Iterator
{
    private $var = array();

    public function __construct($array)
    {
        if (is_array($array)) {
            $this->var = $array;
        }
    }

    public function rewind() {
        echo "rewinding\n";
        reset($this->var);
    }

    public function current() {
        $var = current($this->var);
        echo "current: $var\n";
        return $var;
    }

    public function key() {
        $var = key($this->var);
        echo "key: $var\n";
        return $var;
    }

    public function next() {
        $var = next($this->var);
        echo "next: $var\n";
        return $var;
    }

    public function valid() {
        $var = $this->current() !== false;
        echo "valid: {$var}\n";
        return $var;
    }
}

$values = array(1,2,3);
$it = new MyIterator($values);

foreach ($it as $a => $b) {
    print "$a: $b\n";
}

生成器

可以说之前为了拥有一个能够被foreach遍历的对象,你不得不去实现一堆的方法,yield关键字就是为了简化这个过程。

生成器提供了一种更容易的方法来实现简单的对象迭代,相比较定义类实现Iterator接口的方式,性能开销和复杂性大大降低。

<?php
function xrange($start, $end, $step = 1) {
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}
 
foreach (xrange(1, 1000000) as $num) {
    echo $num, "\n";
}

记住,一个函数中如果用了yield,他就是一个生成器,直接调用他是没有用的,不能等同于一个函数那样去执行!

所以,yield就是yield,下次谁再说yield是协程,我肯定把你xxxx。

PHP协程

前面介绍协程的时候说了,协程需要程序员自己去编写调度机制,下面我们来看这个机制怎么写。

0)生成器正确使用

既然生成器不能像函数一样直接调用,那么怎么才能调用呢?

方法如下:

  1. foreach他
  2. send($value)
  3. current / next…

1)Task实现

Task就是一个任务的抽象,刚刚我们说了协程就是用户空间协程,线程可以理解就是跑一个函数。

所以Task的构造函数中就是接收一个闭包函数,我们命名为coroutine

/**
 * Task任务类
 */
class Task
{
    protected $taskId;
    protected $coroutine;
    protected $beforeFirstYield = true;
    protected $sendValue;

    /**
     * Task constructor.
     * @param $taskId
     * @param Generator $coroutine
     */
    public function __construct($taskId, Generator $coroutine)
    {
        $this->taskId = $taskId;
        $this->coroutine = $coroutine;
    }

    /**
     * 获取当前的Task的ID
     * 
     * @return mixed
     */
    public function getTaskId()
    {
        return $this->taskId;
    }

    /**
     * 判断Task执行完毕了没有
     * 
     * @return bool
     */
    public function isFinished()
    {
        return !$this->coroutine->valid();
    }

    /**
     * 设置下次要传给协程的值,比如 $id = (yield $xxxx),这个值就给了$id了
     * 
     * @param $value
     */
    public function setSendValue($value)
    {
        $this->sendValue = $value;
    }

    /**
     * 运行任务
     * 
     * @return mixed
     */
    public function run()
    {
        // 这里要注意,生成器的开始会reset,所以第一个值要用current获取
        if ($this->beforeFirstYield) {
            $this->beforeFirstYield = false;
            return $this->coroutine->current();
        } else {
            // 我们说过了,用send去调用一个生成器
            $retval = $this->coroutine->send($this->sendValue);
            $this->sendValue = null;
            return $retval;
        }
    }
}

2)Scheduler实现

接下来就是Scheduler这个重点核心部分,他扮演着调度员的角色。

/**
 * Class Scheduler
 */
Class Scheduler
{
    /**
     * @var SplQueue
     */
    protected $taskQueue;
    /**
     * @var int
     */
    protected $tid = 0;

    /**
     * Scheduler constructor.
     */
    public function __construct()
    {
        /* 原理就是维护了一个队列,
         * 前面说过,从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
         * */
        $this->taskQueue = new SplQueue();
    }

    /**
     * 增加一个任务
     *
     * @param Generator $task
     * @return int
     */
    public function addTask(Generator $task)
    {
        $tid = $this->tid;
        $task = new Task($tid, $task);
        $this->taskQueue->enqueue($task);
        $this->tid++;
        return $tid;
    }

    /**
     * 把任务进入队列
     *
     * @param Task $task
     */
    public function schedule(Task $task)
    {
        $this->taskQueue->enqueue($task);
    }

    /**
     * 运行调度器
     */
    public function run()
    {
        while (!$this->taskQueue->isEmpty()) {
            // 任务出队
            $task = $this->taskQueue->dequeue();
            $res = $task->run(); // 运行任务直到 yield

            if (!$task->isFinished()) {
                $this->schedule($task); // 任务如果还没完全执行完毕,入队等下次执行
            }
        }
    }
}

这样我们基本就实现了一个协程调度器。

你可以使用下面的代码来测试:

<?php
function task1() {
    for ($i = 1; $i <= 10; ++$i) {
        echo "This is task 1 iteration $i.\n";
        yield; // 主动让出CPU的执行权
    }
}
 
function task2() {
    for ($i = 1; $i <= 5; ++$i) {
        echo "This is task 2 iteration $i.\n";
        yield; // 主动让出CPU的执行权
    }
}
 
$scheduler = new Scheduler; // 实例化一个调度器
$scheduler->addTask(task1()); // 添加不同的闭包函数作为任务
$scheduler->addTask(task2());
$scheduler->run();

关键说下在哪里能用得到PHP协程。

function task1() {
        /* 这里有一个远程任务,需要耗时10s,可能是一个远程机器抓取分析远程网址的任务,我们只要提交最后去远程机器拿结果就行了 */
        remote_task_commit();
        // 这时候请求发出后,我们不要在这里等,主动让出CPU的执行权给task2运行,他不依赖这个结果
        yield;
        yield (remote_task_receive());
        ...
}
 
function task2() {
    for ($i = 1; $i <= 5; ++$i) {
        echo "This is task 2 iteration $i.\n";
        yield; // 主动让出CPU的执行权
    }
}

这样就提高了程序的执行效率。

关于『系统调用』的实现,鸟哥已经讲得很明白,我这里不再说明。

3)协程堆栈

鸟哥文中还有一个协程堆栈的例子。

我们上面说过了,如果在函数中使用了yield,就不能当做函数使用。

所以你在一个协程函数中嵌套另外一个协程函数:

<?php
function echoTimes($msg, $max) {
    for ($i = 1; $i <= $max; ++$i) {
        echo "$msg iteration $i\n";
        yield;
    }
}
 
function task() {
    echoTimes('foo', 10); // print foo ten times
    echo "---\n";
    echoTimes('bar', 5); // print bar five times
    yield; // force it to be a coroutine
}
 
$scheduler = new Scheduler;
$scheduler->addTask(task());
$scheduler->run();

这里的echoTimes是执行不了的!所以就需要协程堆栈。

不过没关系,我们改一改我们刚刚的代码。

把Task中的初始化方法改下,因为我们在运行一个Task的时候,我们要分析出他包含了哪些子协程,然后将子协程用一个堆栈保存。(C语言学的好的同学自然能理解这里,不理解的同学我建议去了解下进程的内存模型是怎么处理函数调用)

 /**
     * Task constructor.
     * @param $taskId
     * @param Generator $coroutine
     */
    public function __construct($taskId, Generator $coroutine)
    {
        $this->taskId = $taskId;
        // $this->coroutine = $coroutine;
        // 换成这个,实际Task->run的就是stackedCoroutine这个函数,不是$coroutine保存的闭包函数了
        $this->coroutine = stackedCoroutine($coroutine); 
    }

当Task->run()的时候,一个循环来分析:

/**
 * @param Generator $gen
 */
function stackedCoroutine(Generator $gen)
{
    $stack = new SplStack;

    // 不断遍历这个传进来的生成器
    for (; ;) {
        // $gen可以理解为指向当前运行的协程闭包函数(生成器)
        $value = $gen->current(); // 获取中断点,也就是yield出来的值

        if ($value instanceof Generator) {
            // 如果是也是一个生成器,这就是子协程了,把当前运行的协程入栈保存
            $stack->push($gen);
            $gen = $value; // 把子协程函数给gen,继续执行,注意接下来就是执行子协程的流程了
            continue;
        }

        // 我们对子协程返回的结果做了封装,下面讲
        $isReturnValue = $value instanceof CoroutineReturnValue; // 子协程返回`$value`需要主协程帮忙处理
        
        if (!$gen->valid() || $isReturnValue) {
            if ($stack->isEmpty()) {
                return;
            }
            // 如果是gen已经执行完毕,或者遇到子协程需要返回值给主协程去处理
            $gen = $stack->pop(); //出栈,得到之前入栈保存的主协程
            $gen->send($isReturnValue ? $value->getValue() : NULL); // 调用主协程处理子协程的输出值
            continue;
        }

        $gen->send(yield $gen->key() => $value); // 继续执行子协程
    }
}

然后我们增加echoTime的结束标示:

class CoroutineReturnValue {
    protected $value;
 
    public function __construct($value) {
        $this->value = $value;
    }
     
    // 获取能把子协程的输出值给主协程,作为主协程的send参数
    public function getValue() {
        return $this->value;
    }
}

function retval($value) {
    return new CoroutineReturnValue($value);
}

然后修改echoTimes

function echoTimes($msg, $max) {
    for ($i = 1; $i <= $max; ++$i) {
        echo "$msg iteration $i\n";
        yield;
    }
    yield retval("");  // 增加这个作为结束标示
}

Task变为:

function task1()
{
    yield echoTimes('bar', 5);
}

这样就实现了一个协程堆栈,现在你可以举一反三了。

4)PHP7中yield from关键字

PHP7中增加了yield from,所以我们不需要自己实现携程堆栈,真是太好了。

把Task的构造函数改回去:

    public function __construct($taskId, Generator $coroutine)
    {
        $this->taskId = $taskId;
        $this->coroutine = $coroutine;
        // $this->coroutine = stackedCoroutine($coroutine); //不需要自己实现了,改回之前的
    }

echoTimes函数:

function echoTimes($msg, $max) {
    for ($i = 1; $i <= $max; ++$i) {
        echo "$msg iteration $i\n";
        yield;
    }
}

task1生成器:

function task1()
{
    yield from echoTimes('bar', 5);
}

这样,轻松调用子协程

[转]饿了么异地多活技术实现(一)总体介绍

饿了么技术团队花了1年多的时间,实现了业务的整体异地多活,能够灵活的在多个异地机房之间调度用户,实现了自由扩容和多机房容灾的目标。本文介绍这个项目的整体结构,还简要介绍实现多活的5大核心基础组件,为读者建立基本的概念模型,后续会有系列文章陆续介绍每个组件的实现细节。读者能够从中了解到做异地多活的大方向,为实现自己的异地多活,或者是容灾备份提供参考。

背景:为什么要做异地多活?

饿了么要做多活,是受业务发展的驱动,经过几年的高速发展,我们的业务已经扩大到单个数据中心撑不住了,主要机房已经不能再加机器,业务却不断的要求加扩容,所以我们需要一个方案能够把服务器部署到多个机房。另外一个更重要的原因是,整个机房级别的故障时有发生,每次都会带来严重的后果,我们需要在发生故障时,能够把一个机房的业务全部迁移到别的机房,保证服务可用。

归纳起来,我们要达到两个目标:

  1. 服务可以扩展到多个机房
  2. 能够应对整个机房级别的故障

解决这两个问题的常见办法是做异地多活,把服务分散到多个机房,自然扩展和高可用的问题就迎刃而解了。

饿了么有自己的特殊情况,对于其他大部分公司来说,其实要不要做多活,主要就是看下图这样一个曲线。

对于一个业务快速增长的企业,每次故障带来的损失也相应是加速增长的,而技术的投入总体上是线性的,初期故障损失小于技术投入,在某个时间点,故障的损失会超过技术投入,这时就要用一些高可用方案,来避免故障,多活就是其中最重要的一种。

异地多活面临的主要挑战是网络延迟,以北京到上海 1468 公里,即使是光速传输,一个来回也需要接近10ms,我们在实际测试的过程中,发现上海到北京的网络延迟,一般是 30 ms。这 30 ms可以和运算系统中其他的延迟时间做个比较:

L1 cache reference ……………………. 0.5 ns

Branch mispredict ………………………. 5 ns

L2 cache reference ……………………… 7 ns

Mutex lock/unlock ……………………… 25 ns

Main memory reference …………………. 100 ns

Compress 1K bytes with Zippy …………. 3,000 ns = 3 µs

Send 2K bytes over 1 Gbps network ……. 20,000 ns = 20 µs

SSD random read …………………… 150,000 ns = 150 µs

Read 1 MB sequentially from memory ….. 250,000 ns = 250 µs

Round trip within same datacenter …… 500,000 ns = 0.5 ms

Read 1 MB sequentially from SSD* ….. 1,000,000 ns = 1 ms

上海之间两个机房的网络延时…………….. 1,000,000 ns = 1 ms

Disk seek ……………………… 10,000,000 ns = 10 ms

Read 1 MB sequentially from disk …. 20,000,000 ns = 20 ms

北京到上海的网络延时……………….. 30,000,000 ns = 30 ms

Send packet CA->Netherlands->CA …. 150,000,000 ns = 150 ms

北京上海两地的网络延迟时间,大致是内网网络访问速度的 60 倍(30ms/0.5ms),如果不做任何改造,一方直接访问另外一方的服务,那么我们的APP的反应会比原来慢 60 倍,其实考虑上多次往返,可能会慢600倍。

如果机房都在上海,那么网络延迟只有内网速度的2倍,可以当成一个机房使用。所有有些公司的多活方案,会选择同城机房,把同城的几个机房当成一个机房部署,可以在不影响服务架构的情况下扩展出多个机房,不失为一个快速见效的方法。我们在做多活的初期也讨论过同城方案,比如在北京周边建设一个新机房,迁移部分服务到新机房,两个机房专线连接,服务间做跨机房调用。虽然这个方案比较容易,也解决了机房的扩展问题,但是对高可用却没有好处,相反还带来了更高的风险。

与同城多活的方案不同,异地多活的方案会限制机房间的相互调用,需要定义清晰的服务边界,减少相互依赖,让每个机房都成为独立的单元,不依赖于其他机房。经过几番考量,我们最终选择了异地多活的方案,对这两个方案的比较和思考可以见下表,异地多活虽然更困难一点,但是能同时达到我们的两个核心目标,更为可行。

设计:异地多活的实现思路和方法

我们的异地多活方案的,有几条基本原则,整个多活方案都是这些原则的自然推导。但在介绍一下这些原则之前,先要说明一下饿了么的服务流程,才能让大家更好的理解这些原则的来由

下面这张简图是我们的主流程:

业务过程中包含3个最重要的角色,分别是用户、商家和骑手,一个订单包含3个步骤:

  1. 用户打开我们的APP,系统会推荐出用户位置附近的各种美食,推荐顺序中结合了用户习惯,推荐排序,商户的推广等。用户找到中意的食物 ,下单并支付,订单会流转到商家。
  2. 商家接单并开始制作食物,制作完成后,系统调度骑手赶到店面,取走食物
  3. 骑手按照配送地址,把食物送到客户手中。

整个下单到配送完成,有严格的时间要求,必须在短短的几十分钟内完成,我们的服务和地理位置强相关,并且实时性要求高,服务的地域性和实时性是我们的核心特性,多活设计最重要的是满足这两个特性。

进过反复讨论,我们的多活架构通过遵循以下几条基本原则,来满足这两个核心特性:

  1. 业务内聚:单个订单的旅单过程,要在一个机房中完成,不允许跨机房调用。这个原则是为了保证实时性,旅单过程中不依赖另外一个机房的服务,才能保证没有延迟。我们称每个机房为一个 ezone,一个 ezone 包含了饿了么需要的各种服务。一笔业务能够内聚在一个 ezone 中,那么一个定单涉及的用户,商家,骑手,都会在相同的机房,这样订单在各个角色之间流转速度最快,不会因为各种异常情况导致延时。恰好我们的业务是地域化的,通过合理的地域划分,也能够实现业务内聚。
  2. 可用性优先:当发生故障切换机房时,优先保证系统可用,首先让用户可以下单吃饭,容忍有限时间段内的数据不一致,在事后修复。每个 ezone 都会有全量的业务数据,当一个 ezone 失效后,其他的 ezone 可以接管用户。用户在一个ezone的下单数据,会实时的复制到其他ezone。
  3. 保证数据正确:在确保可用的情况下,需要对数据做保护以避免错误,在切换和故障时,如果发现某些订单的状态在两个机房不一致,会锁定该笔订单,阻止对它进行更改,保证数据的正确。
  4. 业务可感:因为基础设施还没有强大到可以抹去跨机房的差异,需要让业务感知多活逻辑,业务代码要做一些改造,包括:需要业务代码能够识别出业务数据的归属,只处理本 ezone 的数据,过滤掉无关的数据。完善业务状态机,能够在数据出现不一致的时候,通过状态机发现和纠正。

这几条基本原则,贯穿了饿了么多活的整个设计。

基于这几条原则,我们从服务划分,流量路由,业务改造等方面设计了多活方案,下面简要介绍一主要逻辑。

服务划分(Sharding):

为了实现业务内聚,我们首先要选择一个划分方法(Sharding Key),对服务进行分区,让用户,商户,骑手能够正确的内聚到同一个 ezone 中。分区方案是整个多活的基础,它决定了之后的所有逻辑。

根据饿了么的业务特点,我们自然的选择地理位置作为划分业务的单元,把地理位置上接近的用户,商户,骑手划分到同一个ezone,这样一个订单的履单流程就会在一个机房完成,能够保证最小的延时,在某个机房出现问题的时候,也可以按照地理位置把用户,商户,骑手打包迁移到别的机房即可。

所以我们最终选择的方案如下图,自定义地理划分围栏,用围栏把全国分为多个 shard,围栏的边界尽量按照行政省界,必要的时候做一些调整,避免围栏穿过市区。一个ezone可以包含多个 shard,某个 ezone 的 shard ,可以随时切换到另外一个 ezone ,灵活的调度资源和failover。

这样的划分方案,基本解决了垮城市下单的问题,线上没有观察到有跨 ezone 下单的情况。围栏的划分是灵活的,可以随着以后业务的拓展进行修改,因为每个机房都是全量数据,所以调整围栏不会导致问题。

对这种划分方法,有一些常见的疑问,比如:

1.如果两个城市是接壤的,会出现商家和用户处于不同 ezone 的情况,岂不是破坏了内聚性原则?

这种情况确实会出现,为了尽量避免,我们在划分shard的时候没有简单的用城市名称,而是用了复杂的地理围栏实现,地理围栏主体按照省界划分,再加上局部微调,我们最大限度的避免了跨ezone下单的情况。但如果真的出现了,用户下单也不受影响,最多只是状态有1s左右的延迟。

2.用户是会动的,如果用户从北京到了上海,那么划分规则应该怎么应对?

用户在北京下单,数据落在北京shard,到上海下单,数据则落在上海的 shard,借助于底层的数据同步工具,用户无论在什么地方,都能看到自己的数据,但是有1s左右的延时,对于大部分的业务场景,这个延迟是可以承受的。当然也有些业务场景不能接受这 1s 的延时,我们也提供了另外的方案来应对,参考下文介绍Globa Zone的章节。

3.为什么不简单点,按照用户的ID来切分?

阿里是按照用户ID的取模来划分单元的,比较简洁。我们如果也用ID做切分,同一地方的用户,商户,骑手可能被划分到不同 ezone,就会出现比较多的跨机房调用,这样就更可能出现延迟,难以保证实时性。所以,我们本地配送的业务模式,决定了需要用地理位置来划分服务。

流量路由:

基于地理位置划分规则,我们开发了统一的流量路由层(API Router),这一层负责对客户端过来的 API 调用进行路由,把流量导向到正确的 ezone。API Router 部署在多个公有云机房中,用户就近接入到公有云的API Router,还可以提升接入质量。

前端 APP 做了改造,为每个请求都带上了分流标签,API Router 会检查流量上自带的分流标签,把分流标签转换为对应的 Shard ID,再查询 Shard ID 对应的 eZone,最终决定把流量路由到哪个 ezone。

最基础的分流标签是地理位置,有了地理位置,AR 就能计算出正确的 shard 归属。但业务是很复杂的,并不是所有的调用都能直接关联到某个地理位置上,我们使用了一种分层的路由方案,核心的路由逻辑是地理位置,但是也支持其他的一些 High Level Sharding Key,这些 Sharding Key 由 APIRouter 转换为核心的 Sharding Key,具体如下图。这样既减少了业务的改造工作量,也可以扩展出更多的分区方法。

除了入口处的路由,我们还开发了 SOA Proxy,用于路由SOA调用的,和API Router基于相同的路由规则。APIRouter 和 SOAProxy 构成了流量的路由通道,让我们可以灵活的控制各种调用在多活环境下的走向。

数据复制:

为了实现可用优先原则,所有机房都会有全量数据,这样用户可以随时切换到其他机房,全量数据就需要对数据进行实时复制,我们开发了相应的中间件,对 mysql,zookeeper ,消息队列和 redis 的数据进行复制。

Mysql 数据复制工具 DRC:

Mysql 的数据量最大,每个机房产生的数据,都通过 DRC 复制到其他 ezone,每个ezone的主键取值空间是ezoneid + 固定步长,所以产生的 id 各不相同,数据复制到一起后不会发生主键冲突。按照分区规则,正常情况下,每个 ezone 只会写入自己的数据,但万一出现异常,2个 ezone 同时更新了同一笔数据,就会产生冲突。DRC 支持基于时间戳的冲突解决方案,当一笔数据在两个机房同时被修改时,最后修改的数据会被保留,老的数据会被覆盖。

ZooKeeper 复制:

有些全局的配置信息,需要在所有机房都完全一致,我们开发了 zookeeper 复制工具,用于在多个机房中同步 ZK 信息。

消息队列和Redis复制:

MQ,Redis 的复制与 ZK 复制类似,也开发了 相应的复制工具。

强一致保证:

对于个别一致性要求很高的应用,我们提供了一种强一致的方案(Global Zone),Globa Zone是一种跨机房的读写分离机制,所有的写操作被定向到一个 Master 机房进行,以保证一致性,读操作可以在每个机房的 Slave库执行,也可以 bind 到 Master 机房进行,这一切都基于我们的数据库访问层(DAL)完成,业务基本无感知。

切换过程和各种异常保护:

避免数据错误非常重要,在网络断开,或者是切换过程中,特别容易产生错误数据。比如由于复制延时,订单状态不一致,用户有可能会重复支付。为了避免我们采取了一些保护措施,避免在切换时发生错误。

  1. 在网络中断时,如果不是必要,不做切换,因为任意单个机房能够提供完整服务。
  2. 如果需要切换,对锁定切换过程中的订单,直到切换完成,数据复制正常,才开放锁定。这个过程也通过 DAL 来实现
  3. 对于标记为其他机房的写入数据,DAL 会进行保护,拒绝写入。
  4. DRC 会检查并报告错误的写入操作,方便检查隐藏问题。

通过以上4条的保护,我们保证了数据的正确性,频繁的切换也不会出现异常的业务数据。

多个机房的Cache刷新:

数据的变更信息,通过 DRC 广播到多个机房,实现缓存的刷新,保证各个机房的缓存一致性。

整体结构

以上介绍了各个考虑的方面,现在可以综合起来看,饿了么多活的整体结构如下图:

业务改造:

业务可感知是一条基本原则,通过中间件提供的服务,多活逻辑会暴露给业务方,例如:当前服务所属的 ezone,路由策略,数据的归属 shard 等,基于这些信息,业务可以执行很多的逻辑。包括:

  1. 后台任务可以过滤掉非本 ezone 的数据。
  2. 可以在发生切换时,执行特定的逻辑,触发特定动作。
  3. 业务需要准备一些数据修复逻辑,在万一发生不一致时,手工或者自动纠正数据。

实现:多活的基础中间件

下面简要介绍一下支持以上功能的中间件,我们归纳为多活 5 大基础组件,之后会有系列文章,介绍每个基础组件的具体实现。

APIRouter : 路由分发服务

API Router是一个HTTP反向代理和负载均衡器,部署在公有云中作为HTTP API流量的入口,它能识别出流量的归属 shard ,并根据 shard 将流量转发到对应的 ezone。API Router 支持多种路由键,可以是地理位置,也可以是商户ID,订单ID等等,最终由 API Router 映射为统一的 Sharding ID。

Global Zone Service:全局状态协调器

GZS 维护着整个多活的路由表,其他所有的服务都从 GZS 订阅路由信息。切换机房的操作也在 GZS 控制台中完成。路由表包括:地理围栏信息,shard 到 ezone 的归属信息,商铺ID/订单ID 等路由逻辑层到 shard id 的映射关系等。GZS 通过在 SDK 端建立 Cache,来保证shard 逻辑能够最快速度执行,基本不需要和 GZS 交互,同时也有实时推送机制,确保在数据变更后能够快速通知到其他的服务。

SOA Proxy:内部网关

SOA Proxy 实现了对 SOA 调用的路由,执行和 API Router 相似的逻辑,但只用在机房之间进行通信的场景。业务使用 SOA Proxy 需要对代码做一些修改,把路由信息加入到调用的上下文中。

Data Replication Center:数据复制

DRC 负责 Mysql 数据的实时双向复制,保证跨机房延时在 1s 以内。提供了基于时间的冲突解决方案,确保各个机房的数据一致。DRC 除了复制数据,还对外提供了数据变更的通知,让业务能够感知到其他机房的数据变化,做相应的处理,例如清除Cache等。除了DRC,我们还有 ZK复制工具,RMQ 复制工具,Redis复制工具,基本每个数据层次,都有对应的复制方案。

Data Access Layer:数据访问

数据访问层支撑了 Globa Zone 的逻辑,还提供了最后一道保护,拒绝路由错误的数据写入,是多活最底层的支撑。

未来:下一步多活的计划

目前饿了么的服务已经部署到2个异地机房,下一步我们会扩展到3-4个机房,并且在公有云上建立一个新的ezone,充分利用公有云的强大的扩展能力,未来我们将能够快速的在全世界各地搭建数据中心,也能够快速的利用各种公有云基础设施,实现全球规模的高可用和扩展性。

文献引用:

Latency numbers every programmer should know

Linked In Databus

Alibaba Canal Project

作者介绍:

李双涛,饿了么框架工具部高级架构师,参与了整个饿了么多活的设计和实施过程。职业生涯中,有两次参与这种大规模的整站高可用改造,一次在 Cisco Webex,一次在饿了么,积累的宝贵的经验。

[转]MySQL 【去重留一】一条sql语句完成 思路总结

最后在一个技术群里得到了完美的答案,看这条sql语句:

DELETE consum_record
FROM
    consum_record, 
    (
        SELECT
            min(id) id,
            user_id,
            monetary,
            consume_time
        FROM
            consum_record
        GROUP BY
            user_id,
            monetary,
            consume_time
        HAVING
            count(*) > 1
    ) t2
WHERE
    consum_record.user_id = t2.user_id 
    and consum_record.monetary = t2.monetary
    and consum_record.consume_time  = t2.consume_time
AND consum_record.id > t2.id;

上面这条sql语句,仔细看一下,揣摩出思路也不难,大概也分为3步来理解:

  1. (SELECT min(id) id, user_id, monetary, consume_time FROM consum_record GROUP BY user_id, monetary, consume_time HAVING count(*) > 1 ) t2 查询出重复记录形成一个集合(临时表t2),集合里是每种重复记录的最小ID
  2. consum_record.user_id = t2.user_id and consum_record.monetary = t2.monetary and consum_record.consume_time = t2.consume_time 关联判断重复基准的字段
  3. 根据条件,删除原表中id大于t2中id的记录

看到这个语句的时候,心里想这也太厉害了。这么一个简单的sql语句,竟然可以解决这么复杂的问题,涨姿势了~
运行起来也超级快,原先的代码循环执行,需要116s左右,而这里0.3s就可以了,厉害了~

perfect_sql.png

perfect_sql.png