热门标签 | HotTags
当前位置:  开发笔记 > 后端 > 正文

Redis的分布式锁详解

分布式锁的实现方式也有很多种,基于zookeeper的,基于分布式数据库的以及本文我们要介绍的基于Redis的。为了系统而全面的介绍基于Redis的实现方案,本文主要涉及如下内容:

分布式锁的实现方式也有很多种,基于zookeeper的,基于分布式数据库的以及本文我们要介绍的基于Redis的。为了系统而全面的介绍基于Redis的实现方案,本文主要涉及如下内容:




    1. Redis单节点锁的实现

    2. Redis集群锁的实现(RedLock)

    3. Redis锁的使用场景分析

    4. 一个RedLock的python实现及需要的改进方案


 


单节点Redis的锁实现

单节点Redis的锁实现主要有三大点: SETNX的使用、超时时间设置以及释放锁前先检查锁,下面一一介绍。


SETNX

前面我们说过,锁的实现一定是要基于各个工作流都能看到的公共区域实现。而Redis本身作为一个内存数据库就天然的适合锁的实现。就跟击鼓传花似的,只要有一个进程拿到了这个花,别的进程就必须得有办法知道这个花被占用了,这样他就可以原地等待直到这个花被释放出来,各个进程再去抢这朵花。Redis的SETNX命令非常适合排他性的锁实现。该命令只会在键不存在的情况下才会为键设置值,一个进程申明独占某个资源的方式就是对一个键使用SETNX,这样当别的进程再去使用这个命令设置这个键的时候就会失败进而无法获得锁,这样满足了我们的前言中锁的互斥性原则。这时我们的程序和我们的锁获得与释放可以这么写。

def Job():
identifier = str(uuid.uuid4())
lock_name = "mylock"
try:
#一直循环等待获得锁
while True:
#如果获得锁c成功就完成工作并退出循环
if lock(lock_name):
do_something()
break
finally:
#释放锁
unlock(lock_name)


def lock(lock_name):
return redis.setnx(lock_name, identifier)
def unlock(lock_name):
redis.delete(lock_name)

超时时间设置

但是只用上述方法,避免死锁的性质能否被满足呢?假设当前获得锁的进程挂掉了,那么由于当前进程没有释放锁,那么其他的进程就会永远都没法获得锁(因为setnx命令会一直返回False)。所以我们不能只依赖工作进程自己主动去释放锁。还需要给锁加一个生存期间,如果锁过了生存期间还没有被释放,则Redis强制释放该锁。这样,即使获得锁的进程挂掉了,其他的进程还是可以在一段时间后获得锁而不会现如死锁的困境。Redis的超时限制特性可以解决这个问题。此时,我们的锁获得和释放可以这么写:

def lock(lock_name, lock_timeout):
if redis.setnx(lock_name, identifier):
redis.expire(lock_name,lock_timeout)
return True
return False
def unlock(lock_name):
redis.delete(lock_name)

释放锁时先检查

带有超时特性的锁满足了避免死锁的性质,但是这种auto release的机制的却很有可能破坏锁的互斥性质。举个例子,比如当进程A获得了锁,并设置锁的超期时间为10s,进程A由于处理任务花费的时间较长,10s后任务还没处理完,但是此时锁已经过期被释放了,进程B重新获得了锁(不要忘了,锁实际上是对资源独占的一种申明)。这个时候由于进程A没有主动释放锁,进程B又获得了锁,对A、B来讲,他们都认为自己独占了资源,当他们按照独占资源的想法去操作资源的时候就可能会导致冲突。同时还存在的另一个问题,A的锁由于超时被释放了且B重新获得了锁,但是A并不知道自己的锁已经被释放了,A做完处理工作之后开始释放锁,然而这时释放的其实是B的锁(因为都删除的是mylock键)。B吃着火锅唱着歌,回头一看,锁没了==,很糟糕。对于第一个问题的解决方案我们后续介绍,但是对于第二个误释放别人的锁,我们可以在unlock中使用如下步骤释放锁:



  • 从redis中使用GET命令得到mylock的值,并检查锁对应的值是不是自己当时存的identifier

  • 如果是,那就使用DELETE释放,如果不是,说明自己的锁已经被自动释放了,则不做任何处理。

