9 Ocak 2014 Perşembe

SQL Server 2014 Yenilikleri - 2 (In-Memory OLTP)

Veri yönetimi trendi, giderek artan ve hızla talep edilmek istenen yoğun veri ihtiyacı gereği diskten memory tarafına doğru kayıyor. Microsoft da SQL Server 2014 ile birlikte bu alana yatırımlarını yaptı ve In-Memory teknolojisini müşterilerine duyurdu.

CTP1 versiyonuna nazaran biraz daha geliştirilen bu teknoloji iki ayrı başlıkta incelenebilir durumda. Bu başlıklardan birisi OLTP modeldeki veri tabanlarının INSERT, UPDATE, DELETE performanslarını arttırmak için kullanılabilecek bir çözüm olan In-Memory OLTP dir. Diğer başlık ise Veri ambarı gibi genelde de-normalize yapıda tutulan veri tabanlarının SELECT performansını arttıracak bir çözüm olan In-Memory DW dir. Bu yazımızda In-Memory OLTP kısmına odaklanıyor olacağız.

Hekaton adı ile anılan yeni bir engine dizayn edildi ve var olan database engine bileşenlerine entegre hale getirildi. Yani SQL Server 2014 kurulumunu yaptığınızda artık kolayca In-Memory OLTP teknolojisinden de faydalanabileceksiniz demektir.

Bu entegrasyon sayesinde veri tabanı üzerindeki çalışma alışkanlıklarınızı çok fazla değiştirmenize gerek kalmayacak. Tabi ki bu yeni teknolojiyi mevcut sistem ile birlikte kullanmaya başladığımızda bazı kısıtlarla karşılaşmamamız kaçınılmaz olacaktır. Bu kısıtlar tamamen yeni teknolojinin doğasından kaynaklanmaktadır.

In-Memory bileşenleri arka planda C tabanlı dll’ler ve FileStream özelliğini kullandığı için bunlarla çakışan hizmetlerden faydalanamayacaksınız demektir. Örneğin High Availability çözümlerinden AlwaysOn’u kurabilirlen zaten FileStream replikasyonu desteği olmayan Mirroring ve Replication(kısıtlı) kurulamayacaktır.


Bu teknolojinin özellikleri neler?

Aslında hemen hemen her şey C tabanlı çalışmanın getirilerine bağlı. Çünkü istenen talepler anında makine koduna çevriliyor ve işlemci tarafından işlenebiliyor.

Ayrıca veriler diskte veya buffer pool da değil direk memoryde tutuluyor. Dolayısıyla disk blockları üzerinde ve page mantığında çalışmanın dezavantajlarından kurtulmuş olunuyor. Aslına bakarsanız memorynin yetersiz olduğu durumlarda bu yöntemle çalışmak da başka bir dezavantaj oluyor.

Transaction yönetimini incelediğimizde ise şu özelliklerle karşılaşıyoruz;
Tüm transactionlarda SNAPSHOT tabanlı isolation seviyeleri kullanılmakta. Bu sayede verinin birden fazla versiyonu üzerinde çalışabilmekte(Multi-Version). Snapshot tabanlı bu çalışma sırasında klasik Snapshot seviyesinden farklı olarak TempDB kullanılmıyor.

Her satır versiyonu TIMESTAMP’ler yardımıyla değişiklikler için zaman aralıkları bilgisi tutuyor. Transactionlar doğru versiyonu seçmek ve validasyonu sağlamak için Begin Timestamp bilgisini kullanıyor.

Bir satırda Row Header kısmında Begin Timestamp(Begin Ts), End Timstampt(End Ts) bilgilerinin yanı sıra satırı create eden ifadenin statement Id(SmtId) si ve tanımlı index pointerları için tutulan IdxLinkCount bilgisi yer almakta. Row Header’dan geriye kalan kısımda ise satırda tuttuğumuz veriler bulunmaktadır.


Verinin tutarlılığını sağlamak için çalışan lock ve latch mevcut değil(Optimistic). Dolayısıyla blocking gibi deadlock gibi durumlarla karşılaşılmıyor. Verinin tutarlılığı(tekrarlı okuma hayali okuma toleransları baz alınarak) transaction commit edilmeden hemen önce Validation evresinde kontrol ediliyor ve isolation seviyesine göre transactionın commit edilip edilmeyeceği belirleniyor.


