缓存与数据库一致性问题深度剖析 (修订)

前言

当咱们在作数据库与缓存数据同步时,究竟更新缓存,仍是删除缓存,到底是先操做数据库,仍是先操做缓存?本文带你们深度分析数据库与缓存的双写问题而且给出了全部方案的实现代码方便你们参考html

本篇文章主要内容

  • 数据缓存
    • 为什么要使用缓存
    • 哪类数据适合缓存
    • 缓存的利与弊
  • 如何保证缓存和数据库一致性
    • 不更新缓存,而是删除缓存
    • 先操做缓存,仍是先操做数据库
    • 非要保证数据库和缓存数据强一致该怎么办
  • 缓存和数据库一致性实战
    • 实战:先删除缓存,再更新数据库
    • 实战:先更新数据库,再删缓存
    • 实战:缓存延时双删
    • 实战:删除缓存重试机制
    • 实战:读取binlog异步删除缓存

码字不易,只求关注,欢迎关注个人原创技术公众号:后端技术漫谈(二维码见文章底部)mysql

项目源码在这里

https://github.com/qqxx6661/miaoshagit

数据缓存

在咱们实际的业务场景中,必定有不少须要作数据缓存的场景,好比售卖商品的页面,包括了许多并发访问量很大的数据,它们能够称做是是“热点”数据,这些数据有一个特色,就是更新频率低,读取频率高,这些数据应该尽可能被缓存,从而减小请求打到数据库上的机会,减轻数据库的压力。github

为什么要使用缓存

缓存是为了追求“快”而存在的。咱们用代码举一个例子。web

我在本身的Demo代码仓库中增长了两个查询库存的接口getStockByDB和getStockByCache,分别表示从数据库和缓存查询某商品的库存量。面试

随后咱们用JMeter进行并发请求测试。(JMeter的使用请参考我以前写的文章:点击这里redis

须要声明的是,个人测试并不严谨,只是做对比测试,不要做为实际服务性能的参考。算法

这是两个接口的代码:spring

/**
 * 查询库存:经过数据库查询库存
 * @param sid
 * @return
 */
@RequestMapping("/getStockByDB/{sid}")
@ResponseBody
public String getStockByDB(@PathVariable int sid) {
    int count;
    try {
        count = stockService.getStockCountByDB(sid);
    } catch (Exception e) {
        LOGGER.error("查询库存失败:[{}]", e.getMessage());
        return "查询库存失败";
    }
    LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count);
    return String.format("商品Id: %d 剩余库存为:%d", sid, count);
}

/**
 * 查询库存:经过缓存查询库存
 * 缓存命中:返回库存
 * 缓存未命中:查询数据库写入缓存并返回
 * @param sid
 * @return
 */
@RequestMapping("/getStockByCache/{sid}")
@ResponseBody
public String getStockByCache(@PathVariable int sid) {
    Integer count;
    try {
        count = stockService.getStockCountByCache(sid);
        if (count == null) {
            count = stockService.getStockCountByDB(sid);
            LOGGER.info("缓存未命中,查询数据库,并写入缓存");
            stockService.setStockCountToCache(sid, count);
        }
    } catch (Exception e) {
        LOGGER.error("查询库存失败:[{}]", e.getMessage());
        return "查询库存失败";
    }
    LOGGER.info("商品Id: [{}] 剩余库存为: [{}]", sid, count);
    return String.format("商品Id: %d 剩余库存为:%d", sid, count);
}

首先设置为10000个并发请求的状况下,运行JMeter,结果首先出现了大量的报错,10000个请求中98%的请求都直接失败了。让人很慌张~sql

打开日志,报错以下:

SpringBoot内置的Tomcat最大并发数搞的鬼,其默认值为200,对于10000的并发,单机服务实在是力不从心。固然,你能够修改这里的并发数设置,可是你的小机器仍然可能会扛不住。

将其修改成以下配置后,个人小机器才在经过缓存拿库存的状况下,保证了10000个并发的100%返回请求:

server.tomcat.max-threads=10000
server.tomcat.max-connections=10000

