原文位于Redis官网http://redis.io/topics/twitter-clone

Redis是NoSQL数据库中一个知名数据库,在新浪微博中亦有部署,适合固定数据量的热数据的访问。

作为入门,这是一篇很好的教材,简单描述了如何使用KV数据库进行数据库的设计。新的项目www.xiayucha.com亦采用Redis + MySQL进行开发,考虑Redis文档比较少,故翻译了此文。

其他参考资料:

  • Redis命令参考中文版(Redis Command Reference)
  • Try Redis

我会在此文中描述如何使用PHP以及仅使用Redis来设计实现一个简单的Twitter克隆。
很多编程社区常认为KV储存是一个特别的数据库,在web应用中不能替代关系数据库。
本文尝试证明这恰恰相反。

这个twitter克隆名为Retwis,结构简单,性能优异,能很轻易地用N个web服务器和Redis服务器以分布式架构。
在此获取源码http://code.google.com/p/redis/downloads/list。
我们使用PHP作为例子是因为它能被每个人读懂,也能使用Ruby、Python、Erlang或其他语言获取同样(或者更佳)的效果。

注意:Retwis-RB是一个由Daniel Lucraft用Ruby与Sinatra写的Retwis分支!
此文全部代码在本页尾部的Git repository链接里。
此文以PHP为例,但是Ruby程序员也能检出其他源码。他们很相似。

注意Retwis-J是Retwis的一个分支,由Costin Leau以Java和Spring框架写成。
源码能在GitHub找到,并且在springsource.org有综合的文档。

Key-value 数据库基础

KV数据的精髓,是能够把value储存在key里,此后该数据仅能够通过确切的key来获取,无法搜索一个值。
确切的来讲,它更像一个大型HASH/字典,但它是持久化的,比如,当你的程序终止运行,数据不会消失。
比如我们能用SET命令以key foo 来储存值 bar
 SET foo bar
Redis会永久储存我们的数据,所以之后我们可以问Redis:“储存在key foo里的数据是什么?”,Redis会返回一个值:bar
 GET foo => bar
KV数据库提供的其他常见操作有:DEL,用于删除指定的key和关联的value;
SET-if-not-exists (在Redis上称为SETNX )仅会在key不存在的时候设置一个值;
INCR能够对指定的key里储存的数字进行自增。
 SET foo 10
 INCR foo => 11
 INCR foo => 12
 INCR foo => 13

原子操作
目前为止它是相当简单的,但是INCR有些不同。设想一下,为什么要提供这个操作?毕竟我们自己能用以下简单的命令实现这个功能:
 x = GET foo
 x = x + 1
 SET foo x
问题在于要使上面的操作正常进行,同时只能有一个客户端操作x的值。看看如果两台电脑同时操作这个值会发生什么:
 x = GET foo (返回10)
 y = GET foo (返回10)
 x = x + 1 (x现在是11)
 y = y + 1 (y现在是11)
 SET foo x (foo现在是11)
 SET foo y (foo现在是11)
问题发生了!我们增加了值两次,本应该从10变成12,现在却停留在了11。这是因为用GET和SET来实现INCR不是一个原子操作(atomic operation)。
所以Redis\memcached之类提供了一个原子的INCR命令,服务器会保护get-increment-set操作,以防止同时的操作。
让Redis与众不同的是它提供了更多类似INCR的方案,用于解决模型复杂的问题。
因此你可以不使用任何SQL数据库、仅用Redis写一个完整的web应用,而不至于抓狂。

超越Ke-Value数据库
本节我们会看到构建一个Twitter克隆所需Redis的功能。首先需要知道的是,Redis的值不仅仅可以是字符串(String)。
Redis的值可以是列表(Lists)也可以是集合(Sets),在操作更多类型的值时也是原子的,所以多方操作同一个KEY的值也是安全的。
让我们从一个Lists开始:
 LPUSH mylist a (现在mylist含有一个元素:'a'的list)
 LPUSH mylist b (现在mylist含有元素'b,a')
 LPUSH mylist c (现在mylist含有'c,b,a')
LPUSH的意思是Left Push, 就是把一个元素加在列表(list)的左边(或者说头上)。
在PUSH操作之前,如果mylist这个键(key)不存在,Redis会自动创建一个空的list。
就像你能想到的一样,同样有个RPUSH操作可以把元素加在列表(list)的右边(尾部)。
这对我们复制一个twitter非常有用,例如我们可以把用户的更新储存在username:updates里。
当然,我们也有相应的操作来获取数据或者信息。比如LRANGE返回列表(list)的一个范围内的元素,或者所有元素
 LRANGE mylist 0 1 => c,b