Sadece In-Memory tablolarda kullanılabilecek Snapshot tabanlı 3 tane transaction isolation seviyesi mevcut. Bunlar klasik seviyelerden biraz farklı çalışıyor. Ancak tabi ki tüm transactionlar ACID kurallarını desteklemektedir. Sadece mantıksız bir sonuç oluştuğunda aldığınız hata karşısında işlemi tekrarlamanız gerekiyor. Bu durumla daha az karşılaşmak için mümkün olduğunca kısa süreli atomik transaction blokları oluşturmalısınız.

Isolation seviyelerine kısaca bir göz atalım:

Snapshot: Herhangi bir validasyon gerekli değil. Transactionlar verinin farklı bir versiyonu üzerinde çalışıyor ve ilk talep gönderen transactionın değişikliği onaylanır. Diğer transactionda ise erişim sırasında hata alınıyor.

Repeatable Reads: Snapshot seviyesindeki şartlar burada da sağlanır. Ek olarak transaction içerisinde okuma tutarlılığı garanti altına alınıyor. Transactiondan çıkmadan önce veride bir değişiklik olursa hata ile karşılaşıyor.

Serializable: Repeatable Reads seviyesindeki durum aynen geçerli. Ek olarak çalışılan aralığa farklı bir kaydın eklenmemesi veya kaydın silinmemesi (phantom) garanti altına alınıyor.

Transactionlar içerisinde In-Memory tablolar(Memory Optimized Table) ile disk tabanlı tabloları (Disk-Based Table) birlikte kullanabiliriz. Ancak tüm klasik isolation seviyeleri ile yukarıda belirttiğimiz isolation seviyeleri birlikte çalışamamaktadır.


Bu şekilde karma kullanımlarda Memory Optimized tablo erişimi için transaction seviyeleri WITH (SNAPSHOT)şeklinde belirtilmelidir. Aşağıdaki örnekte desteklenmeyen bir transaction seviyesi kullandığımızda nasıl bir hata aldığımızı görebiliriz.


Hangi nesneler oluşturulabiliyor ve arka planda neler oluyor?

In-Memory OLTP adı altında şu nesneleri oluşturabiliyoruz:
-          Memory Optimized Data File Group
-          Memory Optimized Table
o   Hash Index
o   Range Index
-          Natively – Compiled Procedure

Bu nesneleri SQL Server kodları ile oluşturduğumuzda(çalıştırdığımızda) SQL Server tarafında tanımlanan metadata bilgisi kullanılarak C tabanlı dll’ler oluşur. Bu dll’leri ve C kodlarını aşağıdaki varsayılan yolda database Id leri ile oluşan klasörlerin içerisinde görebilirsiniz.
C:\Program Files\Microsoft SQL Server\MSSQL12.\MSSQL\DATA\xtp
Nesnelerin oluşan dll’lerini şu komut yardımıyla listelemek mümkün.

SELECT
     name,
     description
FROM sys.dm_os_loaded_modules
WHERE name LIKE '%xtp_t_' + cast(db_id() AS varchar(10)) + '_' + cast(object_id('dbo.T2_InMem') AS varchar(10)) + '.dll'

xtp klasöründekilere ek olarak File Group create ederken belirttiğimiz klasör içerisinde Checkpoint dosyaları oluşmakta. İki tür checkpoint dosyası mevcuttur:
  • Data File: Insert edilmiş satırlar ve önceki versiyonları.
  • Delta File: Data file içerisinde silinmiş olan satıların bilgisi. Bu dosya sayesinde silinen satırlar filtrelenerek tekrar memorye yüklenmez.

Birer çift olarak çalışan bu dosyaların hangilerinin aktif olarak kullanıldığını görmek için şu komutu kullanabilirsiniz.

select * from sys.dm_db_xtp_checkpoint_files
where is_active=1

Bu dosyaların çalışma mantığı anlaşılınca performansın nereden geldiği de ortaya çıkıyor. 

DELETE komutu gönderdiğinizde gerçekten silme işlemi yapılmıyor. Sadece satır timestamp yardımıyla silindi olarak işaretleniyor. INSERT işlemi de direk memory üzerinde çalışan bir işlem. Bu esnada transaction log’da ya çok az kayıt tutuluyor veya tablo oluşturma yöntemine göre hiçbir kayıt tutulmuyor. UPDATE işlemi de zaten bir DELETE(eski kayıt) ve bir INSERT(yeni kayıt) işleminden oluşmaktadır.