为保证上述整个操作的原子性,防止在GET之后,DELETE之前的期间Redis恰巧把锁给自动释放了,一般把上述的过程写到一个Lua的脚本中提交给Redis执行,因为Redis执行Lua脚本中的命令是原子性质的。

代码如下:

def unlock(lock_nameidentifier):
unlock_script = """
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end"""
redis.eval(unlock_script, 1, lock_name, identifier)

到这里Redis单节点的锁实现已经基本介绍完了,主要有三点



  1. 使用SETNX实现排他性锁

  2. 使用超时限制特性来避免死锁

  3. 释放锁的时候需要进行检查来避免误释放别的进程的锁

那么我们接下来要讨论的一个问题是:如果在使用的过程中Redis突然挂了会怎样?Redis介绍1中介绍过Redis是有持久化机制的。当Redis挂了之后,再次恢复的时候可以从磁盘上把数据恢复出来。

但是问题在于如果A进程获得了锁(即在Redis中使用SETNX设置了键),在Redis把这个操作持久化到磁盘之前Redis就挂了的话,那么Redis再次启动的时候,通过磁盘持久化文件恢复出来的数据中是没有这个锁的信息的。当B去尝试获得锁时发现自己可以获得锁,但是此时A认为自己还没有释放锁,于是又导致了A、B两个进程同时认为自己持有锁的情况发生,破坏了性质1。

上述的问题主要发生本质原因还是在于单点问题,当我们只依赖单个Redis节点时,我们就只能承受单点不可靠带来的风险。而我们都知道,在分布式系统中,常用的应对单点风险的解决方案就是冗余节点。那我们能不能多启动几个Redis,保留键的多副本,这样即使一个Redis因为意外挂掉了,我们也可以使用别的Redis服务器继续正常服务?答案是YES! 这就是我们接下来要介绍的RedLock。


RedLock

RedLock是Redis之父Salvatore Sanfilippo提出来的基于多个Redis实例的分布式锁的实现方案。其核心思想就在于使用多个Redis冗余实例来避免单Redis实例的不可靠性。比如我们采用5个Redis实例,我们可以把5个Redis全部部署到同一台机器上,也可以把5个Redis部署在5个不同的机器上。一般为了实现更好的读写性能以及抗风险能力,我们选择部署5个Redis在5个机器上。

基于Redis的锁的实现本质都是针对数据库的读写操作。那么采用5个Redis节点我们就需要考虑副本读写的一致性问题。基于不同的准则,我们有不同的权衡,比如写入的副本一致性,可以要求到只要一个节点写入成功则成功,或者依据法团准则,写入(N/2 +1)个节点后才成功,或者写入所有的节点后才成功等等。RedLock采用的就是依据法团准则的方案:

多副本情况下的一致性准则

假如我们现在有5台机器上跑着5个Redis服务器,RedLock的获取步骤如下:



  1. 依次向每个Redis服务器获取锁(即使用SETNX设置键值), 如果获得至少(N/2 +1)个服务器的锁(在本例中就是3),则认为获得锁成功

  2. 如果第一步中获得的锁的个数少于3个,则认为获得锁失败。为保证其他节点获得锁正常,在所有Redis节点上释放锁(因为有可能有的节点设置成功了锁)

释放锁的过程也很简单,就和上述的单节点的锁释放步骤一致,只不过改成了在所有的节点上都执行一遍锁释放。

那我们先来看看这样是否能够解决如果一个Redis节点挂了出现锁被同时两个客户端获得的情况。假设我们总共有5台机器,客户端A从R1,R2,R3上获得了锁,但是在R1未来得及把这个操作持久化到磁盘上时,R1挂掉了。此时R1重启之后,其从磁盘上恢复的数据并没有A的锁的信息,所以进程B可以从R1,R4,R5再次获得锁(满足法团协议),这样就又造成了冲突。为解决这个问题,Redis作者提出了延迟重启的解决方案。


