忘掉 Snowflake!感受一下性能高出 587 倍的全局唯一 ID 生成算法

2020-07-03 18:20:27 +08:00
 NightTeam

文章作者:「夜幕团队 NightTeam 」 - 韦世东

润色、校对:「夜幕团队 NightTeam 」 - Loco

本文首发于「 NightTeam 」微信公众号,如需转载请在微信端发消息告知。


今天我们来拆解 Snowflake 算法,同时领略百度、美团、腾讯等大厂在全局唯一 ID 服务方面做的设计,接着根据具体需求设计一款全新的全局唯一 ID 生成算法。这还不够,我们会讨论到全局唯一 ID 服务的分布式 CAP 选择与性能瓶颈。

已经熟悉 Snowflake 的朋友可以先去看大厂的设计和权衡。

百度 UIDGenertor: https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md

美团 Leaf: https://tech.meituan.com/2017/04/21/mt-leaf.html

腾讯 Seqsvr: https://www.infoq.cn/article/wechat-serial-number-generator-architecture

全局唯一 ID 是分布式系统和订单类业务系统中重要的基础设施。这里引用美团的描述:

在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。如在美团点评的金融、支付、餐饮、酒店、猫眼电影等产品的系统中,数据日渐增长,对数据分库分表后需要有一个唯一 ID 来标识一条数据或消息,数据库的自增 ID 显然不能满足需求;特别一点的如订单、骑手、优惠券也都需要有唯一 ID 做标识。

这时候你可能会问:我还是不懂,为什么一定要全局唯一 ID ?

我再列举一个场景,在 MySQL 分库分表的条件下,MySQL 无法做到依次、顺序、交替地生成 ID,这时候要保证数据的顺序,全局唯一 ID 就是一个很好的选择。

在爬虫场景中,这条数据在进入数据库之前会进行数据清洗、校验、矫正、分析等多个流程,这期间有一定概率发生重试或设为异常等操作,也就是说在进入数据库之前它就需要有一个 ID 来标识它。

全局唯一 ID 应当具备什么样的属性,才能够满足上述的场景呢?

美团技术团队列出的 4 点属性我觉得很准确,它们是:

  1. 全局唯一性:不能出现重复的 ID 号,既然是唯一标识,这是最基本的要求;
  2. 趋势递增:在 MySQL InnoDB 引擎中使用的是聚集索引,由于多数 RDBMS 使用 B-tree 的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能;
  3. 单调递增:保证下一个 ID 一定大于上一个 ID,例如事务版本号、IM 增量消息、排序等特殊需求;
  4. 信息安全:如果 ID 是连续的,恶意用户的爬取工作就非常容易做了,直接按照顺序下载指定 URL 即可;如果是订单号就更危险了,竞争对手可以直接知道我们一天的单量。所以在一些应用场景下,会需要 ID 无规则、不规则。

看上去第 3 点和第 4 点似乎还存在些许冲突,这个后面再说。除了以上列举的 ID 属性外,基于这个生成算法构建的服务还需要买足高 QPS 、高可用性和低延迟的几个要求。

业内常见的 ID 生成方式有哪些?

大家在念书的时候肯定都学过 UUIDGUID,它们生成的值看上去像这样:

6F9619FF-8B86-D011-B42D-00C04FC964FF

由于不是纯数字组成,这就无法满足趋势递增和单调递增这两个属性,同时在写入时也会降低写入性能。上面提到了数据库自增 ID 无法满足入库前使用和分布式场景下的需求,遂排除。

有人提出了借助 Redis 来实现,例如订单号=日期+当日自增长号,自增长通过 INCR 实现。但这样操作的话又无法满足编号不可猜测需求。

这时候有人提出了 MongoDB 的 ObjectID,不要忘了它生成的 ID 是这样的: 5b6b3171599d6215a8007se0,和 UUID 一样无法满足递增属性,且和 MySQL 一样要入库后才能生成。

难道就没有能打的了吗

大名鼎鼎的 Snowflake

Twitter 于 2010 年开源了内部团队在用的一款全局唯一 ID 生成算法 Snowflake,翻译过来叫做雪花算法。Snowflake 不借助数据库,可直接由编程语言生成,它通过巧妙的位设计使得 ID 能够满足递增属性,且生成的 ID 并不是依次连续的,能够满足上面提到的全局唯一 ID 的 4 个属性。它连续生成的 3 个 ID 看起来像这样:

563583455628754944
563583466173235200
563583552944996352

Snowflake 以 64 bit 来存储组成 ID 的 4 个部分:

1 、最高位占 1 bit,值固定为 0,以保证生成的 ID 为正数;

2 、中位占 41 bit,值为毫秒级时间戳;

3 、中下位占 10 bit,值为工作机器的 ID,值的上限为 1024 ;

4 、末位占 12 bit,值为当前毫秒内生成的不同 ID,值的上限为 4096 ;

Snowflake 的代码实现网上有很多款,基本上各大语言都能找到实现参考。我之前在做实验的时候在网上找到一份 Golang 的代码实现:

代码可在我的 Gist 查看和下载。

Snowflake 存在的问题

snowflake 不依赖数据库,也不依赖内存存储,随时可生成 ID,这也是它如此受欢迎的原因。但因为它在设计时通过时间戳来避免对内存和数据库的依赖,所以它依赖于服务器的时间。上面我们提到了 Snowflake 的 4 段结构,实际上影响 ID 大小的是较高位的值,由于最高位固定为 0,遂影响 ID 大小的是中位的值,也就是时间戳。

试想,服务器的时间发生了错乱或者回拨,这就直接影响到生成的 ID,有很大概率生成重复的 ID一定会打破递增属性。这是一个致命缺点,你想想,支付订单和购买订单的编号重复,这是多么严重的问题!

另外,由于它的中下位末位 bit 数限制,它每毫秒生成 ID 的上限严重受到限制。由于中位是 41 bit 的毫秒级时间戳,所以从当前起始到 41 bit 耗尽,也只能坚持 70 年

再有,程序获取操作系统时间会耗费较多时间,相比于随机数和常数来说,性能相差太远,这是制约它生成性能的最大因素

一线企业如何解决全局唯一 ID 问题

长话短说,我们来看看百度、美团、腾讯(微信)是如何做的。

百度团队开源了 UIDGenerator 算法.

它通过借用未来时间和双 Buffer 来解决时间回拨与生成性能等问题,同时结合 MySQL 进行 ID 分配。这是一种基于 Snowflake 的优化操作,是一个好的选择,你认为这是不是优选呢?

美团团队根据业务场景提出了基于号段思想的 Leaf-Segment 方案和基于 Snowflake 的 Leaf-Snowflake 方案.

出现两种方案的原因是 Leaf-Segment 并没有满足安全属性要求,容易被猜测,无法用在对外开放的场景(如订单)。Leaf-Snowflake 通过文件系统缓存降低了对 ZooKeeper 的依赖,同时通过对时间的比对和警报来应对 Snowflake 的时间回拨问题。这两种都是一个好的选择,你认为这是不是优选呢?

微信团队业务特殊,它有一个用 ID 来标记消息的顺序的场景,用来确保我们收到的消息就是有序的。在这里不是全局唯一 ID,而是单个用户全局唯一 ID,只需要保证这个用户发送的消息的 ID 是递增即可。

这个项目叫做 Seqsvr,它并没有依赖时间,而是通过自增数和号段来解决生成问题的。这是一个好的选择,你认为这是不是优选呢?

性能高出 Snowflake 587 倍的算法是如何设计的?

在了解 Snowflake 的优缺点、阅读了百度 UIDGenertor 、美团 Leaf 和腾讯微信 Seqsvr 的设计后,我希望设计出一款能够满足全局唯一 ID 4 个属性且性能更高、使用期限更长、不受单位时间限制、不依赖时间的全局唯一 ID 生成算法。

这看起来很简单,但吸收所学知识、设计、实践和性能优化占用了我 4 个周末的时间。在我看来,这个算法的设计过程就像是液态的水转换为气状的雾一样,遂我给这个算法取名为薄雾( Mist )算法。接下来我们来看看薄雾算法是如何设计和实现的。

位数是影响 ID 数值上限的主要因素,Snowflake 中下位和末位的 bit 数限制了单位时间内生成 ID 的上限,要解决这个两个问题,就必须重新设计 ID 的组成。

