> 헤드라인 > 본문

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

藏色散人
풀어 주다: 2022-05-13 20:14:50
원래의
4288명이 탐색했습니다.

12306 표 잡기, 극단적인 동시성이 불러일으키는 생각?

휴일마다 고향으로 돌아가 1, 2선 도시를 여행하는 사람들은 거의 항상 기차표를 구하는 문제에 직면합니다. 티켓을 예매했다가 티켓이 공개되자마자 잃어버리는 상황은 다들 경험해보셨을 거라 생각합니다. 특히 춘절 기간 동안 사람들은 12306을 사용할 뿐만 아니라 "Zhixing" 및 기타 티켓 예매 소프트웨어도 고려합니다. "12306 서비스"는 전 세계 어떤 플래시 세일 시스템도 능가할 수 없는 QPS를 가지고 있습니다. 수백만 동시성은 정상일 뿐입니다! 저자는 "12306"의 서버 아키텍처를 구체적으로 연구했으며 시스템 설계의 많은 하이라이트를 배웠습니다. 여기서는 100만 명이 동시에 10,000장의 기차표를 구매하고 있을 때 정상적인 서비스를 제공하는 방법에 대한 예를 여러분과 공유하고 시뮬레이션할 것입니다. , 안정적인 서비스. github 코드 주소

관련 추천: "수백만 개의 데이터 동시성 솔루션(이론 + 실무)"

1. 대규모 동시성 시스템 아키텍처

고동시성 시스템 아키텍처는 다음을 사용합니다. 분산 서비스의 상위 계층인 클러스터 배포는 계층별 로드 밸런싱을 갖추고 있으며 시스템의 고가용성을 보장하기 위해 다양한 재해 복구 수단(이중 화재 전산실, 노드 내결함성, 서버 재해 복구 등)을 제공합니다. , 그리고 트래픽은 다른 서버에 대한 다양한 로드 기능 및 구성 전략에 따라 균형을 이룹니다. 다음은 간단한 도식 다이어그램입니다.

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

1.1 로드 밸런싱 소개

위 그림은 서버에 대한 사용자 요청이 세 가지 계층의 로드 밸런싱을 경험했음을 설명합니다. 다음은 이 세 가지 유형에 대한 간략한 소개입니다. 로드 밸런싱:

  • OSPF(Open Shortest Link First)는 내부 게이트웨이 프로토콜(IGP)입니다. OSPF는 라우터 간 네트워크 인터페이스의 상태를 알려 링크 상태 데이터베이스를 구축하고 최단 경로 트리를 생성합니다. OSPF는 라우팅 인터페이스에서 비용 값을 자동으로 계산하지만 인터페이스의 비용 값을 수동으로 지정할 수도 있습니다. 자동으로 계산된 값이 우선 적용됩니다. OSPF에서 계산한 Cost는 인터페이스 대역폭에 반비례합니다. 대역폭이 높을수록 Cost 값은 작아집니다. 타겟까지 Cost 값이 동일한 경로는 로드밸런싱을 수행할 수 있으며, 최대 6개의 링크까지 동시에 로드밸런싱을 수행할 수 있습니다.

  • LVS(Linux VirtualServer)는 IP 로드 밸런싱 기술과 컨텐츠 기반 요청 분산 기술을 이용한 클러스터(Cluster) 기술입니다. 스케줄러는 처리 속도가 매우 좋고 요청을 다른 서버로 균등하게 전송하여 실행하며, 스케줄러는 서버 장애를 자동으로 보호하여 서버 그룹을 고성능, 고가용성 가상 서버로 구성합니다.

  • 누구나 Nginx에 대해 잘 알고 계실 겁니다. 서비스 개발 시 로드 밸런싱에도 자주 사용되는 고성능 http 프록시/리버스 프록시 서버입니다. Nginx가 로드 밸런싱을 구현하는 세 가지 주요 방법은 폴링, 가중 폴링, IP 해시 폴링입니다. 아래에서는 Nginx의 가중 폴링에 대한 특수 구성 및 테스트를 수행합니다

1.2 Nginx 가중 폴링 시연

Nginx 구현 업스트림 모듈을 통한 로드 밸런싱 구성은 해당 서비스에 가중치 값을 추가할 수 있으며, 구성 중에 서버의 성능 및 로드 용량에 따라 해당 로드가 설정될 수 있습니다. 다음은 로컬에서 포트 3001-3004를 수신하고 각각 1, 2, 3, 4의 가중치를 구성하는 가중치 폴링 로드 구성입니다.

#配置负载均衡
    upstream load_rule {
       server 127.0.0.1:3001 weight=1;
       server 127.0.0.1:3002 weight=2;
       server 127.0.0.1:3003 weight=3;
       server 127.0.0.1:3004 weight=4;
    }
    ...
    server {
    listen       80;
    server_name  load_balance.com www.load_balance.com;
    location / {
       proxy_pass http://load_rule;
    }
}
로그인 후 복사

로컬 /etc/hosts 디렉토리에 www를 구성한 코드를 복사합니다. .load_balance.com 가상 도메인 이름 주소를 사용하고 Go 언어를 사용하여 4개의 http 포트 수신 서비스를 엽니다. 다음은 포트 3001에서 수신하는 Go 프로그램입니다. 다른 포트만 수정하면 됩니다.

package main

import (
"net/http"
"os"
"strings"
)

func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3001", nil)
}

//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
failedMsg :=  "handle in port:"
writeLog(failedMsg, "./stat.log")
}

//写入日志
func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{msg, "\r\n"}, "3001")
buf := []byte(content)
fd.Write(buf)
}
로그인 후 복사

내가 요청할 포트 로그 정보는 ./stat.log 파일에 기록된 다음 ab 스트레스 테스트 도구를 사용하여 스트레스 테스트를 수행합니다.

ab -n 1000 -c 100 http://www.load_balance.com/buy/ticket
로그인 후 복사

통계 로그 결과에 따르면 포트 3001-3004가 100, 200, 300 및 400을 수신한 것으로 표시됩니다. 이것은 nginx에서 구성한 가중치 비율과 잘 일치하며 로드 후 트래픽은 매우 균일하고 무작위입니다. 구체적인 구현에 대해서는 nginx의 업스트림 모듈 구현 소스 코드를 참조할 수 있습니다. 다음은 Nginx의 업스트림 메커니즘 로드 밸런싱

2입니다. 플래시 판매 시스템 선택

원래 언급한 질문으로 돌아가기 : 기차표 깜짝판매 시스템은 어떻게 높은 동시성 조건에서 정상적이고 안정적인 서비스를 제공하는가?

위 소개에서 우리는 사용자 플래시 세일 트래픽이 여러 계층의 로드 밸런싱을 통해 여러 서버에 균등하게 분산된다는 것을 알고 있습니다. 그럼에도 불구하고 클러스터의 단일 머신이 견디는 QPS도 매우 높습니다. 독립 실행형 성능을 극한까지 최적화하는 방법은 무엇입니까? 이 문제를 해결하려면 다음 한 가지를 이해해야 합니다.

일반적으로 티켓 예약 시스템은 주문 생성, 재고 감소, 사용자 결제의 세 가지 기본 단계를 처리해야 합니다. 우리 시스템에서 해야 할 일은 기차표 주문이 과매도되거나 과소판매되지 않았는지 확인하는 것입니다. 효과적이려면 시스템이 매우 높은 동시성을 견뎌야 합니다. 이 세 단계의 순서를 어떻게 더 합리적으로 변경할 수 있는지 분석해 보겠습니다.

2.1 재고를 줄이기 위해 주문하기

사용자의 동시 요청이 서버에 도달하면 주문이 먼저 생성된 다음 재고가 생성됩니다. 차감하고 사용자가 지불하기를 기다리고 있습니다. 이 주문은 우리 대부분이 생각할 첫 번째 솔루션입니다. 이 경우 원자적 작업인 주문이 생성된 후 재고가 줄어들기 때문에 주문이 과매도되지 않도록 보장할 수도 있습니다. 그러나 이로 인해 몇 가지 문제도 발생합니다. 첫 번째는 극단적인 동시성 조건에서 메모리 작업의 세부 사항이 성능에 큰 영향을 미치며, 특히 일반적으로 디스크 데이터베이스에 저장해야 하는 주문 생성과 같은 논리가 성능에 큰 영향을 미치게 된다는 점입니다. 두 번째는 사용자가 악의적으로 주문하고 지불하지 않고 주문하면 서버가 IP와 수를 제한할 수 있지만 재고가 줄어들고 많은 주문이 덜 판매된다는 것입니다. 사용자 구매 주문의 경우에도 이는 좋은 접근 방식이 아닙니다.

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

2.2 결제를 통해 재고 줄이기

사용자가 주문 대금을 지불할 때까지 기다렸다가 재고를 줄이면 첫 번째 느낌은 매출이 줄어들지 않을 것이라는 것입니다. 그러나 이는 동시성 아키텍처의 금기 사항입니다. 왜냐하면 극단적인 동시성 조건에서는 사용자가 많은 주문을 생성할 수 있기 때문입니다. 재고가 0으로 줄어들면 많은 사용자는 자신이 잡은 주문에 대해 지불할 수 없다는 것을 알게 됩니다. "과매도" . 데이터베이스 디스크 IO의 동시 작업은 피할 수 없습니다

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

2.3 재고 보류

위 두 솔루션의 고려 사항을 통해 주문이 생성되는 한 데이터베이스 IO를 자주 작동해야 한다는 결론을 내릴 수 있습니다. 그렇다면 데이터베이스 IO를 직접 운영하지 않아도 되는 솔루션이 있을까요? 이것이 바로 재고 원천징수입니다. 먼저, 과매도되지 않도록 재고를 차감한 다음 사용자 주문이 비동기적으로 생성되어 사용자에 대한 응답이 훨씬 빨라질 것입니다. 그러면 판매량이 많이 발생하도록 하려면 어떻게 해야 할까요? 주문을 받은 후 결제를 하지 않으면 사용자는 어떻게 해야 하나요? 예를 들어, 사용자가 5분 이내에 결제하지 않으면 주문이 만료되며, 주문이 만료되면 새로운 재고가 추가됩니다. 이는 많은 온라인 소매업체에서 채택하는 솔루션이기도 합니다. 회사는 많은 상품을 판매하도록 보장합니다. 주문은 비동기식으로 생성되며 일반적으로 MQ 및 Kafka와 같은 인스턴트 소비 대기열에서 처리됩니다. 주문량이 상대적으로 적으면 주문이 매우 빠르게 생성되며 사용자가 대기열에 들어갈 필요가 거의 없습니다.

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

3. 재고 원천징수의 기술

위의 분석을 보면 재고 원천징수 솔루션이 가장 합리적임이 분명합니다. 재고 공제 세부 사항을 더 자세히 분석해 보겠습니다. 아직 최적화할 여지가 많이 남아 있습니다. 높은 동시성과 사용자 요청에 대한 신속한 응답 하에서 올바른 재고 공제를 보장하는 방법은 무엇입니까?

단일 머신에서 동시성이 낮은 경우 일반적으로 재고 공제를 구현하는 방식은 다음과 같습니다.

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

인벤토리 공제 및 주문 생성의 원자성을 보장하려면 트랜잭션 처리를 사용해야 합니다. 재고가 판단되어 재고가 감소하고 최종적으로 트랜잭션이 제출되면 전체 프로세스에 IO가 많아 데이터베이스의 작동이 차단됩니다. 이 방법은 동시성이 높은 플래시 판매 시스템에는 전혀 적합하지 않습니다.

다음으로 단일 기계 재고 공제 계획을 최적화하겠습니다. 로컬 재고 공제. 일정량의 재고를 로컬 머신에 할당하고 메모리에서 직접 재고를 줄인 다음 이전 로직에 따라 비동기적으로 주문을 생성합니다. 향상된 독립형 시스템은 다음과 같습니다.

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

这样就避免了对数据库频繁的IO操作,只在内存中做运算,极大的提高了单机抗并发的能力。但是百万的用户请求量单机是无论如何也抗不住的,虽然nginx处理网络请求使用epoll模型,c10k的问题在业界早已得到了解决。但是linux系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应。上面我们提到了nginx的加权均衡策略,我们不妨假设将100W的用户请求量平均均衡到100台服务器上,这样单机所承受的并发量就小了很多。然后我们每台机器本地库存100张火车票,100台服务器上的总库存还是1万,这样保证了库存订单不超卖,下面是我们描述的集群架构:

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这100台服务器上有两三台机器因为扛不住并发的流量或者其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存。有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“buffer库存”用来防止机器中有机器宕机的情况。我们结合下面架构图具体分析一下:

12306 티켓 잡기, 극한의 동시성이 만들어내는 생각!

我们采用Redis存储统一库存,因为Redis的性能非常高,号称单机QPS能抗10W的并发。在本地减库存以后,如果本地有订单,我们再去请求redis远程减库存,本地减库存和远程减库存都成功了,才返回给用户抢票成功的提示,这样也能有效的保证订单不会超卖。当机器中有机器宕机时,因为每个机器上有预留的buffer余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不少卖。buffer余票设置多少合适呢,理论上buffer设置的越多,系统容忍宕机的机器数量就越多,但是buffer设置的太大也会对redis造成一定的影响。虽然redis内存数据库抗并发能力非常高,请求依然会走一次网络IO,其实抢票过程中对redis的请求次数是本地库存和buffer库存的总量,因为当本地库存不足时,系统直接返回用户“已售罄”的信息提示,就不会再走统一扣库存的逻辑,这在一定程度上也避免了巨大的网络请求量把redis压跨,所以buffer值设置多少,需要架构师对系统的负载能力做认真的考量。

4. 代码演示

Go语言原生为并发设计,我采用go语言给大家演示一下单机抢票的具体流程。

4.1 初始化工作

go包中的init函数先于main函数执行,在这个阶段主要做一些准备性工作。我们系统需要做的准备工作有:初始化本地库存、初始化远程redis存储统一库存的hash键值、初始化redis连接池;另外还需要初始化一个大小为1的int类型chan,目的是实现分布式锁的功能,也可以直接使用读写锁或者使用redis等其他的方式避免资源竞争,但使用channel更加高效,这就是go语言的哲学:不要通过共享内存来通信,而要通过通信来共享内存。redis库使用的是redigo,下面是代码实现:

...
//localSpike包结构体定义
package localSpike

type LocalSpike struct {
LocalInStock     int64
LocalSalesVolume int64
}
...
//remoteSpike对hash结构的定义和redis连接池
package remoteSpike
//远程订单存储健值
type RemoteSpikeKeys struct {
SpikeOrderHashKey string //redis中秒杀订单hash结构key
TotalInventoryKey string //hash结构中总订单库存key
QuantityOfOrderKey string //hash结构中已有订单数量key
}

//初始化redis连接池
func NewPool() *redis.Pool {
return &redis.Pool{
MaxIdle:   10000,
MaxActive: 12000, // max number of connections
Dial: func() (redis.Conn, error) {
c, err := redis.Dial("tcp", ":6379")
if err != nil {
panic(err.Error())
}
return c, err
},
}
}
...
func init() {
localSpike = localSpike2.LocalSpike{
LocalInStock:     150,
LocalSalesVolume: 0,
}
remoteSpike = remoteSpike2.RemoteSpikeKeys{
SpikeOrderHashKey:  "ticket_hash_key",
TotalInventoryKey:  "ticket_total_nums",
QuantityOfOrderKey: "ticket_sold_nums",
}
redisPool = remoteSpike2.NewPool()
done = make(chan int, 1)
done <- 1
}
로그인 후 복사

4.2 本地扣库存和统一扣库存

本地扣库存逻辑非常简单,用户请求过来,添加销量,然后对比销量是否大于本地库存,返回bool值:

package localSpike
//本地扣库存,返回bool值
func (spike *LocalSpike) LocalDeductionStock() bool{
spike.LocalSalesVolume = spike.LocalSalesVolume + 1
return spike.LocalSalesVolume < spike.LocalInStock
}
로그인 후 복사

注意这里对共享数据LocalSalesVolume的操作是要使用锁来实现的,但是因为本地扣库存和统一扣库存是一个原子性操作,所以在最上层使用channel来实现,这块后边会讲。统一扣库存操作redis,因为redis是单线程的,而我们要实现从中取数据,写数据并计算一些列步骤,我们要配合lua脚本打包命令,保证操作的原子性:

package remoteSpike
......
const LuaScript = `
        local ticket_key = KEYS[1]
        local ticket_total_key = ARGV[1]
        local ticket_sold_key = ARGV[2]
        local ticket_total_nums = tonumber(redis.call(&#39;HGET&#39;, ticket_key, ticket_total_key))
        local ticket_sold_nums = tonumber(redis.call(&#39;HGET&#39;, ticket_key, ticket_sold_key))
-- 查看是否还有余票,增加订单数量,返回结果值
       if(ticket_total_nums >= ticket_sold_nums) then
            return redis.call(&#39;HINCRBY&#39;, ticket_key, ticket_sold_key, 1)
        end
        return 0
`
//远端统一扣库存
func (RemoteSpikeKeys *RemoteSpikeKeys) RemoteDeductionStock(conn redis.Conn) bool {
lua := redis.NewScript(1, LuaScript)
result, err := redis.Int(lua.Do(conn, RemoteSpikeKeys.SpikeOrderHashKey, RemoteSpikeKeys.TotalInventoryKey, RemoteSpikeKeys.QuantityOfOrderKey))
if err != nil {
return false
}
return result != 0
}
로그인 후 복사

我们使用hash结构存储总库存和总销量的信息,用户请求过来时,判断总销量是否大于库存,然后返回相关的bool值。在启动服务之前,我们需要初始化redis的初始库存信息:

 hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0
로그인 후 복사

4.3 响应用户信息

我们开启一个http服务,监听在一个端口上:

package main
...
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":3005", nil)
}
로그인 후 복사

上面我们做完了所有的初始化工作,接下来handleReq的逻辑非常清晰,判断是否抢票成功,返回给用户信息就可以了。

package main
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
redisConn := redisPool.Get()
LogMsg := ""
<-done
//全局读写锁
if localSpike.LocalDeductionStock() && remoteSpike.RemoteDeductionStock(redisConn) {
util.RespJson(w, 1,  "抢票成功", nil)
LogMsg = LogMsg + "result:1,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
} else {
util.RespJson(w, -1, "已售罄", nil)
LogMsg = LogMsg + "result:0,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
}
done <- 1

//将抢票状态写入到log中
writeLog(LogMsg, "./stat.log")
}

func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{msg, "\r\n"}, "")
buf := []byte(content)
fd.Write(buf)
}
로그인 후 복사

前边提到我们扣库存时要考虑竞态条件,我们这里是使用channel避免并发的读写,保证了请求的高效顺序执行。我们将接口的返回信息写入到了./stat.log文件方便做压测统计。

4.4 单机服务压测

开启服务,我们使用ab压测工具进行测试:

ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket
로그인 후 복사

下面是我本地低配mac的压测信息

This is ApacheBench, Version 2.3 <$Revision: 1826891 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests


Server Software:
Server Hostname:        127.0.0.1
Server Port:            3005

Document Path:          /buy/ticket
Document Length:        29 bytes

Concurrency Level:      100
Time taken for tests:   2.339 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      1370000 bytes
HTML transferred:       290000 bytes
Requests per second:    4275.96 [#/sec] (mean)
Time per request:       23.387 [ms] (mean)
Time per request:       0.234 [ms] (mean, across all concurrent requests)
Transfer rate:          572.08 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    8  14.7      6     223
Processing:     2   15  17.6     11     232
Waiting:        1   11  13.5      8     225
Total:          7   23  22.8     18     239

Percentage of the requests served within a certain time (ms)
  50%     18
  66%     24
  75%     26
  80%     28
  90%     33
  95%     39
  98%     45
  99%     54
 100%    239 (longest request)
로그인 후 복사

根据指标显示,我单机每秒就能处理4000+的请求,正常服务器都是多核配置,处理1W+的请求根本没有问题。而且查看日志发现整个服务过程中,请求都很正常,流量均匀,redis也很正常:

//stat.log
...
result:1,localSales:145
result:1,localSales:146
result:1,localSales:147
result:1,localSales:148
result:1,localSales:149
result:1,localSales:150
result:0,localSales:151
result:0,localSales:152
result:0,localSales:153
result:0,localSales:154
result:0,localSales:156
...
로그인 후 복사

5.总结回顾

总体来说,秒杀系统是非常复杂的。我们这里只是简单介绍模拟了一下单机如何优化到高性能,集群如何避免单点故障,保证订单不超卖、不少卖的一些策略,完整的订单系统还有订单进度的查看,每台服务器上都有一个任务,定时的从总库存同步余票和库存信息展示给用户,还有用户在订单有效期内不支付,释放订单,补充到库存等等。

我们实现了高并发抢票的核心逻辑,可以说系统设计的非常的巧妙,巧妙的避开了对DB数据库IO的操作,对Redis网络IO的高并发请求,几乎所有的计算都是在内存中完成的,而且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。我觉得其中有两点特别值得学习总结:

  • 负载均衡,分而治之。通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致,这样系统的整体也就能承受极高的并发了,就像工作的的一个团队,每个人都将自己的价值发挥到了极致,团队成长自然是很大的。

  • 合理的使用并发和异步。自epoll网络架构模型解决了c10k问题以来,异步越来被服务端开发人员所接受,能够用异步来做的工作,就用异步来做,在功能拆解上能达到意想不到的效果,这点在nginx、node.js、redis上都能体现,他们处理网络请求使用的epoll模型,用实践告诉了我们单线程依然可以发挥强大的威力。服务器已经进入了多核时代,go语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使用并发来解决,比如go处理http请求时每个请求都会在一个goroutine中执行,总之:怎样合理的压榨CPU,让其发挥出应有的价值,是我们一直需要探索学习的方向。

原文链接:https://juejin.cn/post/6844903949632274445

관련 라벨:
원천:php.cn
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
최신 이슈
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