知乎从问答起步,在过去的 8 年中逐步成长为一个大规模的综合性知识内容平台,目前,知乎上有多达 3000 万个问题,共收获了超过 1.3 亿个回答,同时知乎还沉淀了数量众多的文章、电子书以及其他付费内容。知乎通过个性化首页推荐的方式在海量的信息中高效分发用户感兴趣的优质内容。为了避免给用户推荐重复的内容,已读服务会将所有知乎站上用户深入阅读或快速掠过的内容长期保存,并将这些数据应用于首页推荐信息流和个性化推送的已读过滤。
业务场景,技术挑战
首页已读过滤流程示意图
从首页使用已读服务的流程我们可以看出这个服务的业务模式较为简单,我们只需要简单的以用户为第一维度内容为第二纬度来查询指定用户是否已经阅读过某个内容。但我们并没因为业务简单就在设计上放弃了灵活性和普适性。为此我们设计开发了一套支持 BigTable 数据模型的 Cache Through 缓冲系统 RBase 来实现已读服务,一方面充分利用 Cache 的高吞吐低时延能力,另一方面还可以利用灵活的 BigTable 数据模型来辅助业务快速演进。
BigTable 数据模型
已读服务虽然从业务模式看非常简单,但它在技术上的挑战确并不低。目前知乎已读的数据规模已超万亿并以每天接近 30 亿的速度持续高速增长。与常见的“读多写少”的业务不同,已读服务不仅需要在这样的存量数据规模下提供在线查询服务,还同时承载着每秒 4 万条新纪录写入的冲击。已读内容过滤作为首页信息流推荐中对响应时间影响较大的关键任务点,它的可用性和响应时间都需要满足非常高的要求。
综合业务需求和线上数据来看,已读服务的要求和挑战主要有以下几点:
可用性要求「高」:服务于个性化首页和个性化推送,最重要的流量分发渠道
写入量「大」:峰值每秒写入 40K+ 行记录,日新增记录近 30 亿条
历史数据「长期」保存:目前已达一万二千亿条记录
查询吞吐「高」:在线查询峰值 30K QPS / 12M+ 条已读检查
响应时间「敏感」:90ms 超时
早期方案,架构演进
BloomFilter on Redis Cluster
最初我们在 Redis 集群上使用 BITSET 结构直接存储已读数据的 BloomFilter。首先由于缺乏多个位的批量操作,操作放大非常严重会消耗非常多的计算资源。其次使用全内存的方式存储全量数据也拉高了整体成本。最后由于难以预估用户的阅读量增速无法以用户为粒度合理控制 BloomFilter 的尺寸和 False Positive Rate。
HBase
考虑到 BloomFilter on Redis 方案存在的问题,我们开始尝试使用 HBase 来存储用户的阅读历史并提供在线查询服务。已读的业务需求可以非常直观的映射到 BigTable 的数据模型上。我们将用户 id 作为 row key,访问的文档 id 作为 qualifier 保存下来, 而 timestamp 则恰好可以用来记录文档的读取时间。整个系统的可扩展性和成本都要显著优于直接使用 Redis Cluster 存储 BloomFilter 的方案。
随着已读数据量级和业务查询量的迅速增长,已读数据访问极度稀疏的特点开始影响到了 HBase 的 cache 命中率。HBase 的存储模型在发生 cache miss 需要访问存储的情况下 IO 路径很长。根据缓冲穿透的层次不同整个请求路径上可能会经过数个 Java 进程,任何一个进程的 GC 和 IO 都会对这次访问的 latency 产生显著的影响,导致响应时间产生较大的波动,而大的响应时间波动是首页难以接受的。
HBase 缓冲穿透的 IO 路径
在吸取了最初两代已读架构方案的经验教训后,我们开始设计实现新一代的已读服务,在这次的设计中我们在可用性、性能以及扩展性上都设定了更高的目标尤其是以往系统表现不好的性能和扩展性方面我们希望看到更加长足的进步。
高可用
✓HBase
✓BloomFilter on Redis Cluster
高性能
✘HBase
✓BloomFilter on Redis Cluster
易扩展
✓HBase
✘BloomFilter on Redis Cluster
下面就让我们一起从 高可用、高性能 和 易扩展 这三个角度来思考如何构建一个更好的已读服务满足好业务的需求和挑战。
高可用
当我们讨论高可用的时候,也意味着我们已经意识到故障是无时无刻都在发生的,依赖传统人工运维的方式来保证复杂系统的高可用是不现实的。我们需要以系统化的方式对各个组件的状态进行探测感知他们发生的故障。并且我们需要为系统中的组件设计自愈机制,当故障发生时可以不经人工干预而自动的恢复。最后我们还需要隔离各种故障所产生的变化,让业务侧尽可能对故障的发生和恢复无感知。
故障监测、自动恢复并隔离变化
高性能
对常见的系统来说,越核心的组件往往状态越重扩展的代价也越大,层层拦截快速降低需要深入到核心组件的请求量对提高性能是非常有效的手段。首先我们通过缓冲分 Slot 的方式来扩展集群所能缓冲的数据规模。接着进一步在 Slot 内通过多副本的方式提升单个 Slot 缓冲数据集的读取吞吐,将大量的请求拦截在系统的缓冲层进行消化。如果请求不可避免的走到了最终的数据库组件上,我们还可以利用效率较高的压缩来继续降低落到物理设备上的 IO 压力。
分层去并发
易扩展
提升系统扩展性的关键在于减少有状态组件的范围,在路由和服务发现组件的帮助下,系统中的无状态组件可以非常轻松的扩展扩容。所以通过扩大无状态服务的范围,收缩重状态服务的比例可以显著的帮助我们提升整个系统的可扩展性。除此之外如果我们能够设计一些可以从外部系统恢复状态的弱状态服务,那么我们往往可以利用弱状态的组件来部分替代重状态组件。随着弱状态组件的扩大和重状态组件的收缩,整个系统的可扩展性可以得到进一步的提升。
弱状态部分替代重状态
RBase
在高可用、高性能和易扩展的设计理念下,我们设计实现了 RBase 做为已读服务的根基。现在让我们来从 RBase 全局设计入手来了解高可用、高性能和易扩展的设计理念是如何落地的。
RBase 架构
客户端 API 和 Proxy 是完全无状态可随时扩展的组件,最底层是由 MHA 管理的 MySQL 集群,中间存在大量可从数据库或者副本中恢复的弱状态组件。这些弱状态组件中最核心的部分是分层的缓冲模块,这些缓冲模块的状态可以从副本或数据库中恢复重建因此他们的可扩展性仍然是非常优秀的。缓冲以外的组件则负责管理缓冲的一致性,在它们的协助下缓冲模块可以完全避免无意义的 Cache Invalidate 提升缓存的命中率,从而极大缓解了传导到底层数据库系统上的压力。整个架构中除了唯一的重状态组件 MySQL 集群之外所有的组件都拥有自我恢复的能力。在拥有自我恢复能力和全局故障监测的前提下,我们使用 Kubernetes 来管理所有 RBase 的服务组件在机制上确保整个服务的高可用。
RBase 的设计中缓存是一个非常关键的组件,然而它们的组织管理方式又同常见的缓存系统不尽相同,这些设计上的不同体现着我们在高性能方面的思考。现在让我们以一个典型的计算机系统的内存分层设计来引出我们在缓存系统设计上的思路。
计算机系统内存分层示意图
在过去的几十年中工业界为了提升计算机系统的性能,在计算机系统中添加了更多的 CPU 乃至更多的核心。随着 CPU 内部处理能力的不断增强我们还为 CPU 加上多级的缓存来弥补主存在带宽和时延上同 CPU 的巨大差距。除此之外我们还进一步把主存连同 CPU 分成多组让他们之间有更快的本地连接,只有当需要交叉访问远程内存的时候再通过互联总线进行交流。在这样一个系统里大部分的读写操作都发生在离核心最近的 L1 或者 L2 cache 里,而在修改主存中数据的时候则需要更加复杂的机制和更长的时间来达成缓冲的最终一致。为了能将计算机系统的性能发挥到极致工业界不断的改进体系结构,在这个架构中处处都体现着设计的取舍和智慧。
我们利用在内存分层设计上获得的灵感,在 RBase 的缓存一致性上采用了类似的设计。类似计算机系统内存的数据库层,相似的分层缓冲设计,类似的 cache through 以及 cache coherence 组件设计。大道至简殊途同归,通过学习借鉴体系结构经典成熟的思想将它应用到软件系统设计中同样可以得到非常好的效果。
核心组件,关键设计
接下来让我们从细节来深入了解已读系统中一些核心组件的关键设计。
Proxy
Proxy 负责负载均衡和隔离故障
Proxy 是已读服务的接入层组件,它用传统的方式将缓冲按照用户维度拆分成多个 slot 组织起来,每个 slot 负责数据集内的一个子集。Slot 内可以有多个副本来分担同一批数据的读取压力,proxy 会在 slot 内对同一个会话绑定同一个副本来保证会话内的一致性,当副本发生故障时 proxy 优先选择同一个 slot 内的其它副本来继续承载请求,在极端情况发生 slot 内的所有副本同时失效时,proxy 还可以选择其它 slot 的活跃节点来处理用户的请求,这时我们付出了无法利用缓冲提高性能的代价来换取系统在极端场景下的可用性。
Cache
在由「用户」和「内容类型」和「内容」所组成的空间中,由于「用户」维度和「内容」维度的基数非常高,都在数亿级别,即使记录数在万亿这样的数量级下,数据在整个三维空间内的分布依然非常稀疏。单纯依靠底层存储系统的能力很难在尺寸巨大且极度稀疏的数据集上提供高吞吐的在线查询,更难以满足业务对低响应时间的要求。另外尺寸巨大且分布稀疏的数据集对缓存系统的资源消耗和命中率的也提出了巨大的挑战。
已读数据空间分布极度稀疏
考虑到目前知乎站上沉淀的内容量级巨大,我们可以容忍 false positive 但依旧为用户召回到足够多可能会感兴趣的内容。基于这样的业务特点我们可以将数据库中存储的原始数据转化为 BloomFilter 缓冲起来,这极大的降低了内存的消耗在相同的资源状况下可以缓冲更多的数据提高缓存的命中率。
缓存 Bloom Filter
提升缓存命中率的方式有很多种,除了前面提到的提升缓存数据密度增加可缓冲的数据量级之外,我们还可以通过避免不必要的缓存失效来进一步的提升缓存的效率。因此我们将缓存设计为 write through cache 使用原地更新缓存的方式来避免 invalidate cache 操作。再配合数据变更订阅我们可以在不失效缓冲的情况下确保同一份数据的多个缓冲副本能在很短的时间内达成最终一致。另一方面得益于 read through 的设计,我们可以将对同一份数据的多个并发查询请求转化成一次 cache miss 加多次缓冲读取,进一步提升缓存的命中率降低穿透到底层数据库系统的压力。
Cache Through
缓冲系统的核心工作是拦截住大量热数据的访问,因此维持缓冲数据的热度是整个系统的稳定性的关键因素。但作为不断迭代演进的业务系统,如何在系统滚动升级或者副本扩容的时候让新启动的缓冲节点也快速热身进入状态呢?虽然我们可以选择逐步向新节点开放流量的方式避免冷缓冲的影响,但我们也会面临故障后自动恢复的情景,这时我们没有时间等待新的缓冲服务逐步进入状态。考虑到这点我们在新节点启动后会从当前 slot 内挑选一个活跃的副本迁移全部或足够多的状态让新节点快速进入工作状态,避免因新节点缓冲不热导致的响应时间抖动。
缓冲状态迁移
前面我们提到借鉴计算机系统中的内存分层设计,接下来我们从收益的角度来探讨采用类似的分层缓冲设计在已读服务上为系统带来了什么。现实中的缓冲系统不论如何提高命中率,我们始终都需要面对 cache miss 的场景。多层缓冲的存在可以让我们在不同层级的 Cache 上应用不同的配置策略,力图在每一个新引入的 cache 层级上都进一步降低穿透到下一层的请求数量。在多层缓冲的帮助下我们可以让不同的缓冲层级分别从空间维度和时间维度关注数据的热度。我们甚至还可以在多数据中心部署的情况下,通过进一步增加缓冲层数利用多层缓冲的机制来极大的降低跨数据中心访问数据的带宽消耗和时延增加。
跨数据中心部署
知乎作为一个内容社区,用户的已读数据是非常核心的行为数据。不但我们在首页个性化推荐上有过滤需求,在个性化推送上也存在着类似的过滤需求。个性化推送是典型的离线任务,查询吞吐更高但可以放松响应时间的要求,虽然他们所访问的数据源相同但推送和首页所访问的数据在热度分布上存在着显著的不同。为了让业务之间不互相影响并且针对不同业务的数据访问特征选择不同的缓冲策略,我们还进一步提供了 cache 标签隔离的机制来隔离离线写入和多个不同的业务租户的查询。
业务独立的缓冲策略以及物理隔离
MySQL
在系统开发初期为了加快开发效率,我们在物理存储层上选择了在知乎内部应用最广泛的 MySQL。针对系统对高性能和高可用的要求,我们使用了分库分表加 MHA 机制来提升系统的性能并保障系统的高可用。除此以外我们还根据已读数据高写入低删除的特点选择了更适合这个场景的 TokuDB 存储引擎,得益于 TokuDB 引擎极高的压缩比系统在一万亿记录时单副本的总数据尺寸大约 13T,平均一行记录仅使用了 10 多字节的空间。在这个阶段我们使用了 12 台节点来承载这一万亿已读数据,在这样的一个集群规模下手工运维还是勉强可以接受的。
性能指标
到 2019 年初当前一代的已读服务已在线上稳定服务了首页一年有余,在各项业务指标上来看都是非常满意的。目前已读的流量已达每秒 4 万行纪录写入, 3 万独立查询 和 1200 万个文档判读,在这样的压力下已读服务响应时间的 P99 和 P999 仍然稳定的维持在 25ms 和 50ms。
已读服务核心业务指标
全面云化,面向未来
从已读的业务指标上看我们交出了一份还让人满意的答卷,但作为已读服务的开发和运维人员我们深知目前的这套架构还存在着一些核心的痛点没有解决好。首当其冲的就是 MySQL 的运维问题。我们不但需要考虑数据量继续膨胀后需要再次分库分表的扩展性问题,还需要考虑整个集群的高可用和节点发生物理故障后的恢复等一系列问题。在每月数据量近 1000 亿持续膨胀带来的压力下我们的不安感与日俱增,迫切的需要一个完善的方案来解决 MySQL 集群的运维问题。其次已读系统的整体架构都是面向在线业务设计的,这导致数据分析的工作很难被直接的应用在这样的架构之上。带着已读服务的这些问题我们于近期开始了新一轮的迭代,本轮迭代的核心目标是将已读服务全面云化,达到全系统高可用规模随需扩展的目标。
已读服务最痛的 MySQL 运维问题实质是单机数据库在扩展性和可用性上不足所带来的问题,那么这个问题最直接了当的解决方式就是致力于解决这些问题的原生分布式的数据库。幸运的是近些年工业界在原生分布式数据库领域有了非常多的进展,而 CockroachDB 和 TiDB 则是在这个领域非常优秀的两个开源项目。虽然他们的具体实现细节和技术路径有所不同,但在大方向上看他们有着不少的相似点。
计算存储分层的分布式数据库架构
考虑到已读服务过去构建于 MySQL 技术上,相比之下兼容 MySQL 的 TiDB 比 CockroachDB 对已读服务有着更低的迁移门槛。除此之外 TiDB 背后的 PingCAP 作为一家中国境内的公司,在我们遇到困难的时候可以更加容易的寻求到帮助。基于这些考虑我们最终选择将 TiDB 作为已读服务 MySQL 集群迁移的目标。得益于 TiDB 对 MySQL 的良好兼容和生态工具的完善整个迁移工作并不复杂,除了工作量最大的数据迁移工作之外,开发上还需要调整 CDC 组件与 TiDB Binlog 相适配。
数据迁移
目前 TiDB 官方推荐使用一站式的数据迁移工具 DM 来完成从 MySQL 到 TiDB 的全量数据迁移和增量数据同步。但考虑到在准备迁移时已读服务数据已经达到一万一千亿行的规模,直接使用 DM 做逻辑式的初始全量数据迁移耗时可能会无法接受。在尝试使用 DM 进行导入测试的结果也从数据上验证了即便不考虑后期数据量变大后可能的速度下降,以逻辑的方式导入初始全量数据也需要耗时至少一个月。基于使用 DM 导入全量数据耗时预估我们作出了独立使用 TiDB Lightning 迁移全量数据的决定。
MySQL 到 TiDB 数据迁移
目前 TiDB Lightning 还尚未同 DM 进行整合,因此整个迁移的过程存在不少的人工操作流程。迁移过程需要首先启动 DM 开始收集 MySQL 上的增量 Binlog,然后使用 TiDB Lightning 快速导入历史全量数据到 TiDB。当全量数据导入完成后由 DM 负责将全量数据迁移过程中 MySQL 侧产生的增量数据同步到 TiDB 中并维持两边数据的一致性。整个迁移过程看起来很简单但在实际操作中我们也遇到了一些考虑不周的地方导致了多次导入失败。
TiDB Lightning 导入数据的工作实际需要两个独立的程序 tidb-lightning 和 tikv-importer 配合完成,而这两个程序都属于资源消耗密集的应用。在导入初期我们可以用来做数据迁移的服务器并不多,再加上过于轻视 TiDB Lightning 庞大的资源需求,我们尝试了多种部署模式来试图使用尽量少的服务器来完成数据导入工作。但这些尝试无一例外的都失败了,下面是我们尝试并最终失败了的部署方式希望能给大家一些帮助避免遇到同样的问题。
最初我们试图在同一台机器上跑多套 tidb-lightning 和 tikv-importer 进程进程同时导入多个 MySQL 上的数据,这种方式最终以机器上的内存消耗殆尽进程被 OOM Killer 杀掉失败而告终。接下来我们尝试在多个 tidb-lightning 进程间复用同一个 tikv-importer,并利用多个 tidb-lightning 进程 encode 数据时不需要 tikv-importer 的特点打时间差提高 tikv-importer 的利用率。通过错开 tidb-lightning 进程的启动时间我们在数据导入的初期阶段顺利的提高了 tikv-importer 服务器的利用率。由于不同 tidb-lightning 任务的实际执行时间不尽相同,在导入进行了一段时间后很难避免多个 tidb-lightning 任务同时向同一个 tikv-importer 节点提交导入请求导致目标服务资源不足引起失败或被 OOM Killer 杀掉的现象。在经历了这些失败后我们认识到 TiDB Lightning 惊人的导入速度背后也对应着相当高的资源需求,想要提升导入数据的速度必须要给予足够多的硬件资源。 认识到这一点之后我们调整了一下策略,尝试将部分原本计划部署 TiKV 的节点改为 TiDB Lightning 数据导入节点。而节点减少导致存储空间不足的问题我们通过暂时调整集群的副本数为 1 来减少 TiKV 在存储空间上的需求并预期在导入完成后再恢复副本数到 3。这次尝试我们成功的导入了全量的数据,并开始使用 DM 增量同步 MySQL 中新发生的写入。为了避免对实时的增量同步产生过大的冲击导致无法跟上 MySQL 的数据生产速度,我们对副本恢复设定了速度限制。在这样情况下将 45T 数据从 1 副本增加到 3 副本的过程需要消耗的时间过长,甚至远超我们最初在数据导入上获得的收益。除此以外在副本完全恢复完成前的时间,如果任何一台节点发生硬件故障都有可能会导致某些 region 数据丢失最终使得整个迁移过程功亏于溃。
在吸取了多次导入失败的教训后我们最终按照实际的硬件资源情况选择了更为保守的导入方案。独占使用 8 台硬件配置满足要求的物理机部署 TiDB Lightning,同时导入 4 个 MySQL 实例的数据,总共 16 个 MySQL 实例上的一万一千亿行纪录迁移最终耗时 4 天完成。在 DM 的帮助下,导入全量数据的 4 天中所产生的全部增量数据在不到一天的时间全部导入完成,16 个 MySQL 实例上新写入的数据也会由 DM 近实时的同步到 TiDB 集群上来。
业务迁移
MySQL 同 TiDB 的同步稳定运转起来后我们开始了业务流量迁移的工作。同迁移过程类似流量切换的过程也并不是一帆风顺的。当我们信心满满地把线上 100% 流量一次性全部切换到 TiDB 上后发现查询的响应时间产生了非常显著的上涨,业务方的调用超时也迅速增加并触发了业务报警,于是我们立刻回滚了流量到 MySQL 集群并开始排查响应时间陡增的问题。
已读服务只有在 cache miss 时才需要穿透到存储层查询数据重建缓存,而重建缓存过程中则需要一次性的拿出指定用户过往所有已经读过内容的全量数据。在实际的业务场景中许多知乎的重度老用户可能在过去的数年中累计看到过几万、十几万甚至几十万条不同的内容,读取这样的全量数据很难在业务要求的几十毫秒内完成。为此我们将 cache miss 请求的处理分为两个独立的步骤,第一个步骤是阻塞式的直接查询当前请求所需要的数据,与此同时我们还会异步发起一个用于缓冲重建的全量数据查询请求用来填充缓冲,满足当前条件的阻塞式查询完成后会立刻响应用户的查询请求。已读服务的这种工作模式决定了我们的核心优化目标是阻塞式的那个小查询的响应时间。因此我们根据业务对不同查询在响应时间上的要求区分 Low/Normal/High 三种优先级不同的 SQL 语句,使用 hint 的方式告知 TiDB 和 TiKV 对不同的 query 使用不同的任务队列在资源层面区分隔离避免每个级别任务不受其它级别任务的影响。除此之外我们还进一步调整业务端逻辑通过使用低精度 TSO 和复用 PreparedStatement 的方式来进一步的减少网络上的 roundtrip 将同步查询的 latency 压缩到极致。
除了前面的在线查询侧逻辑调整之外我们还需要将 MySQL Binlog 适配到 TiDB Binlog 确保变更事件可以正确的推送给订阅方。TiDB Binlog 的开发适配非常简单,只需消费 kafka 上的消息并根据 protobuf 的定义转换成我们内部的变更消息格式整体适配工作就完成了。在这部分适配工作的验证过程中我们发现 TiDB Binlog 为了维持事务全局有序的约束只会使用 Kafka 的第 0 号分区,在我们的业务写入吞吐下只使用一个分区很容易超出 kafka 对应 broker 的处理能力导致 binlog 事件写入和消费的延迟。为此我们对 TiDB Binlog 的 drainer 组件做了一些临时的调整根据配置选择按照 database 或者 table 进行选择目标分区达到均衡负载的目的。验证过程中 PingCAP 的同学也及时调整根据我们的反馈做了诸多的改善,在 TiDB Binlog 最终上线时 TiDB Binlog 的事件时延和吞吐都有了非常显著的加强。
迁移效果
迁移到 TiDB 后的核心业务指标
经过两个月的迁移和灰度放量后,已读服务成功的从 MySQL 迁移到了 TiDB 并且核心指标维持在同 MySQL 相似的水平线上。迁移整体完成后已读服务全部组件都拥有了高可用规模随需扩展的能力,为将来更好的服务首页的流量增长打下了坚实的基础。
关于 TiDB 3.0
在知乎内部采用同已读相同的技术架构我们还支撑了一套用于反作弊的风控类业务。同已读服务极端的历史数据规模不同,反作弊业务有着更加极端的写入吞吐但只需在线查询最近 48 小时入库的数据。TiDB 3.0 的一些新特性在反作弊业务上比 2.1 有了质的提升,因此我们从 TiDB 3.0 rc1 开始就在反作弊业务上将 TiDB 3.0 引入到了生产环境并在 rc2 发布之后不久就开启了 Titan 存储引擎。TiDB 3.0 的 gRPC Batch Message 和多线程 Raft Store 提升了集群吞吐能力让我们可以用更少的资源投入来解决同样的业务问题,Titan 引擎则极大的改善了业务的读写响应时间的稳定性。除此之外 3.0 的分区表功能也可以更好的利用业务 48 小时查询时效的业务特点来提升查询效率。
开启 Titan 引擎的效果
在反作弊业务上从 TiDB 3.0 收获的红利很大程度对已读服务也同样适用,我们计划在 3.0 GA 后将已读服务的集群进行升级并预期获得较大的资源使用效率的提升。
结语
在已读服务开发上线以及一年多的演进过程中我们收获到了一些经验和教训,更好的理解了作为支撑部门如何更好的支持自己的下游业务。首先要理解业务需求有针对性的根据业务特点来设计支撑系统,但在设计上我们仍然要提炼抽象出更有普适性的架构。作为支撑性业务从最初就要关注高可用,为业务提供一个稳固的后方。同样我们还需要关注高性能可伸缩,为业务未来的发展扫清障碍。最后在这个云原生的时代,即便是业务研发也应当抱着开放的心态去积极的拥抱新技术,Cloud Native from Ground Up。
评论