抛开中位,我们先看看中下位和末位的设计。中下位的 10 bit 的值其实是机器编号,末位 12 bit 的值其实是单位时间(同一毫秒)内生成的 ID 序列号,表达的是这毫秒生成的第 5 个或第 150 个 数值,同时二者的组合使得 ID 的值变幻莫测,满足了安全属性。实际上并不需要记录机器编号,也可以不用管它到底是单位时间内生成的第几个数值,安全属性我们可以通过多组随机数组合的方式实现,随着数字的递增和随机数的变幻,通过 ID 猜顺序的难度是很高的。

最高位固定是 0,不需要对它进行改动。我们来看看至关重要的中位,Snowflake 的中位是毫秒级时间戳,既然不打算依赖时间,那么肯定也不会用时间戳,用什么呢?我选择自增数 1,2,3,4,5,...。中位决定了生成 ID 的上限和使用期限,如果沿用 41 bit,那么上限跟用时间戳的上限相差无几,经过计算后我选择采用与 Snowflake 的不同的分段:

缩减中下位和末位的 bit 数,增加中位的 bit 数,这样就可以拥有更高的上限和使用年限,那上限和年限现在是多久呢?中位数值的上限计算公式为 int64(1<<47 - 1),计算结果为 140737488355327百万亿级的数值,假设每天消耗 10 亿 ID,薄雾算法能用 385+ 年,几辈子都用不完

中下位和末位都是 8 bit,数值上限是 255,即开闭区间是 [0, 255]。这两段如果用随机数进行填充,对应的组合方式有 256 * 256 种,且每次都会变化,猜测难度相当高。由于不像 Snowflake 那样需要计算末位的序列号,遂薄雾算法的代码并不长,具体代码可在我的 GitHub 仓库找到:

聊聊性能问题,获取时间戳是比较耗费性能的,不获取时间戳速度当然快了,那 500+ 倍是如何得来的呢?以 Golang 为例(我用 Golang 做过实验),Golang 随机数有三种生成方式:

基于固定数值种子的随机数每次生成的值都是一样的,是伪随机,不可用在此处。将时间戳作为种子以生成随机数是目前 Golang 开发者的主流做法,实测性能约为 8800 ns/op 。

大数真随机知道的人比较少,实测性能 335ns/op,由此可见性能相差近 30 倍。大数真随机也有一定的损耗,如果想要将性能提升到顶点,只需要将中下位和末位的随机数换成常数即可,常数实测性能 15ns/op,是时间戳种子随机数的 587 倍。

要注意的是,将常数放到中下位和末位的性能是很高,但是猜测难度也相应下降。

薄雾算法的依赖问题

薄雾算法为了避开时间依赖,不得不依赖存储,中位自增的数值只能在内存中存活,遂需要依赖存储将自增数值存储起来,避免因为宕机或程序异常造成重复 ID 的事故。

看起来是这样,但它真的是依赖存储吗?

你想想,这么重要的服务必定要求高可用,无论你用 Twitter 还是百度或者美团、腾讯微信的解决方案,在架构上一定都是高可用的,高可用一定需要存储。在这样的背景下,薄雾算法的依赖其实并不是额外的依赖,而是可以与架构完全融合到一起的设计。

薄雾算法和 Redis 的结合

既然提出了薄雾算法,怎么能不提供真实可用的工程实践呢?在编写完薄雾算法之后,我就开始了工程实践的工作,将薄雾算法与 KV 存储结合到一起,提供全局唯一 ID 生成服务。这里我选择了较为熟悉的 Redis,Mist 与 Redis 的结合,我为这个项目取的名字为 Medis 。

性能高并不是编造出来的,我们看看它 Jemeter 压测参数和结果:

以上是 Medis README 中给出的性能测试截图,在大基数条件下的性能约为 2.5w/sec 。这么高的性能除了薄雾算法本身高性能之外,Medis 的设计也作出了很大贡献:

Medis 服务启动流程和接口访问流程图下所示:

感兴趣的朋友可以下载体验一下,启动 Medis 根目录的 server.go 后,访问 http://localhost:1558/sequence 便能拿到全局唯一 ID 。

高可用架构和分布式性能