能够看到,不使用缓存的状况下,吞吐量为668个请求每秒

使用缓存的状况下,吞吐量为2177个请求每秒

在这种“十分不严谨”的对比下,有缓存对于一台单机,性能提高了3倍多,若是在多台机器,更多并发的状况下,因为数据库有了更大的压力,缓存的性能优点应该会更加明显。

测完了这个小实验,我看了眼我挂着MySql的小水管腾讯云服务器,生怕他被这么高流量搞挂。这种突发的流量,指不定会被检测为异常攻击流量呢~

我用的是腾讯云服务器1C4G2M,活动买的,很便宜。这里打个免费的广告,请腾讯云看到后联系我给我打钱 ;)

哪类数据适合缓存

缓存量大但又不常变化的数据,好比详情,评论等。对于那些常常变化的数据,其实并不适合缓存,一方面会增长系统的复杂性(缓存的更新,缓存脏数据),另外一方面也给系统带来必定的不稳定性(缓存系统的维护)。

但一些极端状况下,你须要将一些会变更的数据进行缓存,好比想要页面显示准实时的库存数,或者其余一些特殊业务场景。这时候你须要保证缓存不能(一直)有脏数据,这就须要再深刻讨论一下。

缓存的利与弊

咱们到底该不应上缓存的,这其实也是个trade-off(权衡)的问题。

上缓存的优势:

  • 可以缩短服务的响应时间,给用户带来更好的体验。
  • 可以增大系统的吞吐量,依然可以提高用户体验。
  • 减轻数据库的压力,防止高峰期数据库被压垮,致使整个线上服务BOOM!

上了缓存,也会引入不少额外的问题:

  • 缓存有多种选型,是内存缓存,memcached仍是redis,你是否都熟悉,若是不熟悉,无疑增长了维护的难度(原本是个纯洁的数据库系统)。
  • 缓存系统也要考虑分布式,好比redis的分布式缓存还会有不少坑,无疑增长了系统的复杂性。
  • 在特殊场景下,若是对缓存的准确性有很是高的要求,就必须考虑 缓存和数据库的一致性问题

本文想要重点讨论的,就是缓存和数据库的一致性问题,各位看官且往下看。

如何保证缓存和数据库一致性

说了这么多缓存的必要性,那么使用缓存是否是就是一个很简单的事情了呢,我以前也一直是这么以为的,直到遇到了须要缓存与数据库保持强一致的场景,才知道让数据库数据和缓存数据保持一致性是一门很高深的学问。

从远古的硬件缓存,操做系统缓存开始,缓存就是一门独特的学问。这个问题也被业界探讨了很是久,争论至今。我翻阅了不少资料,发现其实这是一个权衡的问题。值得好好讲讲。

如下的讨论会引入几方观点,我会跟着观点来写代码验证所提到的问题。

不更新缓存,而是删除缓存

大部分观点认为,作缓存不该该是去更新缓存,而是应该删除缓存,而后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。

观点引用:《分布式之数据库和缓存双写一致性方案解析》孤独烟

缘由一:线程安全角度

同时有请求A和请求B进行更新操做,那么会出现

(1)线程A更新了数据库

(2)线程B更新了数据库

(3)线程B更新了缓存

(4)线程A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,可是由于网络等缘由,B却比A更早更新了缓存。这就致使了脏数据,所以不考虑。

缘由二:业务场景角度

有以下两点:

(1)若是你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会致使,数据压根还没读到,缓存就被频繁的更新,浪费性能。

(2)若是你写入数据库的值,并非直接写入缓存的,而是要通过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

其实若是业务很是简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是能够的。可是,淘汰缓存操做简单,而且带来的反作用只是增长了一次cache miss,建议做为通用的处理方式。

先操做缓存,仍是先操做数据库

那么问题就来了,咱们是先删除缓存,而后再更新数据库,仍是先更新数据库,再删缓存呢?

先来看看大佬们怎么说。

《【58沈剑架构系列】缓存架构设计细节二三事》58沈剑:

对于一个不能保证事务性的操做,必定涉及“哪一个任务先作,哪一个任务后作”的问题,解决这个问题的方向是:若是出现不一致,谁先作对业务的影响较小,就谁先执行。

假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引起一次Cache miss。

假设先写数据库,再淘汰缓存:第一步写数据库操做成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。

沈剑老师说的没有问题,不过没彻底考虑好并发请求时的数据脏读问题,让咱们再来看看孤独烟老师《分布式之数据库和缓存双写一致性方案解析》:

先删缓存,再更新数据库

该方案会致使请求数据不一致

同时有一个请求A进行更新操做,另外一个请求B进行查询操做。那么会出现以下情形:

(1)请求A进行写操做,删除缓存

(2)请求B查询发现缓存不存在

(3)请求B去数据库查询获得旧值

(4)请求B将旧值写入缓存

(5)请求A将新值写入数据库

上述状况就会致使不一致的情形出现。并且,若是不采用给缓存设置过时时间策略,该数据永远都是脏数据。

因此先删缓存,再更新数据库并非一劳永逸的解决方案,再看看先更新数据库,再删缓存这种方案怎么样?

先更新数据库,再删缓存这种状况不存在并发问题么?

不是的。假设这会有两个请求,一个请求A作查询操做,一个请求B作更新操做,那么会有以下情形产生

(1)缓存恰好失效

(2)请求A查询数据库,得一个旧值

(3)请求B将新值写入数据库

(4)请求B删除缓存

(5)请求A将查到的旧值写入缓存

ok,若是发生上述状况,确实是会发生脏数据。

然而,发生这种状况的几率又有多少呢?

发生上述状况有一个先天性条件,就是步骤(3)的写数据库操做比步骤(2)的读数据库操做耗时更短,才有可能使得步骤(4)先于步骤(5)。但是,你们想一想,数据库的读操做的速度远快于写操做的(否则作读写分离干吗,作读写分离的意义就是由于读操做比较快,耗资源少),所以步骤(3)耗时比步骤(2)更短,这一情形很难出现。

先更新数据库,再删缓存依然会有问题,不过,问题出现的可能性会由于上面说的缘由,变得比较低!

因此,若是你想实现基础的缓存数据库双写一致的逻辑,那么在大多数状况下,在不想作过多设计,增长太大工做量的状况下,请先更新数据库,再删缓存!

我非要数据库和缓存数据强一致怎么办

那么,若是我非要保证绝对一致性怎么办,先给出结论:

没有办法作到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,因此它属于CAP中的AP。

因此,咱们得委曲求全,能够去作到BASE理论中说的最终一致性

最终一致性强调的是系统中全部的数据副本,在通过一段时间的同步后,最终可以达到一个一致的状态。所以,最终一致性的本质是须要系统保证最终数据可以达到一致,而不须要实时保证系统数据的强一致性

大佬们给出了到达最终一致性的解决思路,主要是针对上面两种双写策略(先删缓存,再更新数据库/先更新数据库,再删缓存)致使的脏数据问题,进行相应的处理,来保证最终一致性。

缓存延时双删

问:先删除缓存,再更新数据库中避免脏数据?

答案:采用延时双删策略。

上文咱们提到,在先删除缓存,再更新数据库的状况下,若是不采用给缓存设置过时时间策略,该数据永远都是脏数据。

那么延时双删怎么解决这个问题呢?

(1)先淘汰缓存

(2)再写数据库(这两步和原来同样)

(3)休眠1秒,再次淘汰缓存

这么作,能够将1秒内所形成的缓存脏数据,再次删除。

那么,这个1秒怎么肯定的,具体该休眠多久呢?

针对上面的情形,读者应该自行评估本身的项目的读数据业务逻辑的耗时。而后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms便可。这么作的目的,就是确保读请求结束,写请求能够删除读请求形成的缓存脏数据。

若是你用了mysql的读写分离架构怎么办?

ok,在这种状况下,形成数据不一致的缘由以下,仍是两个请求,一个请求A进行更新操做,另外一个请求B进行查询操做。