延迟重启

假设R1挂掉了之后,我们不再让R1提供服务会怎么样呢?首先可以保证的时,上述的A、B同时获得锁的情况不会发生,因为B最多从R4,R5获得两个锁,不满足法团协议。但是显然我们不能让R1永远的不提供服务。但我们可以让他等一会再重新对外提供服务,那得等待多久呢?我们可以发现的是,受R1挂了然后接着重启这件事影响的锁只是在R1挂的那个时刻R1上存的所有锁,之后创建的新锁或者没在R1上存储的锁都不受R1挂了这件事的影响。而我们前面又知道,在设置锁的时候,为避免陷入死锁的困境,我们给每个锁设置了一个过期时间。那R1只需要等到R1挂掉的那个时刻其上面所有的键都过期之后再对外提供服务即可,即可以等待一个所有键的MAX TTL即可。但是这个MAX TTL我们是没法只通过统计R1上的键准确的知道的,因为R1有一部分键的信息由于没有持久化到磁盘上已经丢失了。但是为了保险,我们可以通过统计当前时刻所有机器上的MAX TTL,然后取所有机器的MAX TTL即可。这样我们就可以保证R1加入服务后,其上所有的锁都肯定已经失效了。有了延迟重启和多Redis实例的解决方案,我们对Redis节点可能会挂这个风险有了更强的的抵抗能力。

但是软件行业里显然没有任何银弹方案。引入了副本在提升鲁棒性的同时也对整个系统引入了复杂性和不确定性。我们来看这样一个例子:我们有5个Redis服务器,客户端A试图获得一个超时期限为10s的锁。按照上述的流程,我们是从R1到R5依次尝试获得锁mylock,当前时间戳假设是12300



  1. 我们先从R1获得了锁,此时R1机器上记录的mylock的到期时间戳为12310

  2. 我们再尝试从R2获得锁,由于网络的问题,等R2获得请求时,时间已经到了12302了,那么R2机器上记录的mylock的到期时间戳即为12312

  3. 同理,当我们再次尝试从R3获得锁时,网络畅通,当前时间戳仍然是12302,R3上记录的mylock的到期时间戳为12312

发现了么,R1, R2,R3的到期时间戳是不一样的。如果我们按照三个机器的最大时间戳来当作mylock的过期时间戳会导致如果客户端B在时间戳为12311时尝试获得mylock锁,由于R1中mylock已经过期,则B从R1,R4,R5获得锁,满足法团协议,获得获得锁成功,此时出现A、B同时得到锁。所以显然不能使用最大时间戳来当作过期时间戳,使用理论时间戳(即开始设置时,本地机器的时间戳+TTL)是最保险的方案,因为他肯定是最小的。为了避免在获取锁的过程中因为网络的问题占用了过多的锁可使用时间,每次从一个机器获取锁的时候都在网络上只等一个非常小的时间,超时还未获得锁就立马尝试下一个节点。

到这似乎问题都被解决了,那是不是RedLock就真的完美了么?显然不是


RedLock的问题

RedLock可能的最大问题在于对各个机器时间流速的一致性假设。什么叫时间流速一致性假设呢?就是机器A上过了一分钟,机器B上也过了几乎一分钟。读者看到这会想,这不是废话么,难道还有流速不一致的情况。其问题在于,一个机器的时间是有可能跳变的。比如管理员重新校正机器时间,或者机器的时钟模块收到外部更新信号,重新校对时间等。这就有可能导致如下的情况出现:客户端A从R1,R2,R3获得了锁,并设置了过期时间为10s,但是在其中的某个时刻,可能R1的时间被重新校正,“快进了10s”,调整完时间之后,R1上的锁就已经过期了。此时B再次申请同样的锁,则可以从R1,R4,R5获得锁,满足法团协议。获得锁成功。当然,这种问题发生的概率可能是足够低的,能不能承受这样的情况带来的损失决定着是否采用Redis来实现分布式锁。