分布式 CAP (一致性、可用性、分区容错性)已成定局,这类服务通常追求的是可用性架构( AP )。由于设计中采用了预存预取,且要保持整体顺序递增,遂单机提供访问是优选,即分布式架构下的性能上限就是提供服务的那台主机的单机性能。

你想要实现分布式多机提供服务?

这样的需求要改动 Medis 的逻辑,同时也需要改动各应用之间的组合关系。如果要实现分布式多机同时提供服务,那么就要废弃 Redis 和 Channel 预存预取机制,接着放弃 Channel 而改用即时生成,这样便可以同时使用多个 Server,但性能的瓶颈就转移到了 KV 存储(这里是 Redis ),性能等同于单机 Redis 的性能。你可以采用 ETCD 或者 Zookeeper 来实现多 KV,但这不是又回到了 CAP 原点了吗?

至于怎么选择,可根据实际业务场景和需求与架构进行讨论,选择一个适合的方案进行部署即可。

领略了 Mist 和 Medis 的风采后,相信你一定会有其他巧妙的想法,欢迎在评论区留言,我们一起交流进步!


夜幕团队成立于 2019 年,团队包括崔庆才(静觅)、周子淇( Loco )、陈祥安( CXA )、唐轶飞(大鱼| BruceDone )、冯威(妄为)、蔡晋(悦来客栈的老板)、戴煌金(咸鱼)、张冶青( MarvinZ )、韦世东( Asyncins |奎因)和文安哲( sml2h3 )。

涉猎的编程语言包括但不限于 Python 、Rust 、C++、Go,领域涵盖爬虫、深度学习、服务研发、逆向工程、软件安全等。团队非正亦非邪,只做认为对的事情,请大家小心。

20835 次点击
所在节点    分享创造
156 条回复
iyaozhen
2020-07-03 23:39:23 +08:00
@NightTeam “另外,提到 Redis 不靠谱、容易出问题,我猜你平时不用 Redis ?”

我感觉是你没有大规模用过
我说的 redis 不稳定是 redis 这一套链路不稳定,故障的原因很多,单一性不稳定。3 个 9 不是很高,我上层业务要 4 个 9 怎么办?至少得双存储

“你提到的每个实例模块都引入 jar 包的方式(我不懂 Java,不好乱评价)还能保证各个节点生成的 ID 不重复,那真是太厉害了”
不懂 java 没关系呀,就是类似 go import 一个包
你自己也说了“中下位占 10 bit,值为工作机器的 ID,值的上限为 1024” 每个机器 id 不一样 生成的肯定能保证不重复呀

时钟回拨是个问题,但你的做法只是用 redis 来代替本地时间,这个可靠性是降了几个数量级的
optional
2020-07-03 23:42:38 +08:00
@NightTeam snowflake 的分布式可以是同机房的分布式,也可以是异地甚至全球的分布式。而且是无网络依赖了,本地就可以生成。 时间问题可以用 GPS 授时(低成本)和原子钟(高成本),至于回拨其实很简单用一个变量记录最后生成事件,回拨就 sleep 就好,因为回拨是个低频的事情,所以偶尔的低性能也能接受。
但是你用 id 服务器后,基本就只能限制在一定范围了,而且还有单点故障。
swulling
2020-07-03 23:44:00 +08:00
设计算法精神可嘉,但是要设计一个工程上可用的系统,纸上谈兵是没有什么卵用的。

举个例子,如果说你从来没应用过百度 uidgenerator 或者美团 leaf 这个规模的 id 生成场景,只是脑补算法意义不大。先不提算法本身的问题,就工程上没有实践就没有发言权。
renyijiu
2020-07-04 00:08:34 +08:00
先不说所谓算法性能倍数问题,从你的工程实践来说,哪里能够体现出你的 587 倍的优势呢?
locoz
2020-07-04 00:12:44 +08:00
本回复与主题无关,非当事人可以直接跳过。
---
@biu7 #24 你这评价低到让我有点匪夷所思…跟我在第一次看这书内容的时候得到的印象完全不同,于是我刚刚花了二十分钟又大致翻了一下。