LRANGE使用从零开始的索引(zero-based indexes),第一个元素的索引是0,第二个是1,以此类推。该命令的参数是:LRANGE key first-index last-index
参数last index可以是负数,具有特殊的意义:-1是列表(list)的最后一个元素,-2是倒数第二个,以此类推。
所以,如果要获取整个list,我们能使用以下命令:
 LRANGE mylist 0 -1 => c,b,a
其他重要的操作有LLEN,返回列表(list)的长度,LTRIM类似于LRANGE,但不仅仅会返回指定范围内的元素,而且还会原子地把列表(list)的值设置这个新的值。
我们将会使用这些list操作,但是注意阅读Redis文档来浏览所有redis支持的list操作。

数据类型:集合(set)
除了列表(list),Redis还提供了集合(sets)的支持,是不排序(unsorted)的元素集合。
它能够添加、删除、检查元素是否存在,并且获取两个结合之间的交集。当然它也能请求获取集合(set)里一个或者多个元素。
几个例子可以使概念更为清晰。记住:SADD是往集合(set)里添元素;SREM是从集合(set)里删除元素;SISMEMBER是检测一个元素是否包含在集合里;SINTER用于显示两个集合的交集。
其他操作有,SCARD用于获取集合的基数(集合中元素的数量);SMEMBERS返回集合中所有的元素
 SADD myset a
 SADD myset b
 SADD myset foo
 SADD myset bar
 SCARD myset => 4
 SMEMBERS myset => bar,a,foo,b
注意SMEMBERS不会以我们添加的顺序返回元素,因为集合(Sets)是一个未排序的元素集合。如果你要储存顺序,最好使用列表(Lists)取而代之。以下是基于集合的一些操作:
 SADD mynewset b
 SADD mynewset foo
 SADD mynewset hello
 SINTER myset mynewset => foo,b
SINTER能够返回集合之间的交集,但并不仅限于两个集合(Sets),你能获取4个、5个甚至1000个集合(sets)的交集。
最后,让我们看下SISMEMBER是如何工作的:
 SISMEMBER myset foo => 1
 SISMEMBER myset notamember => 0
Okay,我觉得我们可以开始coding啦!