Bu işlemler Natively Procedure içerisinde yapılırsa T-SQL kodunun C kodlarıyla ifade edilmesi safhası atlanmış olur ve dolayısıyla daha fazla performans elde edilmiş olur.

Memorydeki bilgilerin Data ve Delta dosyalarına yazılması işlemine Checkpoint deniyor. Zaman zaman bu dosyalar belli bir boşluk oranına ulaştığında ise otomatik olarak merge ediliyor. Kullanılmayan checkpoint dosyaları da birtakım şartlar oluştuğunda Garbage Collector mekanizması yardımıyla temizleniyor. Bu sistem kendi kendine işlemektedir. İstenirse sisteme müdahale edilebilir.

İşte bu çalışma mantığından dolayı OLTP işlemlerde 30 kata varan performans elde edebiliyoruz.

Ne tür sistemler için daha uygun?

Blocklanmasını istemediğiniz, aynı anda birden fazla bağlantının açıldığı, kısa süreli transactionların yoğun olduğu OLTP işler için çalışan sistemlerde kullanılması tavsiye edilmektedir. Örnek olarak hisse senedi alım satımı, seyahat rezervasyonu ve sipariş işlemlerinin yapıldığı sistemlerde kullanılabilir. Geliştiriciler için ara işlemlerin yapıldığı örneğin ETL sürecinde tercih edilebilen temp tabloların yerine kullanılmasını da tavsiye olarak verebiliriz.

Bu teknolojinin performansından tam manası ile faydalanmak için artık daha fazla diretmeyip en azından canlı olmayan rapor ihtiyacı iş yükünü ayırmak gerekecek. Örneğin gün içinde yoğun bir şekilde gelen INSERT, UPDATE, DELETE talepleri In-Memory ile karşılanıp, gece vakti bu veriler SELECT işleminin yapılacağı raporlama amaçlı veri ambarlarına aktarılabilir.

Nasıl yapılır?

Öncelikle In-Memory tablolar tutacağınız veri tabanında Memory Optimized Data File Group ve içerisinde File oluşturmanız gerekiyor.

ALTER DATABASE [testDB]
ADD FILEGROUP [InMemFileGroup] CONTAINS MEMORY_OPTIMIZED_DATA

GO
ALTER DATABASE [testDB]
ADD FILE (
              NAME = N'InMemFile',
              FILENAME = N'C:\Data\InMemFile'
         ) TO FILEGROUP [InMemFileGroup]

Hemen sonrasında Memory-Optimized tabloyu aşağıdaki şekilde oluşturabilirsiniz.

CREATE TABLE T2_InMem
(
     Id int NOT NULL PRIMARY KEY
              NONCLUSTERED HASH WITH (bucket_count=2000000),
     Ad nvarchar(50),
     Yas int NOT NULL,
     Tarih datetime2 NOT NULL

     INDEX IX_Yas NONCLUSTERED (Yas,Tarih)
)
WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_AND_DATA);

Tabloyu Memory-Optimized hale getiren en sondaki MEMORY_OPTIMIZED=ON ifadesidir. Hemen yanında belirtilen DURABILITY ifadesi ile verinin kalıcılığını örneğin server yeniden başlatılsa bile verinin memoryye tekrar yüklenip yüklenmeyeceğini belirtebiliyoruz. Buraya yazacağımız SCHEMA_AND_DATA ifadesi ile hem yapı hem de veri korunacaktır. Eğer bunun yerine SCHEMA_ONLY ifadesi kullanılırsa yapının kalıcılığı garanti altına alınır fakat veri kalıcı olmaz.

Yukarıdaki tablo örneğimizde sadece Memory-Optimized tablolarda kullanılabilecek iki index tanımladık. İçerisinde HASH ifadesi geçen Hash Index geçmeyen Range Index olarak isimlendiriliyor.

Hash Index

Yukarıdaki tanımdan bağımsız olarak Hash indexin yapısı şu şekildedir.


Index tanımlanırken bucket_count ile belirttiğimiz hücrelere veriler hash algoritmasından geçerek yerleşir. Bucket_count kolondaki unique satır sayısı olarak belirtilir. Belirtilen sayı otomatik olarak ikinin katlarından olacak şekilde bir sonraki sayıya çevrilir.