第一、二章都是基本操作,内容已经很简单粗暴了,都是些会用到的部分。如果这叫大量抄官方文档的话,那动物园的《 Python 学习手册》、《 Python Cookbook 》这种书就可以称之为全篇抄官方文档了;
然后其他章节基本就是基础知识+引导思路的话语+核心的反爬思路或解决思路。这些地方哪来的关键部分技术一笔带过?明明都是讲的思路,本身就没跟你讲什么技术。
我猜你可能是觉得书里直接给你鼠标轨迹生成算法之类的才叫“讲了关键技术”、才能让你感觉“学到了新东西”?

不过这种写法对于有一定知识量的人来说感觉“废话多”、“半天讲不到关键点”是很正常的,我这翻了二十分钟已经开始感觉困了,甚至感觉基本操作太多了。但这并不能说明书的质量低,只是因为我的知识量已经覆盖掉这本书的内容而已。
换到你身上,你觉得没什么内容、质量低同样说明不了什么问题。这或许只是因为你弄的东西比较多了所以知识量早已覆盖这些内容,又或许你本身就不是这本书的目标群体,仅此而已。

另外,讲得详细、各种相关资料都贴上是为了能让更多没有相关基础知识的人看懂,引导思路的“废话”多是为了能让没有掌握逆向思维的人慢慢养成逆向思维,这些做法都是很正常的,我写文章的时候也会这样。
每个人的知识量、思维方式、学习能力都不同,甚至是连自己找资料的能力都不同,想要将知识传播给更多人就应该这么做。连最“菜”的人都能看懂的东西,其他人就更加没问题了。

话说这本书从目录上不是就能很清楚地看出是本基础向的书吗?既然你有一定水平又自己感觉不会学到什么东西,那你为什么要买…就很奇怪。
oneisall8955
2020-07-04 00:22:30 +08:00
我的理解是通过增加中位长度,并且一定的手段如 redis 获取自增值,来规避雪花基于时间会回拨导致重复或不单调自增的弊端,达到随机和单调。本质上和雪花算法是一样的,只是获取雪花基于机器时间存在弊端,你采取的依赖 redis 自增,使得单调递增控制在自己手里。好处显而易见,弊端大家也懂,依赖外部服务,增加网络 IO 时间(存在 buffer 发号且 client 和 Server 都在内网其实 IO 时间可以忽略吧),有舍有得。另外,大家没必要互相喷吧,作者提供一个思路而已。可能文中写的很自信,这里看不惯别人不谦虚吧。(吐槽下文末请大家小心是什么鬼,辣眼睛)
misaka19000
2020-07-04 00:25:49 +08:00
楼主这个算法用在多大规模的系统中的?在线上跑了多长时间了?
Deepseafish
2020-07-04 00:48:52 +08:00
「大家在念书的时候肯定都学过 UUID 和 GUID,它们生成的值看上去像这样:
6F9619FF-8B86-D011-B42D-00C04FC964FF
由于不是纯数字组成,这就无法满足趋势递增和单调递增这两个属性,同时在写入时也会降低写入性能。」

楼主能解释下这段话吗,因果关系是这样的?
Deepseafish
2020-07-04 01:11:32 +08:00
「如果想要将性能提升到顶点,只需要将中下位和末位的随机数换成常数即可,常数实测性能 15ns/op,是时间戳种子随机数的 587 倍。」
原来 587 倍是这么来的,把自己算法中的随机数换成常数,然后和再和自己使用随机数的算法比较。这个标题起的实在是高。
ryd994
2020-07-04 02:48:02 +08:00
@NightTeam #10
"Snowflake 的碰撞问题是因为中位用了时间戳,薄雾算法没有用时间戳,所以不会收到单位时间的限制,也不会产生碰撞问题。另外不用担心高流量下的唯一性,Jmeter 5000w 数据测试过了。"
要保证没有碰撞只有维护某个状态或者维护某种单调递增性。你测试没遇到不代表它不可能发生。
你没有遇到碰撞不排除是占了伪随机数算法的便宜。
我就问你两个独立生成的随机数可不可能有碰撞?这是一个数学问题。

@oneisall8955 喷的是标题党和自大。每个算法都有其适用范围。他这个算法和 snowflakes 解决的完全不是一个问题,去比较性能有什么意义呢?而且,还实现错了。
ryd994
2020-07-04 03:01:42 +08:00
@NightTeam #26
我更倾向于认为是你的实现的性能太低,是时间戳而不是随机数保证了不重复