(1)请求A进行写操做,删除缓存

(2)请求A将数据写入数据库了,

(3)请求B查询缓存发现,缓存没有值

(4)请求B去从库查询,这时,尚未完成主从同步,所以查询到的是旧值

(5)请求B将旧值写入缓存

(6)数据库完成主从同步,从库变为新值

上述情形,就是数据不一致的缘由。仍是使用双删延时策略。只是,睡眠时间修改成在主从同步的延时时间基础上,加几百ms。

采用这种同步淘汰策略,吞吐量下降怎么办?

ok,那就将第二次删除做为异步的。本身起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么作,加大吞吐量。

因此在先删除缓存,再更新数据库的状况下,可使用延时双删的策略,来保证脏数据只会存活一段时间,就会被准确的数据覆盖。

在先更新数据库,再删缓存的状况下,缓存出现脏数据的状况虽然可能性极小,但也会出现。咱们依然能够用延时双删策略,在请求A对缓存写入了脏的旧值以后,再次删除缓存。来保证去掉脏缓存。

删缓存失败了怎么办:重试机制

看似问题都已经解决了,但其实,还有一个问题没有考虑到,那就是删除缓存的操做,失败了怎么办?好比延时双删的时候,第二次缓存删除失败了,那不仍是没有清除脏数据吗?

解决方案就是再加上一个重试机制,保证删除缓存成功。

参考孤独烟老师给的方案图:

方案一:

流程以下所示

(1)更新数据库数据;

(2)缓存由于种种问题删除失败

(3)将须要删除的key发送至消息队列

(4)本身消费消息,得到须要删除的key

(5)继续重试删除操做,直到成功

然而,该方案有一个缺点,对业务线代码形成大量的侵入。因而有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,得到须要操做的数据。在应用程序中,另起一段程序,得到这个订阅程序传来的信息,进行删除缓存操做。

方案二:

流程以下图所示:

(1)更新数据库数据

(2)数据库会将操做信息写入binlog日志当中

(3)订阅程序提取出所须要的数据以及key

(4)另起一段非业务代码,得到该信息

(5)尝试删除缓存操做,发现删除失败

(6)将这些信息发送至消息队列

(7)从新从消息队列中得到该数据,重试操做。

而读取binlog的中间件,能够采用阿里开源的canal

好了,到这里咱们已经把缓存双写一致性的思路完全梳理了一遍,下面就是我对这几种思路徒手写的实战代码,方便有须要的朋友参考。

缓存和数据库一致性实战

实战:先删除缓存,再更新数据库

终于到了实战,咱们在秒杀项目的代码上增长接口:先删除缓存,再更新数据库

OrderController中新增:

/**
 * 下单接口:先删除缓存,再更新数据库
 * @param sid
 * @return
 */
@RequestMapping("/createOrderWithCacheV1/{sid}")
@ResponseBody
public String createOrderWithCacheV1(@PathVariable int sid) {
    int count = 0;
    try {
        // 删除库存缓存
        stockService.delStockCountCache(sid);
        // 完成扣库存下单事务
        orderService.createPessimisticOrder(sid);
    } catch (Exception e) {
        LOGGER.error("购买失败:[{}]", e.getMessage());
        return "购买失败,库存不足";
    }
    LOGGER.info("购买成功,剩余库存为: [{}]", count);
    return String.format("购买成功,剩余库存为:%d", count);
}

stockService中新增:

@Override
public void delStockCountCache(int id) {
    String hashKey = CacheKey.STOCK_COUNT.getKey() + "_" + id;
    stringRedisTemplate.delete(hashKey);
    LOGGER.info("删除商品id:[{}] 缓存", id);
}

其余涉及的代码都在以前三篇文章中有介绍,而且能够直接去Github拿到项目源码,就不在这里重复贴了。

实战:先更新数据库,再删缓存

若是是先更新数据库,再删缓存,那么代码只是在业务顺序上颠倒了一下,这里就只贴OrderController中新增:

/**
 * 下单接口:先更新数据库,再删缓存
 * @param sid
 * @return
 */
@RequestMapping("/createOrderWithCacheV2/{sid}")
@ResponseBody
public String createOrderWithCacheV2(@PathVariable int sid) {
    int count = 0;
    try {
        // 完成扣库存下单事务
        orderService.createPessimisticOrder(sid);
        // 删除库存缓存
        stockService.delStockCountCache(sid);
    } catch (Exception e) {
        LOGGER.error("购买失败:[{}]", e.getMessage());
        return "购买失败,库存不足";
    }
    LOGGER.info("购买成功,剩余库存为: [{}]", count);
    return String.format("购买成功,剩余库存为:%d", count);
}

实战:缓存延时双删

如何作延时双删呢,最好的方法是开设一个线程池,在线程中删除key,而不是使用Thread.sleep进行等待,这样会阻塞用户的请求。

更新前先删除缓存,而后更新数据,再延时删除缓存。

OrderController中新增接口:


// 延时时间:预估读数据库数据业务逻辑的耗时,用来作缓存再删除
private static final int DELAY_MILLSECONDS = 1000;


/**
 * 下单接口:先删除缓存,再更新数据库,缓存延时双删
 * @param sid
 * @return
 */
@RequestMapping("/createOrderWithCacheV3/{sid}")
@ResponseBody
public String createOrderWithCacheV3(@PathVariable int sid) {
    int count;
    try {
        // 删除库存缓存
        stockService.delStockCountCache(sid);
        // 完成扣库存下单事务
        count = orderService.createPessimisticOrder(sid);
        // 延时指定时间后再次删除缓存
        cachedThreadPool.execute(new delCacheByThread(sid));
    } catch (Exception e) {
        LOGGER.error("购买失败:[{}]", e.getMessage());
        return "购买失败,库存不足";
    }
    LOGGER.info("购买成功,剩余库存为: [{}]", count);
    return String.format("购买成功,剩余库存为:%d", count);
}

OrderController中新增线程池:

// 延时双删线程池
private static ExecutorService cachedThreadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());


/**
 * 缓存再删除线程
 */
private class delCacheByThread implements Runnable {
    private int sid;
    public delCacheByThread(int sid) {
        this.sid = sid;
    }
    public void run() {
        try {
            LOGGER.info("异步执行缓存再删除,商品id:[{}], 首先休眠:[{}] 毫秒", sid, DELAY_MILLSECONDS);
            Thread.sleep(DELAY_MILLSECONDS);
            stockService.delStockCountCache(sid);
            LOGGER.info("再次删除商品id:[{}] 缓存", sid);
        } catch (Exception e) {
            LOGGER.error("delCacheByThread执行出错", e);
        }
    }
}

来试验一下,请求接口createOrderWithCacheV3:

日志中,作到了两次删除:

实战:删除缓存重试机制

上文提到了,要解决删除失败的问题,须要用到消息队列,进行删除操做的重试。这里咱们为了达到效果,接入了RabbitMq,而且须要在接口中写发送消息,而且须要消费者常驻来消费消息。Spring整合RabbitMq仍是比较简单的,我把简单的整合代码也贴出来。

pom.xml新增RabbitMq的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

写一个RabbitMqConfig:

@Configuration
public class RabbitMqConfig {

    @Bean
    public Queue delCacheQueue() {
        return new Queue("delCache");
    }

}

添加一个消费者:

@Component
@RabbitListener(queues = "delCache")
public class DelCacheReceiver {

    private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class);

    @Autowired
    private StockService stockService;

    @RabbitHandler
    public void process(String message) {
        LOGGER.info("DelCacheReceiver收到消息: " + message);
        LOGGER.info("DelCacheReceiver开始删除缓存: " + message);
        stockService.delStockCountCache(Integer.parseInt(message));
    }
}

OrderController中新增接口:

/**
 * 下单接口:先更新数据库,再删缓存,删除缓存重试机制
 * @param sid
 * @return
 */
@RequestMapping("/createOrderWithCacheV4/{sid}")
@ResponseBody
public String createOrderWithCacheV4(@PathVariable int sid) {
    int count;
    try {
        // 完成扣库存下单事务
        count = orderService.createPessimisticOrder(sid);
        // 删除库存缓存
        stockService.delStockCountCache(sid);
        // 延时指定时间后再次删除缓存
        // cachedThreadPool.execute(new delCacheByThread(sid));
        // 假设上述再次删除缓存没成功,通知消息队列进行删除缓存
        sendDelCache(String.valueOf(sid));

    } catch (Exception e) {
        LOGGER.error("购买失败:[{}]", e.getMessage());
        return "购买失败,库存不足";
    }
    LOGGER.info("购买成功,剩余库存为: [{}]", count);
    return String.format("购买成功,剩余库存为:%d", count);
}

访问createOrderWithCacheV4:

能够看到,咱们先完成了下单,而后删除了缓存,而且假设延迟删除缓存失败了,发送给消息队列重试的消息,消息队列收到消息后再去删除缓存。

实战:读取binlog异步删除缓存

咱们须要用到阿里开源的canal来读取binlog进行缓存的异步删除。

我写了一篇Canal的入门文章,其中用的入门例子就是读取binlog删除缓存。你们能够直接跳转到这里:阿里开源MySQL中间件Canal快速入门

扩展阅读

更新缓存的的Design Pattern有四种:

  • Cache aside
  • Read through
  • Write through
  • Write behind caching,这里有陈皓的总结文章能够进行学习。

https://coolshell.cn/articles/17416.html

小结

引用陈浩《缓存更新的套路》最后的总结语做为小结:

分布式系统里要么经过2PC或是Paxos协议保证一致性,要么就是拼命的下降并发时脏数据的几率

缓存系统适用的场景就是非强一致性的场景,因此它属于CAP中的AP,BASE理论。

异构数据库原本就没办法强一致,只是尽量减小时间窗口,达到最终一致性

还有别忘了设置过时时间,这是个兜底方案

结束语

本文总结并探讨了缓存数据库双写一致性问题。

文章内容大体能够总结为以下几点:

  • 对于读多写少的数据,请使用缓存。
  • 为了保持数据库和缓存的一致性,会致使系统吞吐量的降低。
  • 为了保持数据库和缓存的一致性,会致使业务代码逻辑复杂。
  • 缓存作不到绝对一致性,但能够作到最终一致性。
  • 对于须要保证缓存数据库数据一致的状况,请尽可能考虑对一致性到底有多高要求,选定合适的方案,避免过分设计。

做者水平有限,写文章过程当中不免出现错误和疏漏,请理性讨论与指正。

码字不易,只求关注,欢迎关注个人原创技术公众号:后端技术漫谈(二维码见文章底部)

参考

  • https://cloud.tencent.com/developer/article/1574827
  • https://www.jianshu.com/p/2936a5c65e6b
  • https://www.cnblogs.com/rjzheng/p/9041659.html
  • https://www.cnblogs.com/codeon/p/8287563.html
  • https://www.jianshu.com/p/0275ecca2438
  • https://www.jianshu.com/p/dc1e5091a0d8
  • https://coolshell.cn/articles/17416.html

关注我

我是一名后端开发工程师。主要关注后端开发,数据安全,爬虫,物联网,边缘计算等方向,欢迎交流。

各大平台均可以找到我

  • 微信公众号:后端技术漫谈
  • Github:@qqxx6661
  • CSDN:@蛮三刀把刀
  • 知乎:@后端技术漫谈
  • 简书:@蛮三刀把刀
  • 掘金:@蛮三刀把刀
  • 腾讯云+社区:@后端技术漫谈

原创文章主要内容

  • 后端开发实战
  • Java面试知识
  • 设计模式/数据结构/算法题解
  • 读书笔记/逸闻趣事/程序人生

我的公众号:后端技术漫谈

我的公众号:后端技术漫谈

若是文章对你有帮助,不妨点赞,收藏起来~

本文分享自微信公众号 - 后端技术漫谈(Rude3Knife)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索