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

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

作为入门,这是一篇很好的教材,简单描述了如何使用KV数据库进行数据库的设计。新的项目www.xiayucha.com亦采用Redis + MySQL进行开发,考虑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. 180626-Spring之借助Redis设计一个简单访问计数器

    文章链接:https://liuyueyi.github.io/hexblog/2018/06/26/180626-Spring之借助Redis设计一个简单访问计数器/ Spring之借助Redis设 ...

  2. Python使用Redis实现一个简单作业调度系统

    Python使用Redis实现一个简单作业调度系统 概述 Redis作为内存数据库的一个典型代表,已经在非常多应用场景中被使用,这里仅就Redis的pub/sub功能来说说如何通过此功能来实现一个简单 ...

  3. 手把手教你用redis实现一个简单的mq消息队列(java)

    众所周知,消息队列是应用系统中重要的组件,主要解决应用解耦,异步消息,流量削锋等问题,实现高性能,高可用,可伸缩和最终一致性架构.目前使用较多的消息队列有 ActiveMQ,RabbitMQ,Zero ...

  4. SpringBoot学习笔记(13)----使用Spring Session+redis实现一个简单的集群

    session集群的解决方案: 1.扩展指定server 利用Servlet容器提供的插件功能,自定义HttpSession的创建和管理策略,并通过配置的方式替换掉默认的策略.缺点:耦合Tomcat/ ...

  5. 使用redis设计一个简单的分布式锁

    最近看了有关redis的一些东西,了解了redis的一下命令,就记录一下: redis中的setnx命令: 关于redis的操作命令,我们一般会使用set,get等一系列操作,数据结构也有很多,这里我 ...

  6. 用lua+redis实现一个简单的计数器功能 (二)

    环境已经搭建完毕 传送门 计数方案 就目前来看nginx是最快的服务 我在设计方案时选择信任redis作为存储库,不做穿透处理,由于目前redis集群方案还不成熟,只在这里做了主备方案.想做集群方案的 ...

  7. 用lua+redis实现一个简单的计数器功能 (一)

    首先安装环境 依赖环境有 luajit http://luajit.org ngx_devel_kit https://github.com/simpl/ngx_devel_kit echo-ngin ...

  8. Redis中的简单事物以及消息订阅发布

    Redis支持简单的事物,但是没有mysql的Innodb支持的那么的完善 我们接下来看一下Redis和Mysql的事物的一个对比:   MySQL Redis 开启 start transactio ...

  9. java架构之路-(Redis专题)简单聊聊redis分布式锁

    这次我们来简单说说分布式锁,我记得过去我也过一篇JMM的内存一致性算法,就是说拿到锁的可以继续操作,没拿到的自旋等待. 思路与场景 我们在Zookeeper中提到过分布式锁,这里我们先用redis实现 ...

随机推荐

  1. 【转】ByteArrayOutputStream和ByteArrayInputStream详解

    ByteArrayOutputStream类是在创建它的实例时,程序内部创建一个byte型别数组的缓冲区,然后利用ByteArrayOutputStream和ByteArrayInputStream的 ...

  2. Java for LeetCode 200 Number of Islands

    Given a 2d grid map of '1's (land) and '0's (water), count the number of islands. An island is surro ...

  3. mybatis随机生成可控制主键的方式

    mybatis生成的主键,一般都是用数据库的序列,可是还有不同的写法,比如: 一.NUMBER类型的主键 <insert id="insertPeriodical" para ...

  4. Windows下安装Cygwin及包管理器apt-cyg(转)

    本文为转载文章: http://www.2cto.com/os/201212/176551.html Cygwin可以在Windows下使用unix环境Bash和各种功能强大的工具,对于Linux管理 ...

  5. July 21st, Week 30th Thursday, 2016

    What youth deemed crystal, age finds out was dew. 年少时的水晶,在岁月看来不过是露珠. As time goes by, we are gradual ...

  6. myeclipse中的js文件报错

    方法一:myeclipse9 很特殊 和 myeclipse10 不一样,所以myeclipse9 不能使用该方法. 方法二: 为了做一个页面特效,导入了一个jquery文件,怎想,myeclipse ...

  7. Solr学习笔记(一)

    最近准备为一个产品做一个站内的搜索引擎,是一个java产品.由于原来做过Lucene.net,所以自然而然的就想到了使用Lucene.在复习Lucene的过程中发现了Solr这个和Lucene绑定在一 ...

  8. Android悬浮窗注意事项

    一 动画无法运行 有时候,我们对添加的悬浮窗口,做动画的时候,始终无法运行. 那么,这个时候,我们可以对要做动画的View,再添加一个parent,即容器.将要做动画的View放入容器中. 二 悬浮窗 ...

  9. HTML 学习新理论

    一直觉得没有时间,现在看到这样的网站: Bootstrap学习http://www.runoob.com/bootstrap/bootstrap-environment-setup.html Less ...

  10. git branch -D 大写的D 删除分支

    今天删除本地分支 git branch -d XXX 提示:  the branch  XXX is not fully merged 原因:XXX分支有没有合并到当前分支的内容 解决方法:使用大写的 ...