第二个问题出现在auto release的机制上。auto release的使用解决了持有锁的进程不能正常释放锁导致的死锁问题,但是同时带来的问题可能就是如下这种情况,即客户端1还没使用完锁但是锁已经过期了,这时客户端2获得了锁,结果客户端1、客户端2都对资源进行了使用。当然,设置过期时间实际上是租约机制的一种,但是RedLock的算法中没有提到续租的相关机制。后来开源的实现中,Java的redisson实现了基于watchdog的续租机制,就比较好的缓解了该问题。

那RedLock有这样的问题的话,我们是否该用RedLock呢?我觉得回答这个问题我们首先需要明确我们为什么需要使用锁。这个问题我觉得Martin的博客中总结的非常好,所以直接拿来用。即用锁主要有两个目的,第一便是为了效率着想,比如我们不想让一个耗时的任务被重复的执行,第二个目的便是为了程序的正确性考虑。比如12306的订票问题,如果不使用锁,很容易出现剩余一张票,但是10个人都在网站上抢到了这张票的情况。



  • Efficiency: Taking a lock saves you from unnecessarily doing the same work twice (e.g. some expensive computation). If the lock fails and two nodes end up doing the same piece of work, the result is a minor increase in cost (you end up paying 5 cents more to AWS than you otherwise would have) or a minor inconvenience (e.g. a user ends up getting the same email notification twice).

  • Correctness: Taking a lock prevents concurrent processes from stepping on each others’ toes and messing up the state of your system. If the lock fails and two nodes concurrently work on the same piece of data, the result is a corrupted file, data loss, permanent inconsistency, the wrong dose of a drug administered to a patient, or some other serious problem.

当我们基于Efficiency的目标的时候,基于Redis的的分布式锁是很好的实现方式,但是实际上如果只是为了Efficiency考虑的话,RedLock的使用就没有必要了,基于单节点的Redis分布式锁就完全能够满足需要。如果程序的正确性严格的依赖于锁的使用的话,那么就看用户是否能够承受可能的时间不一致性带来的风险,如果能,那么就可以使用RedLock,如果不能那么就不要使用RedLock。那么使用什么呢?虽然我没用过,但是貌似听说常用的解决方案就是基于zookeeper的分布式锁。


RedLock的分布式实现

俗话说得好,Talk is cheap, show me the code。RedLock的思想本身不复杂,所以实现也非常的简单。这里给出的RedLock的python实现是Redis官网列出来的实现,应该是比较靠谱的。源代码很简单,就放个git链接给大家自己去研究吧。对着上面的RedLock和单机版的锁实现,这段代码大家就秒懂了。

但我个人认为这个python的实现有个需要完善的地方在于没有watchdog的机制来实现续租,包括这个开源代码中的issue中也提到了这个问题。java的redisson实现了watchdog的feature,但是这个里面没有。我觉得基于watchdog的续租需要满足如下的特性:



  1. 如果程序正常运行,但是锁的到期时间快到了,那么就应该续租

  2. 如果程序终止,则停止续租

  3. 如果程序使用锁期间,陷入死循环,则停止续租。

就目前而言,我觉得在python里面实现锁的续租机制的话,基于多线程应该是个比较好的解决方案之一。具体的研究和实现可以在下一次的博客进行分享~


总结

本篇介绍了基于单节点Redis服务器的分布式锁实现以及为了应对单点风险而基于多节点的分布式锁RedLock实现。无论是单节点锁还是多节点锁,整体的思想还是比较简单的。如果要用基于Redis的锁,我们一定要先衡量我们的场景对锁的要求是否足够严格,如果有非常严格的正确性要求,那么就可能要三思一下是否使用基于Redis的分布式锁了。

 

redis分布式锁就几个方法

1、setnx(key,value) 返回boolean 1为获取锁 0为没获取锁

2、expire() 设置锁的有效时间

3、getSet(key,value) 获取锁当前key对应的锁的有效时间

4、deleteKey() 删除锁

setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

过程分析:

当A通过setnx(lockkey,currenttime+timeout)命令能成功设置lockkey时,即返回值为1,过程与原理1一致;
当A通过setnx(lockkey,currenttime+timeout)命令不能成功设置lockkey时,这是不能直接断定获取锁失败;因为我们在设置锁时,设置了锁的超时时间timeout,当当前时间大于redis中存储键值为lockkey的value值时,可以认为上一任的拥有者对锁的使用权已经失效了,A就可以强行拥有该锁;具体判定过程如下;
A通过get(lockkey),获取redis中的存储键值为lockkey的value值,即获取锁的相对时间lockvalueA
lockvalueA!=null && currenttime>lockvalue,A通过当前的时间与锁设置的时间做比较,如果当前时间已经大于锁设置的时间临界,即可以进一步判断是否可以获取锁,否则说明该锁还在被占用,A就还不能获取该锁,结束,获取锁失败;
步骤4返回结果为true后,通过getSet设置新的超时时间,并返回旧值lockvalueB,以作判断,因为在分布式环境,在进入这里时可能另外的进程获取到锁并对值进行了修改,只有旧值与返回的值一致才能说明中间未被其他进程获取到这个锁
lockvalueB == null || lockvalueA==lockvalueB,判断:若果lockvalueB为null,说明该锁已经被释放了,此时该进程可以获取锁;旧值与返回的lockvalueB一致说明中间未被其他进程获取该锁,可以获取锁;否则不能获取锁,结束,获取锁失败。

 

举个例子:

现在有两台redis服务器,两个进程同时访问redis代理,并且代理按顺序指向redis服务器,当访问A服务器时候,在setnx()执行后并且expiro()执行前A宕机了,这时候B在执行的时候就先去判断 系统当前时间是否大于oldtime+expiro()设置的时间设置的时间,如果大于oldtime+expiro()设置的时间,可以证明A事物的锁使用权已经失效了,我们就可以删除事物A的锁,然后在事物B上重新生成个锁。

 

 

1、什么是分布式锁

分布式锁就是 多个服务器都有redis,但是共用同一套资源。

2、分布式锁实现原理

主要就两个方法

1、getlock() 获取锁方法

2、releaselock()释放锁方法

 

然后我们看一下 getlock()方法是怎么写的

 

当生成锁的时候会有一个key也就是上面的taskId,existskey()意思是在分布式的key中是否有和taskId一致的(这个taskId可以认识取随机数),如果没有一致的就获取锁

然后是releaselock()这个方法里就执行了 deletekey()删除key方法

 

上述做法有几个问题

1、当获取锁之后同时还没有删除key,这时候断网了,那么就会导致我这个锁永远都无法delete

2、同一时间被不同服务器的调用获取到锁

先说第一个问题:我们可以通过设置一个释放锁的时间来解决这个问题比如2秒,如果断网了或者其他问题2秒之后自动释放

在说第二个问题:获取锁进行原子性,也就是说在获取锁的时候多一步操作,就是当前key不存在时候才可以获取锁

SET my_key my_value NX PX milliseconds

其中,NX表示只有当键key不存在的时候才会设置key的值,PX表示设置键key的过期时间,单位是毫秒。

 

就算是这样还会有问题:

3、当时间设置2秒,但是我的逻辑代码执行了3秒,这时候这个锁会被别的请求获取到

其实解决也很简单,我们在释放锁的时候设置一个随机数,在进行deletekey的时候进行随机数的判断如果相同才delete当然这个随机数是在逻辑方法执行完之后生成的

 

本文主要给大家介绍了关于redis实现加锁的几种方法,分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍吧。

1. redis加锁分类

redis能用的的加锁命令分表是INCR、SETNX、SET

2. 第一种锁命令INCR

这种加锁的思路是, key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作进行加一。
然后其它用户在执行 INCR 操作进行加一时,如果返回的数大于 1 ,说明这个锁正在被使用当中。

    1、 客户端A请求服务器获取key的值为1表示获取了锁 

    2、 客户端B也去请求服务器获取key的值为2表示获取锁失败

    3、 客户端A执行代码完成,删除锁

    4、 客户端B在等待一段时间后在去请求的时候获取key的值为1表示获取锁成功

    5、 客户端B执行代码完成,删除锁