先决条件
如果你还没下载,请前往<<a href="http://code.google.com/p/redis/downloads/list">http: //code.google.com/p/redis/downloads/list>下载Retwis的源码。它包含几个PHP文件,是个简单的 tar.gz文件。
实现的非常简单,你会在里面找到PHP客户端(redis.php),用于redis与PHP的交互。该库由Ludovico Magnocavallo(http://qix.it/ )编写,你可以在自己的项目中免费使用。
但如果要更新库的版本请下载Redis的发行版。(注意:现在有更好的PHP库了,请检查我们的客户端页面<<a href="http://redis.io/clients">http://redis.io/clients>)
你需要的另一个东西是正常运行的Redis服务器。仅需要获取源码、用make编译、用./redis-server就完工了,点儿也不须配置就可以在你的电脑上运行Retwis。

数据结构规划
当使用关系数据库的时候,这一步往往是在设计数据表、索引的表单里处理。我们没有表,那我们设计什么呢? 我们需要确认物体使用的key以及key采用的类型。
让我们从用户这块开始设计。当然了,首先需要展示用户的username, userid, password, followers,自己follow的用户等。第一个问题是:如何在我们的系统中标识一个用户?
username是个好主意,因为它是唯一的。不过它太大了,我们想要降低内存的使用。如果我们的数据库是关系数据库,我们能关联唯一ID到每一个用户。每一个对用户的引用都通过ID来关联。
做起来很简单,因为我们有我们的原子的INCR命令!当我们创建一个新用户,我们假设这个用户叫"antirez":
 INCR global:nextUserId => 1000
 SET uid:1000:username antirez
 SET uid:1000:password p1pp0
我们使用global:nextUserId为键(Key)是为了给每个新用户分配一个唯一ID,然后用这个唯一ID来加入其他key,以识别保存用户的其他数据。这就是kv数据库的设计模式!请牢记于心,
除了已经定义的KEY,我们还需要更多的来完整定义一个用户,比如有时需要通过用户名来获取用户ID,所以我们也需要设置这么一个键(Key)
 SET username:antirez:uid 1000
一开始看上去这样很奇怪,但请记住我们只能通过key来获取数据!这不可能告诉Redis返回包含某值的Key,这也是我们的强处。
用关系数据库方式来讲,这个新实例强迫我们组织数据,以便于仅使用primary key访问任何数据。

关注\被关注与更新
这也是在我们系统中另一个重要需求.每个用户都有follower,也有follow的用户.对此我们有最佳的数据结构!那就是.....集合(Sets).那就让我们在结构中加入两个新字段:
 uid:1000:followers => Set of uids of all the followers users
 uid:1000:following => Set of uids of all the following users
另一个重要的事情是我们需要有个地方来放用户主页上的更新。这个要以时间顺序排序,最新的排在旧的前面。所以,最佳的类型是列表(List)。
基本上每个更新都会被LPUSH到该用户的updates key.多亏了LRANGE,我们能够实现分页等功能。请注意更新(updates)和帖子(posts)讲的是同一个东西,实际上更新(updates)是有点小的帖子(posts)。
 uid:1000:posts => a List of post ids, every new post is LPUSHed here.

验证
OK,除了验证,或多或少我们已经有了关于该用户的一切东西。我们处理验证用一个简单而健壮(鲁棒)的办法:我们不使用PHP的session或者其他类似方式。
我们的系统必须是能够在不同不同服务器上分布式部署的,所以一切状态都必须保存在Redis里。所以我们所需要的一个保存在已验证用户cookie里的随机字符串。
包含同样随机字符串的一个key告诉我们用户的ID。我们需要使用两个key来保证这个验证机制的健壮性:
 SET uid:1000:auth fea5e81ac8ca77622bed1c2132a021f9
 SET auth:fea5e81ac8ca77622bed1c2132a021f9 1000
为了验证一个用户,我们需要做一些简单的工作(login.php):
* 从登录表单获取用户的用户名和密码
* 检查是否存在一个键 username::uid
* 如果这个user id存在(假设1000)
* 检查 uid:1000:password 是否匹配,如果不匹配,显示错误信息
* 匹配则设置cookie为字符串"fea5e81ac8ca77622bed1c2132a021f9"(uid:1000:auth的值)
实例代码:

PHP代码
  1. include("retwis.php");
  2. # Form sanity checks
  3. if (!gt("username") || !gt("password"))
  4. goback("You need to enter both username and password to login.");
  5. # The form is OK, check if the username is available
  6. $username = gt("username");
  7. $password = gt("password");
  8. $r = redisLink();
  9. $userid = $r->get("username:$username:id");
  10. if (!$userid)
  11. goback("Wrong username or password");
  12. $realpassword = $r->get("uid:$userid:password");
  13. if ($realpassword != $password)
  14. goback("Wrong useranme or password");
  15. # Username / password OK, set the cookie and redirect to index.php
  16. $authsecret = $r->get("uid:$userid:auth");
  17. setcookie("auth",$authsecret,time()+3600*24*365);
  18. header("Location: index.php");

每次用户登录都会运行,但我们需要一个函数isLoggedIn用于检验一个用户是否已经验证。
这些是isLoggedIn的逻辑步骤
* 从用户获取cookie里auth的值。如果没有cookie,该用户未登录。我们称这个cookie为
* 检查auth:是否存在,存在则获取值(例子里是1000)
* 为了再次确认,检查uid:1000:auth是否匹配
* 用户已验证,在全局变量$User中载入一点信息
也许代码比描述更短:

PHP代码
  1. function isLoggedIn() {
  2. global $User, $_COOKIE;
  3. if (isset($User)) return true;
  4. if (isset($_COOKIE['auth'])) {
  5. $r = redisLink();
  6. $authcookie = $_COOKIE['auth'];
  7. if ($userid = $r->get("auth:$authcookie")) {
  8. if ($r->get("uid:$userid:auth") != $authcookie) return false;
  9. loadUserInfo($userid);
  10. return true;
  11. }
  12. }
  13. return false;
  14. }
  15. function loadUserInfo($userid) {
  16. global $User;
  17. $r = redisLink();
  18. $User['id'] = $userid;
  19. $User['username'] = $r->get("uid:$userid:username");
  20. return true;
  21. }

把loadUserInfo作为一个独立函数对于我们的应用而言有点杀鸡用牛刀了,但是对于复杂的应用而言这是一个不错的模板。
作为一个完整的验证,还剩下logout还没实现。在logout的时候我们怎么做呢?
很简单,仅仅改变uid:1000:auth里的随机字符串,删除旧的auth:并增加一个新的auth:
重要:logout过程解释了为什么我们不仅仅查找auth:而是再次检查了uid:1000:auth。真正的验证字符串是后者,auth:是易变的.
假设程序中有BUGs或者脚本被意外中断,那么就有可能有多个auth:指向同一个用户id。
logout代码如下:(logout.php)

PHP代码
  1. include("retwis.php");
  2. if (!isLoggedIn()) {
  3. header("Location: index.php");
  4. exit;
  5. }
  6. $r = redisLink();
  7. $newauthsecret = getrand();
  8. $userid = $User['id'];
  9. $oldauthsecret = $r->get("uid:$userid:auth");
  10. $r->set("uid:$userid:auth",$newauthsecret);
  11. $r->set("auth:$newauthsecret",$userid);
  12. $r->delete("auth:$oldauthsecret");
  13. header("Location: index.php");

以上是我们所描述过的,应该比较易于理解。

更新(Updates)
更新,或者称为帖子(posts)的实现则更为简单。为了在数据库里创建一个新的帖子,我们做了以下工作:
 INCR global:nextPostId => 10343
 SET post:10343 "$owner_id|$time|I'm having fun with Retwis"
就像你看到的一样,帖子的用户id和时间直接储存在了字符串里。
在这个例子中我们不需要根据时间或者用户id来查找帖子,所以把他们紧凑地挤在一个post字符串里更佳。
在新建一个帖子之后,我们获得了帖子的id。需要LPUSH这个帖子的id到每一个follow了作者的用户里去,当然还有作者的帖子列表。
update.php这个文件展示了这个工作是如何完成的:

PHP代码
  1. include("retwis.php");
  2. if (!isLoggedIn() || !gt("status")) {
  3. header("Location:index.php");
  4. exit;
  5. }
  6. $r = redisLink();
  7. $postid = $r->incr("global:nextPostId");
  8. $status = str_replace("\n"," ",gt("status"));
  9. $post = $User['id']."|".time()."|".$status;
  10. $r->set("post:$postid",$post);
  11. $followers = $r->smembers("uid:".$User['id'].":followers");
  12. if ($followers === false) $followers = Array();
  13. $followers[] = $User['id'];
  14. foreach($followers as $fid) {
  15. $r->push("uid:$fid:posts",$postid,false);
  16. }
  17. # Push the post on the timeline, and trim the timeline to the
  18. # newest 1000 elements.
  19. $r->push("global:timeline",$postid,false);
  20. $r->ltrim("global:timeline",0,1000);
  21. header("Location: index.php");

函数的核心是foreach。 通过SMEMBERS获取当前用户的所有follower,然后循环会把帖子(post)LPUSH到每一个用户的 uid::posts里
注意我们同时维护了一个所有帖子的时间线。为此我们还需要LPUSH到global:timeline里。
面对这个现实,你是否开始觉得:SQL里面用ORDER BY来按时间排序有一点儿奇怪? 我确实是这么想的。

分页
现在很清楚,我们能用LRANGE来获取帖子的范围,并在屏幕上显示。代码很简单:

PHP代码
  1. function showPost($id) {
  2. $r = redisLink();
  3. $postdata = $r->get("post:$id");
  4. if (!$postdata) return false;
  5. $aux = explode("|",$postdata);
  6. $id = $aux[0];
  7. $time = $aux[1];
  8. $username = $r->get("uid:$id:username");
  9. $post = join(array_splice($aux,2,count($aux)-2),"|");
  10. $elapsed = strElapsed($time);
  11. $userlink = ".urlencode($username)."">".utf8entities($username)."";
  12. echo(''.$userlink.' '.utf8entities($post)."
    ");
  13. echo('posted '.$elapsed.' ago via web

');

  • return true;
  • }
  • function showUserPosts($userid,$start,$count) {
  • $r = redisLink();
  • $key = ($userid == -1) ? "global:timeline" : "uid:$userid:posts";
  • $posts = $r->lrange($key,$start,$start+$count);
  • $c = 0;
  • foreach($posts as $p) {
  • if (showPost($p)) $c++;
  • if ($c == $count) break;
  • }
  • return count($posts) == $count+1;
  • }

当showUserPosts获取帖子的范围并传递给showPost时,showPost会简单输出一篇帖子的HTML代码。

Following users 关注的用户
如果用户id 1000 (antirez)想要follow用户id1000的pippo,我们做到这个仅需两步SADD:
SADD uid:1000:following 1001
SADD uid:1001:followers 1000
再次注意这个相同的模式:在关系数据库里的理论里follow的用户和被follow的用户是一张包含类似following_id和follower_id的单独数据表。
用查询你能明确follow和被follow的每一个用户。在key-value数据里有一点特别,需要我们分别设置1000follow了1001并且1001被1000follow的关系。
这是需要付出的代价,但是另一方面讲,获取这些数据即简单又超快。并且这些是独立的集合,允许我们做一些有趣的事情,比如使用SINTER获取两个不同用户的集合。
这样我们也许可以在我们的twitter复制品中加入一个功能:当你访问某个人的资料页时显示"你和foobar有34个共同关注者"之类的东西。
你能够在follow.php中找到增加或者删除following/folloer关系的代码。它如你所见般平常。

使它能够水平分割
亲爱的读者,如果你看到这里,你已经是一个英雄了,谢谢你。在讲到水平分割之前,看看单台服务器的性能是个不错的主意。
Retwis让人惊讶地快,没有任何缓存。在一台非常缓慢和高负载的服务器上,以100个线程并发请求100000次进行apache基准测试,平均占用5ms。
这意味着你可以仅仅使用一台linux服务器接受每天百万用户的访问,并且慢的跟个傻猴似的,就算用更新的硬件。
虽然,就算你有一堆用户,也许也不需要超过1台服务器来跑应用,但让我们假设我们是Twitter,需要处理海量的访问量呢?该怎么做?

Hashing the key
第一件事是把KEY进行hash运算并基于hash在不同服务器上处理请求。有大量知名的hash算法,例如ruby客户端自带的consistent hashing
大致意思是你能把key转换成数字,并除以你的服务器数量
 server_id = crc32(key) % number_of_servers
这里还有大量因为添加一台服务器产生的问题,但这仅仅是大致的意思,哪怕使用一个类似consistent hashing的更好索引算法,
是不是key就可以分布式访问了呢?所有用户数据都分布在不同的服务器上,没有inter-keys使用到(比如SINTER,否则你需要注意要在同一台服务器上进行)
这是Redis不像memcached一样强制指定索引算法的原因,需要应用来指定。另外,有几个key访问的比较频繁。

特殊的Keys
比如每次发布新帖,我们都需要增加global:nextPostId。单台服务器会有大量增加的请求。如何修复这个问题呢?一个简单的办法是用一台专门的服务器来处理增加请求。
除非你有大量的请求,否则矫枉过正了。另一个小技巧是ID并不需要真正地增加,只要唯一即可。这样你可以使用长度为不太可能发生碰撞的随机字符串(除了MD5这样的大小,几乎是不可能)。
完工,我们成功消除了水平分割带来的问题。

另一个问题是global:timeline。这里有个不是解决办法的解决办法,你可以分别保存在不同服务器上,并且在需要这些数据时从不同的服务器上取出来,或者用一个key来进行排序。
如果你确实每秒有这么多帖子,你能够再次用一台独立服务器专门处理这些请求。请记住,商用硬件的Redis能够以100000/s的速度写入数据。我猜测对于twitter这足够了。
请随意在下面评论处提问以及反馈。

PHP + Redis 实现一个简单的twitter相关推荐

  1. php redis 唯一id,PHP + Redis 实现一个简单的twitter

    Redis是NoSQL数据库中一个知名数据库,在新浪微博中亦有部署,适合固定数据量的热数据的访问. 作为入门,这是一篇很好的教材,简单描述了如何使用KV数据库进行数据库的设计.新的项目www.xiay ...

  2. 小王,在 Java 中如何利用 redis 实现一个分布式锁服务呢???

    作者:杨高超 juejin.im/post/5a4984af6fb9a0450b66bc57 在现代的编程语言中,接触过多线程编程的程序员多多少少对锁有一定的了解.简单的说,多线程中的锁就是在多线程环 ...

  3. SpringBoot+Redis 实现一个微博热搜!

    大家好,我是宝哥! 使用java和redis实现一个简单的热搜功能,具备以下功能: 搜索栏展示当前登陆的个人用户的搜索历史记录,删除个人历史记录 用户在搜索栏输入某字符,则将该字符记录下来 以zset ...

  4. java 分布式任务_一个简单的基于 Redis 的分布式任务调度器 —— Java 语言实现...

    折腾了一周的 Java Quartz 集群任务调度,很遗憾没能搞定,网上的相关文章也少得可怜,在多节点(多进程)环境下 Quartz 似乎无法动态增减任务,恼火.无奈之下自己撸了一个简单的任务调度器, ...

  5. redisdemo php,一个简单的用redis做秒杀支撑的demo (PHP版)

    用redis做秒杀的库存扣除, 限制每个账号只能抢购一次, 这个简单的demo使用了string, hash, list三种基本类型. 用string类型的int值来存储剩余库存, 并在抢购成功后减1 ...

  6. 使用redis实现缓存_用下一个js实现一个简单的redis缓存

    使用redis实现缓存 For most websites, the changing pieces don't actually vary that often. That immutability ...

  7. 一个简单的字符串,为什么 Redis 要设计的如此特别

    一个简单的字符串,为什么 Redis 要设计的如此特别 五种基本数据类型之字符串对象 二进制安全字符串 什么是二进制安全的字符串 sds 空间分配策略 空间预分配 惰性空间释放 sds 和 C 语言字 ...

  8. python 消息队列 go_gmq: gmq是基于redis提供的特性,使用go语言开发的一个简单易用的消息队列;支持延迟任务,异步任务,超时任务,优先级任务...

    1. 概述 gmq是基于redis提供的特性,使用go语言开发的一个简单易用的队列;关于redis使用特性可以参考之前本人写过一篇很简陋的文章Redis 实现队列; gmq的灵感和设计是基于有赞延迟队 ...

  9. python如何编写数据库_如何在几分钟内用Python编写一个简单的玩具数据库

    python如何编写数据库 MySQL, PostgreSQL, Oracle, Redis, and many more, you just name it - databases are a re ...

最新文章

  1. 图像去重imagededup
  2. 最新「Nature Index中国」出炉:北大领跑50所国内顶级研究机构
  3. 跳過 Windows RT的UI
  4. mysql修改数据库级别_设置数据库兼容级别的两种方法
  5. Spring自动装配Bean
  6. indexOf()方法的使用,截取字符串,字符串截取,切割字符串,split(),join(),Replace()
  7. python利用pandas存数据并且展示csv
  8. 查看磁盘I/O操作信息
  9. 加密+拜占庭将军_简单读懂拜占庭容错
  10. 设计模式-第七篇之门面模式
  11. SQL Server 2008 R2安装功能选择
  12. MPI + OpenMP实现快速排序
  13. C语言实现万年历记事本,简单的日历记事本jQuery插件e-calendar(带样式美化)
  14. 深入理解计算机系统家庭作业第四章(4.43-4.54)
  15. html飞机翼布局,基础知识 | 飞机客舱布局及主要设施介绍
  16. 微信聊天记录删除了怎么恢复?最简单快捷的恢复方式看这里
  17. 阿里云盘电脑客户端内测版
  18. 数据结构(八):排序 | 插入排序 | 希尔排序 | 冒泡排序 | 快速排序 | 简单选择排序 | 堆排序 | 归并排序 | 基数排序 | 外部排序 | 败者树 | 置换-选择排序 | 最佳归并树
  19. 51单片机和32单片机有什么区别?该从哪个开始入门学习?
  20. c++ 使用结构体实现有理数库

热门文章

  1. linux arm内核栈切换,ARM Linux中断发生时内核堆栈切换
  2. mysql 视图怎么调用方法_mysql 视图的使用
  3. mysql5.7.13_mysql5.7.13.zip安装(windows)
  4. webservice xml java_java访问WebService接口返回xml
  5. oracletns中不存在名称为_oracle tnsname.ora中的SERVICE_NAME 代表实例的名称还是代表全局数据库的名称?...
  6. 机器学习流行算法一览
  7. C#超市管理系统试题
  8. 基于机器学习的AI预测更智能?
  9. 郑州尚新科技--J2EE考试题
  10. selenium之如何使用cssSelector定位页面元素