Insert ve Delete işlemleri (Update her iki işlemi kapsar) timestampler yardımıyla belirtilir. Satırlar pointerlar ile bir sonraki timestamp aralığını işaret eder. Yanında sonsuz işareti bulunan satırlar geçerli satırlardır. Hash indexler sorgu filtrelerinde eşitlik kullanıldığı durumlarda kendilerini gösterirler.

Range Index

CTP2 versiyonu ile birlikte duyurulan bu index türü klasik B-Tree indexlerin değiştirilmiş(lock ve latch olmayacak şekilde) halidir. Yapısı Bw-Tree olarak geçmektedir. Bw-Tree hakkında daha fazla bilgi elde etmek için şu dosyayı inceleyebilirsiniz. http://research.microsoft.com/pubs/178758/bw-tree-icde2013-final.pdf

Bucket_Count belirtilmek istenmediği durumlarda ve aralık sorgulamalarının sıkça yapılacağı durumlarda bu index türü tercih edilmelidir.

Bu index türünde In-Memory mantığına uygun olarak eklenen satırların mantıksal adresleri Page Mapping Table isimli alanda sırayla tutulmaktadır. Diğer pageler de pointerlar yardımıyla bir sonraki pageleri işaret ederler. En son leaf seviyede satırların fiziksel adresleri tutulur. Index pageleri update edilmezler bunun yerine Page Mapping Table update edilir.


Bir takım şartlar yerine geldiğinde Garbage Collector mekanizması devreye girerek indexlerdeki geçerli olamayan satırları asenkron bir şekilde temizler.

Indexler disk üzerinde değildir. Index üzerindeki işlemler transaction logta tutulmazlar. İndex maintain işlem sırasında otomatik olarak gerçekleşir. Tüm indexler recovery sırasında rebuilt olurlar.

Performans karşılaştırmalarında sonuçlar neler?

CPU ve Memory gücüne bağlı olarak farklı sonuçlar ile karşılaşabiliriz. Kendi ortamımda ve eğitimlerde yaptığım testlerde aşağı yukarı şu sonuçlarla karşılaştık.

INSERT

Aynı türdeki iki tabloya 500000(beş yüz bin) satır ekliyorum.
Disk-Based : 43 sn
Memory Optimized: 7 sn


DELETE

Yüklü olan bu satırları silmek istediğimde ise:
Disk-Based : 12 sn
Memory Optimized: 2n


Eğer bu işlemleri Natively Procedure yardımıyla yaparsak fark çok daha ciddi olur.

Testimizi yapabilmek için öncelikle bir Natively Procedure oluşturalım. Procedure içerisinde aynı insert komutunu kullanıyor olacağım.

CREATE PROCEDURE dbo.usp_Native_Kayit
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER
AS BEGIN ATOMIC WITH
(
 TRANSACTION ISOLATION LEVEL = SNAPSHOT,
 LANGUAGE = N'us_english'
)
     ---
     DECLARE @say int=0
     WHILE @say<500000
         BEGIN
              INSERT INTO dbo.T2_InMem VALUES(
                                          @say,
                                          N'kayit',
                                          @say/2,
                                          GETDATE()
                                             )
         SET @say+=1--sayacı arttıralım
         END
     ---
END

Buradaki NATIVE_COMPILATION ifadesi procedureün In-Memory kapsamında olduğunu gösteriyor. Bu tür precedürler SCHEMABINDING ile işaretlenmesi zorunludur. Bu şekilde bağlı olduğu nesnelerin schemalarının değiştirilmesi engellenmiş olur. EXECUTE AS OWNER İfadesi ile de procedureün kimin yetkileri ile çalıştırılacağı belirtilmektedir.

Hemen sonrasındaki Transaction (varsa savepoint) başlatan BEGIN ATOMIC WITH ifadesinin içerisinde dilin, isolation seviyesinin ne olacağı gibi session ayarları belirtilir.

Procedure oluştuktan sonra aynı INSERT işlemini bu procedure ile test ettiğimizde milisaniyeler içerisinde 500000 kaydın eklenebildiğini görebiliyoruz.


Daha ayrıntılı sonuçlar elde etmek için komutu çalıştırmadan önce SET STATISTICS TIME ON komutunu kullanabilirsiniz.

Sonuçlar harika görünüyor. Bunu tüm tablolarımıza uygulayabilir miyiz?

Kesinlikle hayır. Bu teknolojide birçok kısıt mevcut.
Kullanılamayan özelliklerden bazıları şunlar:
  •           DML Trigger
  •           XML ve CLR veri tipleri.
  •           Varchar(max) gibi LOB tipleri.
  •           IDENTITY
  •           FOREIGN KEY
  •           CHECK Constraint
  •           ALTER TABLE
  •           Sonradan Index oluşturma ve kaldırma(tablo yeniden create edilmeli)
  •           TRUNCATE TABLE
  •           Databaseler arası sorgulama
  •           Linked Server
Desteklenen High Availability çözümleri neler?

AlwaysOn Failover Cluster, AlwaysOn Availability Group ve Log Shipping tam olarak destekleniyor. Ancak In-Memory veri tabanın büyüklüğüne bağlı olarak recovery süresinin uzayacağını göz ardı etmemeliyiz. Çünkü failoverdan sonra tüm verinin memoryye yüklenmesi zaman alacaktır.

Ek olarak şöyle bir durum var; AlwaysOn Availability Group, secondary replicalardaki tabloları doldurmak için transaction log kayıtlarını transfer etmektedir. Non-Durable(SCHEMA_ONLY olarak işaretlenmiş) tablolardaki değişiklikler transaction loga yazılmadığı için bu tablolar secondary replicalarda boş görünecektir.

Her ne kadar High Availability kapsamında sayılmasa da bir tür veri transfer çözümü olan Transaction Replication ise belli kısıtlarla birlikte desteklenmektir. Daha fazla bilgi için şu linki inceleyebilirsiniz.


In-Memory veri tabanlarının Memory kullanımını sınırlamak mümkün mü?

Evet mümkün. In-Memory kapsamındaki veri buffer pool’da değil doğrudan memoryde tutulmaktadır ve memory yönetimi de SQL Server sorumluluğundadır. Sonuç itibariyle Resources Governor sayesinde memory yönetimi yapılabilir.

Bunun için öncelikle istediğimiz özelliklerde bir pool oluşturmak gerekiyor. Biz bu örneğimizde memory kullanımını %50 olarak sınırladık.

CREATE RESOURCE POOL inMemPool
WITH (MAX_MEMORY_PERCENT=50);
ALTER RESOURCE GOVERNOR RECONFIGURE;

Sonrasında procedure yardımıyla TestDB isimli In-Memory veri tabanımızı pool ile ilişkilendiriyoruz(bind).

EXEC sp_xtp_bind_db_resource_pool 'TestDB','inMemPool';

Yapılan işlemin geçerli olması için veri tabanın yeniden başlatılması gerekli. Bunun için veri tabanını önce offline sonra tekrar online duruma çekiyoruz.

ALTER DATABASE TestDB SET OFFLINE;
ALTER DATABASE TestDB SET ONLINE;

Eğer In-Memory veri tabanının bu pool ile ilişiğini kesmek isterseniz. Aşağıdaki procedureü kullanarak unbind işlemini gerçekleştirebilirsiniz.

EXEC sp_xtp_unbind_db_resource_pool 'TestDB'

In-Memory veri tabanını hakkında hangi yollarla bilgi edinebilirim?

SQL Server 2014 ile birlikte monitörlemek için yeni DMVler geldi(içerisinde xtp ifadesi geçiyor.) ve mevcut catalog viewlerin bir kısmına yeni kolonlar eklendi. Ayrıca Veritabanı/Reports/Standart Reports/ Memory Usage By Memory Optimized Objects yolu ile aşağıdaki memory kullanım raporuna erişebilirsiniz.          

     
Şu sorgu yardımıyla da kullanılabilecek XEventleri listeleyebilirsiniz.

SELECT
     p.name,
     o.name,
     o.description
FROM sys.dm_xe_objects o JOIN sys.dm_xe_packages p
ON o.package_guid=p.guid
WHERE p.name = 'XtpEngine';

Bu kapsamda kullanılabilecek performance counterlar ise şu sorgu ile listelenebilir:

SELECT
     object_name,
     counter_name
FROM sys.dm_os_performance_counters
WHERE object_name LIKE 'XTP%';

Disk-based tabloları Memory-Optimized tablolara dönüştürmeme yardımcı olabilecek bir araç var mı?

Bunun için SSMS’ya entegre Memory Optimization Advisor wizardı mevcut. Ancak öncesinde AMR (Analysis, Migration, Reporting) olarak isimlendirilen araç ile dönüştürmek isteyebileceğiniz tabloların hangileri olabileceği hakkında fikir edinebilirsiniz.

AMR aracı, SSMS’daki Object Explorer penceresinde Management klasörü altında görünen Data Collection özelliğini kullanılır.


Data Collection sayesinde server hakkındaki bilgiler SQL Server Agent servisi yardımıyla belli aralıklarla belirtilen veri ambarına yüklenir. Buradan sistemin performansı ile ilgili çeşitli raporlar uzun zamandır elde edilebilir durumdaydı. Bu veri ambarı üzerinden erişilebilecek Transaction Performance Analysis Overview ve drill-through yaparak elde edilen diğer raporlar sayesinde transaction analizi yapabilir, ortaya çıkan quadrantlar yardımıyla minimum iş yükü ile maksimum fayda getirebilecek nesneleri görüntüleyebilirsiniz.


Eğer taşımak istediğiniz tabloları belirlediyseniz artık Memory Optimization Advisor wizardını kullanabilirsiniz.

Taşımak istediğiniz tabloyu sağ tıklayıp Memory Optimization Advisor wizardını başlatıyoruz. İlk önce tablo üzerinde In-Memoryde desteklenmeyen özellikler kontrol ediliyor.


Diğer kullanılmayan özelliklerin uyarıları ile ilgili adımı da geçtiğimizde Memory-Optimized File Group ile birlikte içerisinde konumlanacak Logical File’ı ve Checkpoint dosyalarının tutulacağı klasörü belirtebiliyoruz. Yine burada taşıma işlemiyle birlikte Disk-Based tablonun adının ne olacağını ve verinin taşınıp taşınmayacağını ayarlayabiliriz. Bu adımdaki son seçeneği seçtiğimizde ise artık Memory-Optimized tablodaki veri Non-durable olacaktır.

Bir sonraki adımda Primary Key(Non-Durable seçilirse opsiyonel) ve index seçimi yapılarak Migrate butonu ile taşıma işlemi başlatılır.


Natively-Compile Procedure üretmek için de benzer bir yöntem var ancak sadece template script elde edilmekte. Bu yöntemi testDB/Programability/Stored Procedure/New/ Natively Compiled Stored Procedure menüsünü kullanarak deneyebilirsiniz.

Bu araçlar yardımıyla gerekli kurulum sadece birkaç adımda tamamlanabilmektedir. Script veya ara yüz tercih size kalmış.

Baştan beri birlikte neler yaptığımızı özetlemek gerekirse;

In-Memory OLTP başlıklı çözümün alt yapısını, çalışma şeklini, karakteristiğini inceledik. OLTP işlemlerde ciddi manada performans elde edilebileceğini örnekledik. Kısıtlarını göz önünde bulundurarak mevcut sistemlere hangi şartlarda uygulanabileceğini konuştuk. Memory yönetimi ve entegre araçlarıyla kolayca kurulum – monitörleme yapılabildiğini gördük.

Artık sıra sizde! Sisteminizde hangi tabloların bu iş için uygun olabileceğini belirlemek ve yakın geleceğin trendi olan In-Memory hakkındaki gelişmeleri takip etmek isteyebilirsiniz.

In-Memory OLTP’yi birkaç cümle ile özetlemek gerekirse:

Lock ve Latch olmayan. ACID kurallarını destekleyen ve multi-version optimistic concurrency control prensibi ile transactionları yöneten. Verinin memoryde tutulduğu. Arka planda C ve FileStream nesnelerinin kullanıldığı mevcut Database Engine’e entegre yeni bir teknoloji. Bu teknoloji INSERT, UPDATE, DELETE performansını 30 kat arttırmak konusunda iddialı.

Sistemlerinizde kullanıp tatmin edici sonuçlar, etkileyici faydalar edinmeniz dileğiyle…


2 yorum:

  1. Hakkında pek fazla Türkçe makale bulamadığımız bir konuya değindiğiniz için teşekkürler hocam.

    YanıtlayınSil
  2. Yeniliklere adaptasyonu hızlandırıyorsa ne mutlu. Takip ettiğin için ben de teşekkür ederim.

    YanıtlayınSil