1

2


$redis->incr($key);

$redis->expire($key, $ttl); //设置生成时间为1秒


3. 第二种锁SETNX

这种加锁的思路是,如果 key 不存在,将 key 设置为 value

如果 key 已存在,则 SETNX 不做任何动作

    1、 客户端A请求服务器设置key的值,如果设置成功就表示加锁成功

    2、 客户端B也去请求服务器设置key的值,如果返回失败,那么就代表加锁失败

    3、 客户端A执行代码完成,删除锁

    4、 客户端B在等待一段时间后在去请求设置key的值,设置成功

    5、 客户端B执行代码完成,删除锁   










1

2


$redis->setNX($key, $value);

$redis->expire($key, $ttl);


4. 第三种锁SET

上面两种方法都有一个问题,会发现,都需要设置 key 过期。那么为什么要设置key过期呢?如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测。

但是借助 Expire 来设置就不是原子性操作了。所以还可以通过事务来确保原子性,但是还是有些问题,所以官方就引用了另外一个,使用 SET 命令本身已经从版本 2.6.12 开始包含了设置过期时间的功能。

    1、 客户端A请求服务器设置key的值,如果设置成功就表示加锁成功

    2、 客户端B也去请求服务器设置key的值,如果返回失败,那么就代表加锁失败

    3、 客户端A执行代码完成,删除锁

    4、 客户端B在等待一段时间后在去请求设置key的值,设置成功

    5、 客户端B执行代码完成,删除锁










1


$redis->set($key, $value, array('nx', 'ex' => $ttl)); //ex表示秒


5. 其它问题

虽然上面一步已经满足了我们的需求,但是还是要考虑其它问题?

    1、 redis发现锁失败了要怎么办?中断请求还是循环请求?

    2、 循环请求的话,如果有一个获取了锁,其它的在去获取锁的时候,是不是容易发生抢锁的可能?

    3、 锁提前过期后,客户端A还没执行完,然后客户端B获取到了锁,这时候客户端A执行完了,会不会在删锁的时候把B的锁给删掉?

6. 解决办法

针对问题1:使用循环请求,循环请求去获取锁

针对问题2:针对第二个问题,在循环请求获取锁的时候,加入睡眠功能,等待几毫秒在执行循环

针对问题3:在加锁的时候存入的key是随机的。这样的话,每次在删除key的时候判断下存入的key里的value和自己存的是否一样










1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16


do { //针对问题1,使用循环

  $timeout = 10;

  $roomid = 10001;

  $key = 'room_lock';

  $value = 'room_'.$roomid; //分配一个随机的值针对问题3

  $isLock = Redis::set($key, $value, 'ex', $timeout, 'nx');//ex 秒

  if ($isLock) {

    if (Redis::get($key) == $value) { //防止提前过期,误删其它请求创建的锁

      //执行内部代码

      Redis::del($key);

      continue;//执行成功删除key并跳出循环

    }

  } else {

    usleep(5000); //睡眠,降低抢锁频率,缓解redis压力,针对问题2

  }

} while(!$isLock);


7. 另外一个锁

以上的锁完全满足了需求,但是官方另外还提供了一套加锁的算法,这里以PHP为例










1

2

3

4

5

6

7

8

9

10

11

12

13


$servers = [

  ['127.0.0.1', 6379, 0.01],

  ['127.0.0.1', 6389, 0.01],

  ['127.0.0.1', 6399, 0.01],

];

 

$redLock = new RedLock($servers);

 

//加锁

$lock = $redLock->lock('my_resource_name', 1000);

 

//删除锁

$redLock->unlock($lock)


上面是官方提供的一个加锁方法,就是和第6的大体方法一样,只不过官方写的更健壮。所以可以直接使用官方提供写好的类方法进行调用。官方提供了各种语言如何实现锁。

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对脚本之家的支持。



推荐阅读
author-avatar
a5365258784
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有