snowflakes 设计时就指出了依赖时间戳保证唯一性的问题:如果在小于时间戳精度的时间内快速反复生成 ID,就有可能发生碰撞。机器 ID 就是为了规避这个情况。因为单机下维护一致性容易多了。
你倒好,把机器 ID 去掉了。在大规模使用时几乎可以保证会发生碰撞。
cassyfar
2020-07-04 03:45:44 +08:00
纸上谈兵。给我感觉是一群没做过工程的在那玩花活。各种 buzzword,但是一看设计啼笑皆非吧。

自增算法在多分布情况下怎么解决一致性?别告诉我你要用一台机器撑起百万,千万 QPS 吧。
解决一致性我能想到实际的就是锁,但是多线程性能降低不厉害?
另外存储的运维不严重?性能依赖不过分?我以前的公司做千万 QPS 的 auth,要依赖数据库。工业界没有数据库能达到期望的 availability,只有自己花了 3 年,30 人团队开发了一个。你用一个 reddis,我真的打扰了。
最后的最后,你可能不知道工业界系统设计原则之一,keep it simple 。这不是炫技。
qdwang
2020-07-04 06:02:32 +08:00
@cassyfar 老哥说得好啊,真正的炫技是做到极致简单又能完全满足需求。
codingadog
2020-07-04 07:18:56 +08:00
uuid 是纯数字,当字符串处理的都是异端。
后面就不想看了。
hpeng
2020-07-04 08:36:22 +08:00
你们网络情况还是太简单了,居然依赖 redis,这不是自找苦吃么,还高可用呢
jinliming2
2020-07-04 08:39:38 +08:00
> 信息安全:如果 ID 是连续的,恶意用户的爬取工作就非常容易做了,直接按照顺序下载指定 URL 即可;如果是订单号就更危险了,竞争对手可以直接知道我们一天的单量。所以在一些应用场景下,会需要 ID 无规则、不规则。

这里说订单号必须 ID 无规则,不然会被竞争对手直接知道一天的单量。
那么我问个问题,你这个算法怎么保证信息安全?因为你中间使用的是自增 id,所以我只需要把中间那个自增 id 取出来,然后每天减一下,就能知道你一天生成了多少 id ?
中间用时间戳好理解,数值变化一天固定就是变一天的时间戳。但自增 id ?
rrfeng
2020-07-04 09:32:44 +08:00
这就叫做一本正经的胡说八道。功夫多下在研究算法原理上,不要老想着搞大事做表面文章。
sujin190
2020-07-04 10:41:29 +08:00
错漏百出,这都敢出来胡说八道,真行

首先 mongo 的 ObjectId 是本机生成的,谁说要写入库才有的,而且是单调递增的

分布式 ID 的首先需求是区分不同进程不同机器,不同机房,好嘛,直接把时间 bit 数加上去了,区分机器进程能力完全丧失,而且实际使用过程中,生成时间并不是平均的,有可能其中一毫秒内生成了几万几十万接下来好几小时都不用了,单毫秒内计数单字节,作死啊

Snowflake 需要手动配置集群机器进程 ID 作为一个分布式专用唯一 ID 生成算法就已经很不方便了,完全让一个微服务分布式系统无状态依赖变成了有状态依赖,而且是一个非常重要的依赖,作死啊,但是算法限制字节数 8 字节确实没用更好方法,但事实上强行限制字节完全没啥意义,多增加的成本并没有多少,但是一个完全无状态依赖又安全的分布式算法对于微服务分布式系统的意义就大多了,不知道省了多少事多少心

所以不清楚不知道还是别出来误人子弟瞎 bb 了
pigmen
2020-07-04 11:26:11 +08:00
这时候有人提出了 MongoDB 的 ObjectID,不要忘了它生成的 ID 是这样的:5b6b3171599d6215a8007se0,和 UUID 一样无法满足递增属性,且和 MySQL 一样要入库后才能生成。

这句话就错了

mongodb 的 id 是递增的,
respect11
2020-07-04 11:28:27 +08:00
收藏评论

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/686977

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX