后台开发干货:教你打造高性能的 Go 缓存库
ztj100 2025-01-10 18:39 12 浏览 0 评论
作者:bearluo,腾讯IEG后台开发工程师
我在看一些优秀的开源库的时候看到一个有意思的缓存库 fastcache,在它的介绍主要有以下几点特点:
- 读写数据要快,即使在并发下;
- 即使在数 GB 的缓存中,也要保持很好的性能,以及尽可能减少 GC 次数;
- 设计尽可能简单;
本文会通过模仿它写一个简单的缓存库,从而研究其内核是如何实现这样的目标的。希望各位能有所收获。
设计思想
在项目中,我们经常会用到 Go 缓存库比如说 patrickmn/go-cache库。但很多缓存库其实都是用一个简单的 Map 来存放数据,这些库在使用的时候,当并发低,数据量少的时候是没有问题的,但是在数据量比较大并发比较高的时候会延长 GC 时间,增加内存分配次数。
比如,我们使用一个简单的例子:
func main() {
a := make(map[string]string, 1e9)
for i := 0; i < 10; i++ {
runtime.GC()
}
runtime.KeepAlive(a)
}
在这个例子中,预分配了大小是10亿(1e9) 的 map,然后我们通过 gctrace 输出一下 GC 情况:
做实验的环境是 Linux,机器配置是 16C 8G ,想要更深入理解 GC,可以看这篇:《 Go 语言 GC 实现原理及源码分析 https://www.luozhiyun.com/archives/475 》
[root@localhost gotest]# GODEBUG=gctrace=1 go run main.go
...
gc 6 @13.736s 17%: 0.010+1815+0.004 ms clock, 0.17+0/7254/21744+0.067 ms cpu, 73984->73984->73984 MB, 147968 MB goal, 16 P (forced)
gc 7 @15.551s 18%: 0.012+1796+0.005 ms clock, 0.20+0/7184/21537+0.082 ms cpu, 73984->73984->73984 MB, 147968 MB goal, 16 P (forced)
gc 8 @17.348s 19%: 0.008+1794+0.004 ms clock, 0.14+0/7176/21512+0.070 ms cpu, 73984->73984->73984 MB, 147968 MB goal, 16 P (forced)
gc 9 @19.143s 19%: 0.010+1819+0.005 ms clock, 0.16+0/7275/21745+0.085 ms cpu, 73984->73984->73984 MB, 147968 MB goal, 16 P (forced)
gc 10 @20.963s 19%: 0.011+1844+0.004 ms clock, 0.18+0/7373/22057+0.076 ms cpu, 73984->73984->73984 MB, 147968 MB goal, 16 P (forced)
上面展示了最后 5 次 GC 的情况,下面我们看看具体的含义是什么:
gc 1 @0.004s 4%: 0.22+1.4+0.021 ms clock, 1.7+0.009/0.40/0.073+0.16 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 10 @20.963s 19%: 0.011+1844+0.004 ms clock, 0.18+0/7373/22057+0.076 ms cpu, 73984->73984->73984 MB, 147968 MB goal, 16 P (forced)
gc 10 :程序启动以来第10次GC
@20.963s:距离程序启动到现在的时间
19%:当目前为止,GC 的标记工作所用的CPU时间占总CPU的百分比
垃圾回收的时间
0.011 ms:标记开始 STW 时间
1844 ms:并发标记时间
0.004 ms:标记终止 STW 时间
垃圾回收占用cpu时间
0.18 ms:标记开始 STW 时间
0 ms:mutator assists占用的时间
7373 ms:标记线程占用的时间
22057 ms:idle mark workers占用的时间
0.076 ms:标记终止 STW 时间
内存
73984 MB:标记开始前堆占用大小
73984 MB:标记结束后堆占用大小
73984 MB:标记完成后存活堆的大小
147968 MB goal:标记完成后正在使用的堆内存的目标大小
16 P:使用了多少处理器
可以从上面的输出看到每次 GC 处理的时间非常的长,占用的 CPU 资源也非常多。那么造成这样的原因是什么呢?
string 实际上底层数据结构是由两部分组成,其中包含指向字节数组的指针和数组的大小:
type StringHeader struct {
Data uintptr
Len int
}
由于 StringHeader中包含指针,所以每次 GC 的时候都会扫描每个指针,那么在这个巨大的 map中是包含了非常多的指针的,所以造成了巨大的资源消耗。
在上面的例子 map a 中数据大概是这样存储:
一个 map 中里面有多个 bucket ,bucket 里面有一个 bmap 数组用来存放数据,但是由于 key 和 value 都是 string 类型的,所以在 GC 的时候还需要根据 StringHeader中的 Data指针扫描 string 数据。
对于这种情况,如果所有的 string 字节都在一个单一内存片段中,我们就可以通过偏移来追踪某个字符串在这段内存中的开始和结束位置。通过追踪偏移,我们不在需要在我们大数组中存储指针,GC 也不在会被困扰。如下:
如同上面所示,如果我们将字符串中的字节数据拷贝到一个连续的字节数组 chunks 中,并为这个字节数组提前分配好内存,并且仅存储字符串在数组中的偏移而不是指针。
除了上面所说的优化内容以外,还有其他的方法吗?
其实我们还可以直接从系统 OS 中调用 mmap syscall 进行内存分配,这样 GC 就永远不会对这块内存进行内存管理,因此也就不会扫描到它。如下:
func main() {
test := "hello syscall"
data, _ := syscall.Mmap(-1, 0, 13, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE)
p := (*[13]byte)(unsafe.Pointer(&data[0]))
for i := 0; i < 13; i++ {
p[i] = test[i]
}
fmt.Println(string(p[:]))
}
通过系统调用直接向 OS 申请了 13bytes 的内存,然后将一个字符串写入到申请的内存数组中。
所以我们也可以通过提前向 OS 申请一块内存,而不是用的时候才申请内存,减少频繁的内存分配从而达到提高性能的目的。
源码实战
API
我们在开发前先把这个库的 API 定义一下:
func New
func New(maxBytes int) *Cache
创建一个 Cache 结构体,传入预设的缓存大小,单位是字节。
func (*Cache) Get
func (c *Cache) Get(k []byte) []byte
获取 Cache 中的值,传入的参数是 byte 数组。
func (*Cache) Set
func (c *Cache) Set(k, v []byte)
设置键值对到缓存中,k 是键,v 是值,参数都是 byte 数组。
结构体
const bucketsCount = 512
type Cache struct {
buckets [bucketsCount]bucket
}
type bucket struct {
// 读写锁
mu sync.RWMutex
// 二维数组,存放数据的地方,是一个环形链表
chunks [][]byte
// 索引字典
m map[uint64]uint64
// 索引值
idx uint64
// chunks 被重写的次数,用来校验环形链表中数据有效性
gen uint64
}
通过我们上面的分析,可以看到,实际上真正存放数据的地方是 chunks 二维数组,在实现上是通过 m 字段来映射索引路径,根据 chunks 和 gen 两个字段来构建一个环形链表,环形链表每转一圈 gen 就会加一。
初始化
func New(maxBytes int) *Cache {
if maxBytes <= 0 {
panic(fmt.Errorf("maxBytes must be greater than 0; got %d", maxBytes))
}
var c Cache
// 算出每个桶的大小
maxBucketBytes := uint64((maxBytes + bucketsCount - 1) / bucketsCount)
for i := range c.buckets[:] {
// 对桶进行初始化
c.buckets[i].Init(maxBucketBytes)
}
return &c
}
我们会设置一个 New 函数来初始化我们 Cache 结构体,在 Cache 结构体中会将缓存的数据大小平均分配到每个桶中,然后对每个桶进行初始化。
const bucketSizeBits = 40
const maxBucketSize uint64 = 1 << bucketSizeBits
const chunkSize = 64 * 1024
func (b *bucket) Init(maxBytes uint64) {
if maxBytes == 0 {
panic(fmt.Errorf("maxBytes cannot be zero"))
}
// 我们这里限制每个桶最大的大小是 1024 GB
if maxBytes >= maxBucketSize {
panic(fmt.Errorf("too big maxBytes=%d; should be smaller than %d", maxBytes, maxBucketSize))
}
// 初始化 Chunks 中每个 Chunk 大小为 64 KB,计算 chunk 数量
maxChunks := (maxBytes + chunkSize - 1) / chunkSize
b.chunks = make([][]byte, maxChunks)
b.m = make(map[uint64]uint64)
// 初始化 bucket 结构体
b.Reset()
}
在这里会将桶里面的内存按 chunk 进行分配,每个 chunk 占用内存约为 64 KB。在最后会调用 bucket 的 Reset 方法对 bucket 结构体进行初始化。
func (b *bucket) Reset() {
b.mu.Lock()
chunks := b.chunks
// 遍历 chunks
for i := range chunks {
// 将 chunk 中的内存归还到缓存中
putChunk(chunks[i])
chunks[i] = nil
}
// 删除索引字典中所有的数据
bm := b.m
for k := range bm {
delete(bm, k)
}
b.idx = 0
b.gen = 1
b.mu.Unlock()
}
Reset 方法十分简单,主要就是清空 chunks 数组、删除索引字典中所有的数据以及重置索引 idx 和 gen 的值。
在上面这个方法中有一个 putChunk ,其实这个就是直接操作我们提前向 OS 申请好的内存,相应的还有一个 getChunk 方法。下面我们具体看看 Chunk 的操作。
Chunk 操作
getChunk
const chunksPerAlloc = 1024
const chunkSize = 64 * 1024
var (
freeChunks []*[chunkSize]byte
freeChunksLock sync.Mutex
)
func getChunk() []byte {
freeChunksLock.Lock()
if len(freeChunks) == 0 {
// 分配 64 * 1024 * 1024 = 64 MB 内存
data, err := syscall.Mmap(-1, 0, chunkSize*chunksPerAlloc, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE)
if err != nil {
panic(fmt.Errorf("cannot allocate %d bytes via mmap: %s", chunkSize*chunksPerAlloc, err))
}
// 循环遍历 data 数据
for len(data) > 0 {
//将从系统分配的内存分为 64 * 1024 = 64 KB 大小,存放到 freeChunks中
p := (*[chunkSize]byte)(unsafe.Pointer(&data[0]))
freeChunks = append(freeChunks, p)
data = data[chunkSize:]
}
}
//从 freeChunks 获取最后一个元素
n := len(freeChunks) - 1
p := freeChunks[n]
freeChunks[n] = nil
freeChunks = freeChunks[:n]
freeChunksLock.Unlock()
return p[:]
}
初次调用 getChunk 函数时会使用系统调用分配 64MB 的内存,然后循环将内存切成 1024 份,每份 64KB 放入到 freeChunks 空闲列表中。然后获取每次都获取 freeChunks 空闲列表最后一个元素 64KB 内存返回。需要注意的是 getChunk 会下下面将要介绍到的 Cache 的 set 方法中使用到,所以需要考虑到并发问题,所以在这里加了锁。
putChunk
func putChunk(chunk []byte) {
if chunk == nil {
return
}
chunk = chunk[:chunkSize]
p := (*[chunkSize]byte)(unsafe.Pointer(&chunk[0]))
freeChunksLock.Lock()
freeChunks = append(freeChunks, p)
freeChunksLock.Unlock()
}
putChunk 函数就是将内存数据还回到 freeChunks 空闲列表中,会在 bucket 的 Reset 方法中被调用。
Set
const bucketsCount = 512
func (c *Cache) Set(k, v []byte) {
h := xxhash.Sum64(k)
idx := h % bucketsCount
c.buckets[idx].Set(k, v, h)
}
Set 方法里面会根据 k 的值做一个 hash,然后取模映射到 buckets 桶中,这里用的 hash 库是 cespare/xxhash。
最主要的还是 buckets 里面的 Set 方法:
func (b *bucket) Set(k, v []byte, h uint64) {
// 限定 k v 大小不能超过 2bytes
if len(k) >= (1<<16) || len(v) >= (1<<16) {
return
}
// 4个byte 设置每条数据的数据头
var kvLenBuf [4]byte
kvLenBuf[0] = byte(uint16(len(k)) >> 8)
kvLenBuf[1] = byte(len(k))
kvLenBuf[2] = byte(uint16(len(v)) >> 8)
kvLenBuf[3] = byte(len(v))
kvLen := uint64(len(kvLenBuf) + len(k) + len(v))
// 校验一下大小
if kvLen >= chunkSize {
return
}
b.mu.Lock()
// 当前索引位置
idx := b.idx
// 存放完数据后索引的位置
idxNew := idx + kvLen
// 根据索引找到在 chunks 的位置
chunkIdx := idx / chunkSize
chunkIdxNew := idxNew / chunkSize
// 新的索引是否超过当前索引
// 因为还有chunkIdx等于chunkIdxNew情况,所以需要先判断一下
if chunkIdxNew > chunkIdx {
// 校验是否新索引已到chunks数组的边界
// 已到边界,那么循环链表从头开始
if chunkIdxNew >= uint64(len(b.chunks)) {
idx = 0
idxNew = kvLen
chunkIdx = 0
b.gen++
// 当 gen 等于 1<<genSizeBits时,才会等于0
// 也就是用来限定 gen 的边界为1<<genSizeBits
if b.gen&((1<<genSizeBits)-1) == 0 {
b.gen++
}
} else {
// 未到 chunks数组的边界,从下一个chunk开始
idx = chunkIdxNew * chunkSize
idxNew = idx + kvLen
chunkIdx = chunkIdxNew
}
// 重置 chunks[chunkIdx]
b.chunks[chunkIdx] = b.chunks[chunkIdx][:0]
}
chunk := b.chunks[chunkIdx]
if chunk == nil {
chunk = getChunk()
// 清空切片
chunk = chunk[:0]
}
// 将数据 append 到 chunk 中
chunk = append(chunk, kvLenBuf[:]...)
chunk = append(chunk, k...)
chunk = append(chunk, v...)
b.chunks[chunkIdx] = chunk
// 因为 idx 不能超过bucketSizeBits,所以用一个 uint64 同时表示gen和idx
// 所以高于bucketSizeBits位置表示gen
// 低于bucketSizeBits位置表示idx
b.m[h] = idx | (b.gen << bucketSizeBits)
b.idx = idxNew
b.mu.Unlock()
}
- 在这段代码开头实际上我会限制键值的大小不能超过 2bytes;
- 然后将 2bytes 大小长度的键值封装到 4bytes 的 kvLenBuf 作为数据头,数据头和键值的总长度是不能超过一个 chunk 长度,也就是 64 * 1024;
- 然后计算出原索引 chunkIdx 和新索引 chunkIdxNew,用来判断这次添加的数据加上原来的数据有没有超过一个 chunk 长度;
- 根据新的索引找到对应的 chunks 中的位置,然后将键值以及 kvLenBuf 追加到 chunk 后面;
- 设置新的 idx 以及 m 字典对应的值,m 字典中存放的是 gen 和 idx 通过取与的放置存放。
在 Set 一个键值对会有 4bytes 的 kvLenBuf 作为数据头,后面的数据会接着 key 和 value ,在 kvLenBuf 中,前两个 byte 分别代表了 key 长度的低位和高位;后两个 byte 分别代表了 value 长度的低位和高位,数据图大致如下:
下面举个例子来看看是是如何利用 chunks 这个二维数组来实现环形链表的。
我们在 bucket 的 Init 方法中会根据传入 maxBytes 桶字节数来设置 chunks 的长度大小,由于每个 chunk 大小都是 64 * 1024bytes,那么我们设置 3 * 64 * 1024bytes 大小的桶,那么 chunks 数组长度就为 3。
如果当前算出 chunkIdx 在 chunks 数组为 1 的位置,并且在 chunks[1] 的位置中,还剩下 6bytes 未被使用,那么有如下几种情况:
- 现在假设放入的键值长度都是 1byte,那么在 chunks[1] 的位置中剩下的 6bytes 刚好可以放下;
- 现在假设放入的键值长度超过了 1byte,那么在 chunks[1] 的位置中剩下的位置就放不下,只能放入到 chunks[2] 的位置中。
如果当前算出 chunkIdx 在 chunks 数组为 2 的位置,并且现在 Set 一个键值,经过计算 chunkIdxNew 为 3,已经超过了 chunks 数组长度,那么会将索引重置,重新将数据从 chunks[0] 开始放置,并将 gen 加一,表示已经跑完一圈了。
Get
func (c *Cache) Get(dst, k []byte) []byte {
h := xxhash.Sum64(k)
idx := h % bucketsCount
dst, _ = c.buckets[idx].Get(dst, k, h, true)
return dst
}
这里和 Set 方法是一样的,首先是要找到对应的桶的位置,然后才去桶里面拿数据。需要注意的是,这里的 dst 可以从外部传入一个切片,以达到减少重复分配返回值。
func (b *bucket) Get(dst, k []byte, h uint64,returnDst bool) ([]byte, bool) {
found := false
b.mu.RLock()
v := b.m[h]
bGen := b.gen & ((1 << genSizeBits) - 1)
if v > 0 {
// 高于bucketSizeBits位置表示gen
gen := v >> bucketSizeBits
// 低于bucketSizeBits位置表示idx
idx := v & ((1 << bucketSizeBits) - 1)
// 这里说明chunks还没被写满
if gen == bGen && idx < b.idx ||
// 这里说明chunks已被写满,并且当前数据没有被覆盖
gen+1 == bGen && idx >= b.idx ||
// 这里是边界条件gen已是最大,并且chunks已被写满bGen从1开始,,并且当前数据没有被覆盖
gen == maxGen && bGen == 1 && idx >= b.idx {
chunkIdx := idx / chunkSize
// chunk 索引位置不能超过 chunks 数组长度
if chunkIdx >= uint64(len(b.chunks)) {
goto end
}
// 找到数据所在的 chunk
chunk := b.chunks[chunkIdx]
// 通过取模找到该key 对应的数据在 chunk 中的位置
idx %= chunkSize
if idx+4 >= chunkSize {
goto end
}
// 前 4bytes 是数据头
kvLenBuf := chunk[idx : idx+4]
// 通过数据头算出键值的长度
keyLen := (uint64(kvLenBuf[0]) << 8) | uint64(kvLenBuf[1])
valLen := (uint64(kvLenBuf[2]) << 8) | uint64(kvLenBuf[3])
idx += 4
if idx+keyLen+valLen >= chunkSize {
goto end
}
// 如果键值是一致的,表示找到该数据
if string(k) == string(chunk[idx:idx+keyLen]) {
idx += keyLen
// 返回该键对应的值
if returnDst {
dst = append(dst, chunk[idx:idx+valLen]...)
}
found = true
}
}
}
end:
b.mu.RUnlock()
return dst, found
}
Get 方法主要是考虑环形链表的边界问题。我们在 Set 方法中会将每一个 key 对应的 gen 和 idx 索引存放到 m 字典中,所以我们通过 hash 获取 m 字典的值之后通过位运算就可以获取到 gen 和 idx 索引。
找到 gen 和 idx 索引之后就是边界条件的判断了,用一个 if 条件来进行判断:
gen == bGen && idx < b.idx
这里是判断如果是在环形链表的同一次循环中,那么 key 对应的索引应该小于当前桶的索引;
gen+1 == bGen && idx >= b.idx
这里表示当前桶已经进入到下一个循环中,所以需要判断 key 对应的索引是不是大于当前索引,以表示当前 key 对应的值没有被覆盖;
gen == maxGen && bGen == 1 && idx >= b.idx
因为 gen 和 idx 索引要塞到 uint64 类型的字段中,所以留给 gen 的最大值只有 maxGen = 1<< 24 -1,超过了 maxGen 会让 gen 从 1 开始。所以这里如果 key 对应 gen 等于 maxGen ,那么当前的 bGen 应该等于 1,并且 key 对应的索引还应该大于当前 idx,这样才这个键值对才不会被覆盖。
判断完边界条件之后就会找到对应的 chunk ,然后取模后找到数据位置,通过偏移量找到并取出值。
Benchmark
下面我上一下过后的 Benchmark:
代码位置: https://github.com/devYun/mycache/blob/main/cache_timing_test.go
GOMAXPROCS=4 go test -bench='Set|Get' -benchtime=10s
goos: linux
goarch: amd64
pkg: gotest
// GoCache
BenchmarkGoCacheSet-4 836 14595822 ns/op 4.49 MB/s 2167340 B/op 65576 allocs/op
BenchmarkGoCacheGet-4 3093 3619730 ns/op 18.11 MB/s 5194 B/op 23 allocs/op
BenchmarkGoCacheSetGet-4 236 54379268 ns/op 2.41 MB/s 2345868 B/op 65679 allocs/op
// BigCache
BenchmarkBigCacheSet-4 1393 12763995 ns/op 5.13 MB/s 6691115 B/op 8 allocs/op
BenchmarkBigCacheGet-4 2526 4342561 ns/op 15.09 MB/s 650870 B/op 131074 allocs/op
BenchmarkBigCacheSetGet-4 1063 11180201 ns/op 11.72 MB/s 4778699 B/op 131081 allocs/op
// standard map
BenchmarkStdMapSet-4 1484 7299296 ns/op 8.98 MB/s 270603 B/op 65537 allocs/op
BenchmarkStdMapGet-4 4278 2480523 ns/op 26.42 MB/s 2998 B/op 15 allocs/op
BenchmarkStdMapSetGet-4 343 39367319 ns/op 3.33 MB/s 298764 B/op 65543 allocs/op
// sync.map
BenchmarkSyncMapSet-4 756 15951363 ns/op 4.11 MB/s 3420214 B/op 262320 allocs/op
BenchmarkSyncMapGet-4 11826 1010283 ns/op 64.87 MB/s 1075 B/op 33 allocs/op
BenchmarkSyncMapSetGet-4 1910 5507036 ns/op 23.80 MB/s 3412764 B/op 262213 allocs/op
PASS
ok gotest 215.182s
上面的测试是 GoCache、BigCache、Map、sync.Map 的情况。下面是本篇文章中所开发的缓存库的测试:
// myCachce
BenchmarkCacheSet-4 4371 2723208 ns/op 24.07 MB/s 1306 B/op 2 allocs/op
BenchmarkCacheGet-4 6003 1884611 ns/op 34.77 MB/s 951 B/op 1 allocs/op
BenchmarkCacheSetGet-4 2044 6611759 ns/op 19.82 MB/s 2797 B/op 5 allocs/op
可以看到内存分配是几乎就不存在,操作速度在上面的库中也是佼佼者的存在。
总结
在本文中根据其他缓存库,并分析了如果用 Map 作为缓存所存在的问题,然后引出存在这个问题的原因,并提出解决方案;在我们的缓存库中,第一是通过使用索引加内存块的方式来存放缓存数据,再来是通过 OS 系统调用来进行内存分配让我们的缓存数据块脱离了 GC 的控制,从而做到降低 GC 频率提高并发的目的。
其实不只是缓存库,在我们的项目中当遇到需要使用大量的带指针的数据结构并需要长时间保持引用的时候,也是需要注意这样做可能会引发 GC 问题,从而给系统带来隐患。
Reference
https://github.com/VictoriaMetrics/fastcache
Further Dangers of Large Heaps in Go https://syslog.ravelin.com/further-dangers-of-large-heaps-in-go-7a267b57d487
Avoiding high GC overhead with large heaps https://blog.gopheracademy.com/advent-2018/avoid-gc-overhead-large-heaps/
Go 的 GC 如何调优?https://www.bookstack.cn/read/qcrao
作者:luozhiyun 的博客:https://www.luozhiyun.com/archives/531
文中代码位置: https://github.com/devYun/mycache
- 上一篇:性能工具之常见流量复制工具
- 下一篇:golang 方法和方法集
相关推荐
- 干货 | 各大船公司VGM提交流程(msc船运公司提单查询)
-
VGM(VerifiedGrossMass)要来了,大外总管一本正经来给大家分享下各大船公司提交VGM流程。1,赫伯罗特(简称HPL)首先要注册账户第一,登录进入—选择product------...
- 如何修改图片详细信息?分享三个简单方法
-
如何修改图片详细信息?分享三个简单方法我们知道图片的详细信息里面包含了很多属性,有图片的创建时间,修改时间,地理位置,拍摄时间,还有图片的描述等信息。有时候为了一些特殊场景的需要我们需要对这些信息进行...
- 实用方法分享:没有图像处理软件,怎么将一张照片做成九宫格?
-
在发朋友圈时,如果把自己的照片做成九宫格,是不是更显得高大上?可能你问,是不是要借助图片处理软件,在这里,我肯定告诉你,不需要!!!你可能要问,那怎么实现呢?下面你看我是怎么做的,一句代码都不写,只是...
- 扫描档PDF也能变身“最强大脑”?RAG技术解锁尘封的知识宝藏!
-
尊敬的诸位!我是一名物联网工程师。关注我,持续分享最新物联网与AI资讯和开发实战。期望与您携手探寻物联网与AI的无尽可能。今天有网友问我扫描档的PDF文件能否做知识库,其实和普通pdf处理起来差异...
- 这两个Python库,轻而易举就能实现MP4与GIF格式互转,太好用了
-
mp4转gif的原理其实很简单,就是将mp4文件的帧读出来,然后合并成一张gif图。用cv2和PIL这两个库就可以轻松搞定。importglobimportcv2fromPILimpo...
- python图片处理之图片切割(python把图片切割成固定大小的子图)
-
python图片切割在很多项目中都会用到,比如验证码的识别、目标检测、定点切割等,本文给大家带来python的两种切割方式:fromPILimportImage"""...
- python+selenium+pytesseract识别图片验证码
-
一、selenium截取验证码#私信小编01即可获取大量Python学习资源#私信小编01即可获取大量Python学习资源#私信小编01即可获取大量Python学习资源importjso...
- 如何使用python裁剪图片?(python图片截取)
-
如何使用python裁剪图片如上图所示,这是一张包含了各类象棋棋子的图片。我们需要将其中每一个棋子都裁剪出来,此时可以利用python的...
- Python rembg 库去除图片背景(python 删除图片)
-
rembg是一个强大的Python库,用于自动去除图片背景。它基于深度学习模型(如U^2-Net),能够高效地将前景物体从背景中分离,生成透明背景的PNG图像。本教程将带你从安装到实际应用...
- 「python脚本」批量修改图片尺寸&视频安帧提取
-
【python脚本】批量修改图片尺寸#-*-coding:utf-8-*-"""CreatedonThuAug2316:06:352018@autho...
- 有趣的EXCEL&vba作图(vba画图表)
-
还记不记得之前有个日本老爷爷用EXCEL绘图,美轮美奂,可谓是心思巧妙。我是没有那样的艺术细胞,不过咱有自己的方式,用代码作图通过vba代码将指定的图片写入excel工作表中,可不是插入图片哦解题思...
- 怎么做到的?用python制作九宫格图片,太棒了
-
1.应用场景当初的想法是:想把一张图切割成九等份,发布到微信朋友圈,切割出来的图片,上传到朋友圈,发现微信不按照我排列的序号来排版。这样的结果是很耗时间的。让我深思,能不能有一种,直接拼接成一张...
- Python-连续图片合成视频(python多张图叠加为一张)
-
前言很多时候,我们需要将图片直接转成视频。下面介绍用python中的OpenCV将进行多张图合成视频。cv2安装不要直接用pipinstallcv2,这会报错。有很多人建议用打开window自带的...
- 如何把多个文件夹里的图片提取出来?文件夹整理合并工具
-
在项目管理中,团队成员可能会将项目相关的图片资料分散存储在不同的文件夹中,以便于分类和阶段性管理。然而,当项目进入汇报或总结阶段时,需要将所有相关图片整合到一个位置,以便于制作演示文稿、报告或进行项目...
- 超简单!为图片和 PDF 上去掉水印(pdf图片和水印是一体,怎么去除)
-
作者:某某白米饭...
你 发表评论:
欢迎- 一周热门
- 最近发表
-
- 干货 | 各大船公司VGM提交流程(msc船运公司提单查询)
- 如何修改图片详细信息?分享三个简单方法
- 实用方法分享:没有图像处理软件,怎么将一张照片做成九宫格?
- 扫描档PDF也能变身“最强大脑”?RAG技术解锁尘封的知识宝藏!
- 这两个Python库,轻而易举就能实现MP4与GIF格式互转,太好用了
- python图片处理之图片切割(python把图片切割成固定大小的子图)
- python+selenium+pytesseract识别图片验证码
- 如何使用python裁剪图片?(python图片截取)
- Python rembg 库去除图片背景(python 删除图片)
- 「python脚本」批量修改图片尺寸&视频安帧提取
- 标签列表
-
- idea eval reset (50)
- vue dispatch (70)
- update canceled (42)
- order by asc (53)
- spring gateway (67)
- 简单代码编程 贪吃蛇 (40)
- transforms.resize (33)
- redisson trylock (35)
- 卸载node (35)
- np.reshape (33)
- torch.arange (34)
- node卸载 (33)
- npm 源 (35)
- vue3 deep (35)
- win10 ssh (35)
- exceptionininitializererror (33)
- vue foreach (34)
- idea设置编码为utf8 (35)
- vue 数组添加元素 (34)
- std find (34)
- tablefield注解用途 (35)
- python str转json (34)
- java websocket客户端 (34)
- tensor.view (34)
- java jackson (34)